fpx · loot
Tablas de botín con tiradas independientes por entrada (modelo D12) y banda de XP por jugador. RNG inyectado: determinista.
El ZIP incluye: addons/fpx/loot, addons/fpx/items, addons/fpx/stats + sus tests + data/balance/default.tres.
Qué es
Botín como datos con tiradas INDEPENDIENTES por entrada (modelo D12): cada entrada tira su dado; el +%drop multiplica su probabilidad y el exceso sobre 100 % se convierte en copias garantizadas; chance 0 jamás cae; oro en banda aparte. RewardRoll: XP de victoria en banda aleatoria por jugador (min/max % del perfil de balance) con el multiplicador por stat en un ÚNICO punto (xp_multiplier_of). Todo con RNG inyectado: determinista, reproducible por semilla; el orden de las entradas es contrato.
Instalación
- Descarga
fpx-loot-v0.1.0.zip(botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ ├── loot/
│ ├── items/
│ └── stats/
└── tests/ ← opcional: bórralo si no quieres los tests- Abre el proyecto (o corre
--importheadless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script. - Opcional — verifica con la suite (necesita GdUnit4, ver la guía):
-a tests/loot.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# La tabla dorada de la fixture: hierba 100 % ×1..3 · poción 50 % ·
# reliquia 0 % (jamás cae) · oro 10..20. Tus tablas reales son .tres.
var table := LootFixtures.golden_table()
# El RNG se INYECTA: misma semilla → misma tirada (espejo cliente/servidor).
var rng := LootFixtures.seeded_rng(7)
# Modelo D12: cada entrada tira su PROPIO dado; el +%drop del jugador
# multiplica la probabilidad y el exceso sobre 100 % son copias garantizadas.
var drops := table.roll_drops(rng, stats.drop_bonus_pct) # [{item, count}, …]
var gold := table.roll_gold(rng)
for drop: Dictionary in drops:
inv.add_item(drop["item"], drop["count"])
# Para el HUD/preview sin tirar el dado:
var chance := table.final_chance_pct(1, 25.0) # poción con +25 % drop
var copies := table.expected_copies(0, 150.0) # hierba con +150 % drop
# XP de victoria: banda aleatoria por jugador (min/max % del perfil de
# balance), multiplicada por el stat en UN único punto.
var xp := RewardRoll.roll_xp(120, rng, RewardRoll.xp_multiplier_of(stats))
var band := RewardRoll.xp_band(120) # Vector2i(min, max) para mostrar el rangoDemo en vivo
El componente REAL corriendo en tu navegador — Godot 4.7 exportado a Web sobre las fixtures del kit. La primera carga baja el motor (~10 MB comprimido) y se comparte entre todas las demos.
API pública
| Clase | Qué da |
|---|---|
LootTableDefinition (Resource) | entries, gold_min/max · roll_drops(rng, drop_bonus_pct) -> [{item, count}] · final_chance_pct(i, bonus) · expected_copies(i, bonus) · roll_gold(rng) |
LootEntryDefinition (Resource) | item, chance_pct, count_min/max |
RewardRoll | roll_xp(base, rng, multiplier, profile) · xp_band(base, profile) -> Vector2i · xp_multiplier_of(DerivedStats) · XP_HARD_CAP |
Firmas completas (del código)
LootEntryDefinitionextends Resourceloot_entry_definition.gd
Entrada de una loot table (E1-LOOT-01) bajo el modelo de TIRADAS INDEPENDIENTES POR ÍTEM (D12, decisión del diseñador 2026-07-29): cada entrada tiene SU PROPIO dado y no compite con las demás — dos entradas pueden caer en la misma pelea. BREAKING (balance-v2): desaparecieron weight (peso relativo de la ruleta exclusiva v1) y, en la tabla, rolls. Ya no existen entradas "nada" (item = null): la probabilidad de irse con las manos vacías es EMERGENTE — el producto de los (1 − chance) de todas las entradas. Una entrada sin ítem no hace nada y el validador la marca como aviso.
@export var item: ItemDefinition = null@export var chance_pct: float = 100.0Probabilidad BASE de que ESTA entrada caiga, en % (D12). Independiente del resto de la tabla. Admite valores > 100: el motor los convierte en copias garantizadas (250 % = 2 copias seguras + 50 % de una tercera). 0 = jamás cae.
@export var count_min: int = 1@export var count_max: int = 1LootTableDefinitionextends Resourceloot_table_definition.gd
Loot table como datos (E1-LOOT-01, A10) bajo el modelo de TIRADAS INDEPENDIENTES POR ÍTEM (D12, decisión del diseñador 2026-07-29). Ya NO es una ruleta exclusiva ponderada: cada entrada tira SU PROPIO dado, así que en una misma pelea pueden caer varios ítems a la vez. El rango de oro va aparte (roll_gold, sin cambios). El RNG se INYECTA (la seed del combate): dos combates con la misma seed sueltan lo mismo — determinista y testeable headless (§18); las entradas se recorren en el ORDEN FIJO del array, de lo que depende el espejo online (REGLA DURA #4). BREAKING (balance-v2): desaparecieron rolls (nº de tiradas de la ruleta) y LootEntryDefinition.weight. Migración de v1 → v2 por entrada: chance_pct ≈ rolls × weight / suma_de_pesos × 100 y las entradas "nada" (item = null) se BORRAN: P(manos vacías) ya no se autora, es el producto de los (1 − chance) de todas las entradas.
@export var id: StringName@export var entries: Array[LootEntryDefinition] = []@export var gold_min: int = 0@export var gold_max: int = 0func roll_drops(rng: RandomNumberGenerator, drop_bonus_pct: float = 0.0) -> Array[Dictionary]Tira la tabla: [{ "item": ItemDefinition, "count": int }] (sin entradas nulas). D12 — para CADA entrada, de forma INDEPENDIENTE y en el orden del array: Probabilidad_Final = chance_pct × (1 + drop_bonus_pct / 100) Copias_Garantizadas = ⌊Probabilidad_Final / 100⌋ Chance_copia_extra = Probabilidad_Final mod 100 (una Bernoulli más) Es decir: el +%drop de WIS (R4, §2 — cap 100, ya aplicado en DerivedStats) MULTIPLICA la probabilidad de cada ítem, y el exceso por encima del 100 % se convierte en copias (double/triple drop) en vez de desperdiciarse. Cada copia rueda su propio count_min..count_max y sale como un stack aparte (quien acumula ya funde los stacks del mismo ítem: CombatState._spawn_loot). Determinismo: solo se consume RNG donde hace falta — la Bernoulli extra se salta si el resto es exactamente 0 (p. ej. chance 100 sin bonus, o 200), y el count solo se tira por copia realmente concedida.
func final_chance_pct(index: int, drop_bonus_pct: float = 0.0) -> floatProbabilidad final (en %, SIN capar a 100) de la entrada index con el +%drop dado — el número que el simulador del editor contrasta con lo medido.
func expected_copies(index: int, drop_bonus_pct: float = 0.0) -> floatNº ESPERADO de copias de la entrada index por muerte (D12): las copias garantizadas más la esperanza de la Bernoulli extra = Probabilidad_Final/100.
func roll_gold(rng: RandomNumberGenerator) -> intRewardRollextends RefCountedreward_roll.gd
C2 · Tirada de la RECOMPENSA de un combate, POR JUGADOR (decisión del diseñador 2026-08-04): «la experiencia ganada varía por usuario: es un random no fija, de un mínimo y máximo, según aplique el stat correspondiente; y en la pantalla de victoria debe mostrarse la barra de exp acorde a lo que gana CADA UNO. Así mismo con el loot: no es lo mismo para cada uno.» Núcleo PURO (A5/P4): sin señales, sin autoloads y con el RNG INYECTADO — el cliente, el espejo online y el zone server tiran por el MISMO código. Los dos números de la banda son CONTENIDO del perfil de balance versionado (BalanceProfileDefinition.xp_band_min_pct/max_pct, FLAG-XP-BANDA), nunca constantes sueltas aquí. FÓRMULA (catálogo de balance §8, balance_profile_definition.gd): XP_min = ⌊xp_base × xp_band_min_pct⌋ (piso 1 con xp_base > 0) XP_max = ⌈xp_base × xp_band_max_pct⌉ (nunca por debajo de XP_min) XP_tirada= randi_range(XP_min, XP_max) (uniforme, SEMBRADA) XP_final = máx(1, ⌊XP_tirada × xp_multiplier⌋) xp_multiplier es el ENGANCHE del stat: hoy lo mueve el +%XP de WIS (F-4, DerivedStats.xp_bonus_pct), el MISMO stat que reparte el +%drop del botín (R4, §2) — de ahí que la banda y el botín "varíen por usuario" con la misma palanca. xp_multiplier_of() es el único sitio donde se decide QUÉ stat lo mueve: cambiar de stat es cambiar esa función, no la fórmula. COMPATIBILIDAD: con banda [1.0, 1.0] la fórmula devuelve ⌊xp_base × xp_multiplier⌋ — EXACTAMENTE la XP fija de antes de C2. El BOTÍN no necesita fórmula nueva: sigue siendo la tirada D12 de LootTableDefinition.roll_drops (§10.6) — lo que C2 cambia es QUIÉN la tira (cada jugador, con SU +%drop) y eso vive en CombatState._spawn_loot.
static func roll_xp(xp_base: int, rng: RandomNumberGenerator, xp_multiplier: float = 1.0, profile: BalanceProfileDefinition = null) -> intXP de victoria de UN jugador. rng se consume SIEMPRE que haya XP base (el determinismo del espejo depende de cuántos dados se tiran, no de su valor). Devuelve 0 si no hay nada que repartir (derrota, o mobs sin xp_reward).
const XP_HARD_CAP: int = 2_000_000_000Techo DURO de la banda: Vector2i es int32 y un porcentaje disparatado en el perfil (o un xp_base absurdo del contenido) desbordaría en silencio y devolvería una banda NEGATIVA. Saturar es la respuesta honesta: 2 000 000 000 de XP es varios órdenes por encima de cualquier tabla de nivel del juego, así que el clamp nunca muerde una partida real. El gate del perfil (ED-7.1) avisa de los porcentajes fuera de rango ANTES de guardar; esto es el cinturón.
static func xp_band(xp_base: int, profile: BalanceProfileDefinition = null) -> Vector2iExtremos [mín, máx] de la banda de xp_base con el perfil dado — el mismo intervalo que roll_xp muestrea. Lo consultan los tests y el simulador de escenarios del editor (nadie recalcula los redondeos por su cuenta). Saturado a XP_HARD_CAP y con máx >= mín SIEMPRE (una banda invertida por un typo del diseñador colapsa en el mínimo, no revienta randi_range).
static func xp_multiplier_of(stats: DerivedStats) -> floatENGANCHE del stat sobre la XP (§8, F-4): +%XP de WIS → multiplicador. Sin stats (fallback E0, mobs de prueba) vale 1.0 — la banda a secas. FLAG-XP-STAT (resuelto con el vocabulario v2 que YA existe): el stat es WIS vía DerivedStats.xp_bonus_pct, el mismo que da el +%drop (R4). No se inventó ninguna derivada nueva; si el diseñador quiere moverlo a otra, se cambia AQUÍ.
Qué configura el juego
Sus tablas .tres; la banda de XP (xp_band_min_pct/max_pct) en su perfil de balance; el reparto en grupo (política del consumidor).
Fixture mínima
testing/loot_fixtures.gd — LootFixtures.golden_table() (garantizado 100 % ×1..3 / probable 50 % / imposible 0 %, oro 10..20), entry(), seeded_rng(seed).
loot_fixtures.gdcódigo completo
# res://addons/fpx/loot/testing/loot_fixtures.gd
class_name LootFixtures
extends RefCounted
## Fixture mínima del componente `loot`: una tabla dorada EN MEMORIA con los
## tres casos de la regla D12 (garantizado, probable, imposible) y oro en banda,
## más un RNG con semilla para que toda tirada sea reproducible.
static func seeded_rng(rng_seed: int = 7) -> RandomNumberGenerator:
var rng := RandomNumberGenerator.new()
rng.seed = rng_seed
return rng
static func entry(item: ItemDefinition, chance_pct: float,
count_min: int = 1, count_max: int = 1) -> LootEntryDefinition:
var e := LootEntryDefinition.new()
e.item = item
e.chance_pct = chance_pct
e.count_min = count_min
e.count_max = count_max
return e
## Tabla dorada: [0] hierba 100 % ×1..3 · [1] poción 50 % · [2] reliquia 0 %
## (jamás cae, ni con +100 % drop) · oro 10..20.
static func golden_table() -> LootTableDefinition:
var table := LootTableDefinition.new()
table.id = &"tabla_dorada"
table.entries = [
entry(ItemFixtures.material(&"hierba"), 100.0, 1, 3),
entry(ItemFixtures.consumable(&"pocion"), 50.0),
entry(ItemFixtures.quest_item(&"reliquia"), 0.0),
]
table.gold_min = 10
table.gold_max = 20
return tableCómo se prueba
tests/loot/ — reward_roll_test (extremos de la banda, semilla, stat, colapso a fórmula fija), loot_table_test (garantizado/imposible, misma semilla misma tirada, bonus → copias, oro en banda).
Ejemplar oro (Stremus)
scripts/resources/{loot_table,loot_entry}_definition.gd + scripts/core/progression/reward_roll.gd (main@0be95b4). Decisión del diseñador 2026-08-04: XP «random no fija, de un mínimo y máximo, según aplique el stat».
Deuda declarada
- FLAG-LOOT-SPLIT: reparto de grupo = política del consumidor; el ejemplar NO divide.
- Tests que se quedan en Stremus: los de tablas dentro de
CombatState(llegan concombat, FK-5).