fpx · stats
StatSheet (la entidad: base + nivel + fuentes de gear → snapshot), derivados en orden fijo, fórmulas de impacto/daño/crítico/cura y perfil de balance versionado.
El ZIP incluye: addons/fpx/stats + sus tests + data/balance/default.tres.
Qué es
Atributos y fórmulas para CUALQUIER entidad (jugador, NPC, mob): 7 atributos base (StatBlock.ATTRS), pipeline de derivados en orden fijo (efectivo = base + planos de equipo → breakpoints SOLO sobre base → fórmulas → bonos planos) hacia un snapshot DerivedStats que jamás se persiste; GearBonus como vocabulario único de bonos (equipo/sets/pasivas); CombatMath con las fórmulas puras (impacto, evasión con caps blando/duro, daño con crítico saturado y suelo de mitigación, cura, refilón, daño esperado, taper por nivel); BalanceProfileDefinition = TODOS los coeficientes y caps como un .tres versionado. StatSheet (FK-4) es la entidad: base + nivel + fuentes de gear con nombre → snapshot().
Instalación
- Descarga
fpx-stats-v0.2.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/
│ └── 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/stats.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# El ejemplo canónico compartido con Stremus: STR 45 + arma +40 @ nivel 10.
# StatSheet es la entidad: atributos base + nivel + fuentes de gear CON NOMBRE.
var sheet := StatSheet.new(StatFixtures.canonical_base(), 10)
sheet.set_source(&"equipment", StatFixtures.canonical_gear())
# El snapshot JAMÁS se persiste: se deriva y se recalcula.
var stats: DerivedStats = sheet.snapshot()
assert(stats.atk_melee == 150) # 2×45 + 5×⌊45/10⌋ + 40 — contrato numérico
# Cambiar el equipo = cambiar la fuente; el siguiente snapshot ya lo refleja.
sheet.remove_source(&"equipment")
sheet.set_level(11)
# Fórmulas puras de combate (coeficientes del perfil de balance activo).
var hit := CombatMath.hit_chance(stats.precision_pct, 25.0)
var heal := CombatMath.heal_amount(30, 10, stats.atk_magic)
var dmg := CombatMath.damage(
35, 10, stats.atk_melee, # potencia, nivel, ATK
stats.elem_damage_pct, 0.0, # % elemental del atacante / resist del defensor
true, stats.crit_damage_pct, # ¿crítico? + multiplicador bruto (satura dentro)
12, # mitigación del defensor (def_phys o barrier_mag)
)
# Todos los coeficientes y caps viven en un .tres versionado; en sandbox
# se inyecta un perfil sin tocar disco.
var profile := BalanceProfileDefinition.active()
BalanceProfileDefinition.set_active(profile)
# Productores de referencia del harness (jugador/mob tipo por nivel).
var mob := ArchetypeBuilds.mob_stats(12)Demo 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 |
|---|---|
StatSheet | new(base, level).with_gear(g).set_source(&"equipment", g).remove_source(id).snapshot() -> DerivedStats · total_gear() · set_level · set_base_attr |
StatBlock | recompute(base, gear, level) -> DerivedStats (el motor) · point_cost(base) (fachada; los tramos viven en progression) |
DerivedStats | el snapshot (max_hp, ap, mp, atk_*, precisión, evasión, crítico, def/barrera, resistencias, xp/drop…) |
GearBonus | none() · apply_modifiers(Dictionary) · add(other) · FIELD_KEYS |
CombatMath | hit_chance · effective_evasion · damage · effective_crit_damage · heal_amount · graze_damage · level_power_multiplier · expected_damage |
BalanceProfileDefinition | active()/set_active()/reset_active(); todos los coeficientes exportados |
ArchetypeBuilds | player_stats(level) / mob_stats(level): productor de referencia del harness |
Firmas completas (del código)
ArchetypeBuildsextends RefCountedarchetype_builds.gd
Arquetipos de referencia del balance v1.2 (E1-DSGN-01): la ÚNICA fuente de los builds usados por el harness (tools/balance_report.gd) y por las fixtures del demo. Los presupuestos de gear por tramo son la guía para autorar items en E2 (pruebas_y_balanceo_formulas.md (§10.1)).
static func player_stats(level: int) -> DerivedStatsPJ Swordsman-like: 60 % STR / 30 % CON / 10 % AGI + gear de tramo.
static func mob_stats(level: int) -> DerivedStatsMob parejo: 40 % STR / 40 % CON / 20 % AGI + gear más pobre. Su daño se autora al ~50 % del PJ (regla §16 v1.2) vía la potencia de sus acciones.
BalanceProfileDefinitionextends Resourcebalance_profile_definition.gd
Perfil de balance VERSIONADO del combate (ED-7.0, plan_editor_contenido.md §5): TODAS las constantes de balance del catálogo de fórmulas (balance §4.2) como campos de UN solo .tres de contenido. El núcleo (CombatMath, CombatState, StatBlock, UnitState) las LEE de aquí — dejan de ser números sueltos repartidos por el código. REGLA DURA #4 · determinismo del espejo online: el perfil es CONTENIDO de la build, igual que un .tres de skill. Cliente y zone server cargan el MISMO archivo por la MISMA ruta (PROFILE_PATH), así que jamás hay dos balances en la misma partida; un perfil «por servidor» sin redeploy de ambos está PROHIBIDO. Por eso el default de ESTE esquema es el valor de PRODUCCIÓN EXACTO: si el archivo faltara, el fallback new() reproduce producción byte a byte. INVARIANTE: defaults del esquema == data/balance/default.tres. PERFIL v2 (decisión del diseñador, 2026-07-29): críticos más fuertes con saturación asintótica (D2/D3), resistencias al 50 % y derivadas de CON (D4/D6), HP mucho más alto, ataque básico más débil y suelo de mitigación (D5). Los defaults de abajo SON v2; el «v1 exacto» de ED-7.0 quedó atrás. Coste de mover un valor: invalida esperados numéricos de tests/combat (daño de Tajo, curva de PV, caps de evasión…). La política es re-esperarlos EN EL MISMO commit que mueve el perfil — el diff de balance y el de tests viajan juntos (plan §5.3). tests/combat/balance_profile_test.gd es quien lo cobra. Superficie de edición: el dominio «Balance» del Editor de Contenido (ED-7.1), nunca a mano en el código. El simulador de escenarios (ED-7.2) trabaja sobre una copia SANDBOX del perfil vía set_active() / reset_active().
const PROFILE_PATH := "res://data/balance/default.tres"static func active() -> BalanceProfileDefinitionEl perfil ACTIVO (carga perezosa de PROFILE_PATH la primera vez). Sin archivo —o con un .tres que no resuelve a este esquema— devuelve los defaults del esquema, que SON los valores de producción (v2): el aviso es para el diseñador, no una degradación de la resolución.
static func set_active(profile: BalanceProfileDefinition) -> voidFija un perfil SANDBOX (ED-7.2 y tests): a partir de aquí la resolución lee por él. Quien lo llama es responsable de reset_active() al terminar — un sandbox filtrado a producción rompería el espejo (REGLA DURA #4). null equivale a reset_active().
static func reset_active() -> voidVuelve al perfil de la build (la próxima lectura recarga PROFILE_PATH).
@export var evasion_soft_cap: float = 60.0Por encima de este % de evasión BRUTA cada punto rinde evasion_soft_factor.
@export var evasion_soft_factor: float = 0.35@export var evasion_hard_cap: float = 90.0Techo ABSOLUTO de la evasión efectiva: ningún build esquiva más que esto.
@export var hit_chance_floor: float = 10.0Clamp de la chance de impacto. El piso (v2: 10 %) es lo que hace viables los dodge builds sin volverlos negación; el techo evita que la precisión compre inmunidad al fallo.
@export var hit_chance_ceiling: float = 100.0@export var graze_damage_pct: float = 0.30Esquiva al 70 % (P-71): el golpe esquivado NO se anula — entra a REFILÓN con este % del daño normal SIN crítico. La zona detiene-proyectiles no es esquiva (bloqueo duro) y jamás pasa por aquí.
@export var level_taper_pivot: int = 20Hasta este nivel el multiplicador es EXACTAMENTE ×(1+nivel/10) de §6 v1.2 (el SP vive en Lv1-20: intacto byte a byte). Por encima crece como raíz para que el HP —~lineal— no quede sepultado por el daño (TTK-1, hallazgo F3-MMO).
@export var level_taper_exponent: float = 0.5@export var basic_attack_power: int = 10Potencia del ataque básico de los MOBS (AttackAction); el del jugador es un .tres universal (P-05) con esta misma potencia.
@export var push_collision_power: int = 10Daño de colisión por celda NO recorrida de un empuje/atracción bloqueado (P-03). Lo mitiga res_push_pct; el bloqueador no recibe daño en v1.
@export var terrain_hazard_hp_pct: float = 0.15Fracción de PV MÁX que quema una celda de PELIGRO del terreno (FLAG-MP-J): terminar el turno encima o entrar por empuje/atracción. Piso duro de 1.
@export var max_zones: int = 8Tope de zonas persistentes simultáneas (CLS-3): crear una por encima EXPULSA la más antigua (FIFO).
@export var base_ap_budget: int = 6Presupuesto base de PA (§1): referencia para convertir un % de cesión de PA en puntos (lo usa el puente de datos de las clases MMO).
@export var defend_reduction_pct: float = 50.0Reducción de daño de Defender (§5, v2: 50 %), antes del bonus de Baluarte (P-25). Es una etapa APARTE del buff &"damage_reduction_pct" (que tiene su propio damage_reduction_cap): no se suman, gana el mayor (FLAG:GUARD-CAP).
@export var damage_reduction_cap: float = 50.0D8 · Techo del buff/debuff &"damage_reduction_pct" (etapa de guardia): sin él, apilar fuentes de reducción porcentual compraba inmunidad. NO afecta a la acción Defender, que compone como hoy (FLAG:GUARD-CAP en guard_reduced).
@export var detenido_ap_malus: int = 2Malus de PA de &"detenido" (CLS-3, Detención Absoluta): se descuenta del presupuesto del turno mientras dure, además del PM = 0.
@export var quemado_tick_pct: float = 0.25Catálogo de DoTs: % del ATK SNAPSHOT del aplicador por tick y stacks máximos. Dormido/Paralizado entran en E2. &"provocado"/&"inmovilizado"/&"detenido" no aparecen: sin tick y sin stacks — los defaults (0.0 / 1) de status_tick_pct()/status_max_stacks() ya los cubren. v2: los DoTs pasan de «un golpe gordo» a presión sostenida — Quemado apila hasta 5 y Veneno cambia 1 stack del 30 % por 5 stacks del 15 %.
@export var quemado_max_stacks: int = 5@export var envenenado_tick_pct: float = 0.15@export var envenenado_max_stacks: int = 5@export var hp_base: float = 100.0Base 100 (v2): la subida de PV es la contraparte de los críticos fuertes de D2/D3 — sin ella un crítico saturado se acerca al one-shot. (v1.2 partía de 70 porque con 25 el PJ moría en 2 golpes a nivel 1, F-2 de la validación v0.)
@export var hp_per_level: float = 20.0@export var hp_per_con: float = 12.0@export var hp_breakpoint_pct: float = 0.02Breakpoint de CON: +2 % de PV por cada 10 puntos de CON BASE (F-4).
@export var ap_base: int = 6@export var ap_cap: int = 8@export var mp_base: int = 3@export var mp_cap: int = 5@export var atk_per_stat: int = 2Los tres ATK (y el defensivo de CLS-2) comparten forma: N×atributo efectivo + M×breakpoint de la base.
@export var atk_per_breakpoint: int = 5@export var precision_base: float = 100.0@export var precision_per_dex: float = 0.20@export var precision_cap: float = 130.0Techo de precisión POR ENCIMA de 100 (v2): la precisión ya no compra inmunidad al fallo (hit_chance_ceiling sigue en 100) — compra margen contra la evasión, que ahora escala el doble por AGI/LUK.
@export var evasion_per_agi: float = 0.20@export var evasion_per_luk: float = 0.10@export var evasion_per_breakpoint: float = 1.0Breakpoint de AGI: +1 % de evasión por cada 10 puntos de AGI BASE (§2, F-4).
@export var crit_base: float = 5.0@export var crit_per_luk: float = 0.20@export var crit_cap: float = 100.0D3 · Techo de la CHANCE de crítico. Al llegar aquí el crit deja de ser un techo muerto: el exceso se convierte en daño crítico (crit_excess_to_damage).
@export var crit_damage_base: float = 200.0@export var crit_damage_per_breakpoint: float = 2.0@export var crit_damage_cap: float = 500.0D2 · SOFT cap del multiplicador crítico: hasta aquí el valor crudo pasa tal cual; por encima, CombatMath.effective_crit_damage() lo satura hacia crit_damage_asymptote. Ya NO es un minf en el snapshot de StatBlock.
@export var crit_damage_asymptote: float = 800.0D2 · Asíntota del multiplicador crítico: por muchos que apiles (equipo, buffs, exceso de crit de D3) el multiplicador EFECTIVO nunca llega aquí. Debe ser > crit_damage_cap o la curva de saturación se degenera.
@export var crit_excess_to_damage: float = 1.0D3 · Puntos de daño crítico (%) por cada punto de CHANCE de crítico por encima de crit_cap. 1.0 = conversión 1:1. Solo convierte el crit del SNAPSHOT (StatBlock); los buffs en caliente no (FLAG:CRIT-EXCESS-BUFFS).
@export var crit_damage_reduce_per_con: float = 0.1D4 · Crit Damage Reduce: % de la PARTE crítica del golpe que el defensor recorta, por punto de CON efectiva (+ los planos del equipo).
@export var crit_damage_reduce_cap: float = 50.0@export var res_status_cap: float = 50.0Caps del SNAPSHOT: buffed() suma en crudo y NO los re-aplica — un buff puede superarlos (regla de pipeline §3). v2: las resistencias bajan de 75 a 50 (una res al 75 % convertía builds enteros en inmunes).
@export var res_push_cap: float = 50.0@export var elem_resist_cap: float = 50.0@export var drop_cap: float = 100.0@export var res_per_con: float = 0.1D6 · Resistencias por CON: res_push_pct y elem_resist_pct dejan de ser «solo equipo» y ganan este % por punto de CON efectiva (cada una contra su propio cap). res_status_pct NO usa este coeficiente: mantiene su fórmula histórica 0.10×CON + 0.10×LUK (§3), ahora contra el cap de 50.
@export var xp_band_min_pct: float = 0.8C2 · La XP de victoria deja de ser FIJA (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»). La suma de xp_reward de los mobs derrotados se convierte en una BANDA y cada jugador tira la suya: 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_final = máx(1, ⌊randi_range(XP_min, XP_max) × mult_de_stat⌋) mult_de_stat es el +%XP de WIS del jugador (F-4, DerivedStats.xp_bonus_pct → 1 + pct/100) — el MISMO stat que mueve el +%drop del botín (R4). La resolución vive en RewardRoll.roll_xp. FLAG-XP-BANDA (default ±20 %, ratificación asíncrona): con min = max = 1.0 la fórmula colapsa EXACTAMENTE en la vieja (XP fija) — mover estos dos números es la única palanca del diseñador. data/balance/default.tres aún no trae las claves: al faltar, el .tres cae en estos defaults (invariante del esquema intacta).
@export var xp_band_max_pct: float = 1.2@export var mitigation_max_pct: float = 50.0D5 · Suelo de la mitigación FÍSICA/mágica por rating: la curva natural ×100/(100+rating) nunca reduce más que este %. A rating 100 la curva ya daba exactamente 50 %, así que este techo solo recorta a los builds de rating alto.
func status_tick_pct(status_id: StringName) -> float% del ATK snapshot por tick de un estado (0.0 = no es DoT).
func status_max_stacks(status_id: StringName) -> intStacks máximos de un estado (1 = reaplicar solo refresca la duración).
CombatMathextends RefCountedcombat_math.gd
Fórmula de impacto y daño de sistemas_core §6 (v1.2, resolución de F-1/F-2 de la validación E1-DSGN-01), como funciones estáticas puras. PRINCIPIO DOFUS (§6): el preview del HUD y la resolución usan exactamente estas funciones — jamás dos implementaciones. ED-7.0: las constantes de balance salen del PERFIL versionado (BalanceProfileDefinition, data/balance/default.tres) — el default del perfil ES el valor de producción, así que aquí no hay literales de balance. Perfil v2 (2026-07-29): tres etapas nuevas del pipeline de daño viven AQUÍ —y no en el snapshot de StatBlock ni en CombatState— precisamente por el principio Dofus: el preview del HUD las ve gratis. · D2 effective_crit_damage(): el crit_damage_cap pasa a SOFT cap y el multiplicador satura hacia crit_damage_asymptote. · D4 crit_damage_reduce_pct: el defensor recorta la PARTE crítica. · D5 mitigation_max_pct: la curva de mitigación tiene suelo.
static func effective_evasion(raw_pct: float) -> floatCap de evasión (§3/§6): soft cap (por encima, cada punto rinde el factor blando) y hard cap absoluto, ambos del perfil de balance.
static func hit_chance(attacker_precision_pct: float, defender_evasion_raw_pct: float) -> floatPaso 1 de §6: chance de impacto, clamp [piso, techo] del perfil. Sin piso alto de precisión: los dodge builds son viables (contra hard cap y DEX~0, ~10 %).
static func graze_damage(normal_damage: int) -> intDaño de refilón sobre un daño normal YA resuelto por damage() (sin crítico): suelo 1, redondeo hacia abajo. La zona detiene-proyectiles NO es esquiva (bloqueo duro, 0 daño) y jamás pasa por aquí.
static func sync_balance_slots() -> voidRe-siembra los slots si el perfil activo CAMBIÓ de instancia. Es pública porque quien conmuta el perfil (el sandbox de ED-7.2, los tests) necesita dejar los slots coherentes en el acto, sin esperar a la próxima resolución.
static func level_power_multiplier(level: int) -> floatMultiplicador de potencia por nivel (§6 paso 2, v1.3). ≤ pivote: lineal v1.2 exacto. > pivote: media geométrica ponderada del multiplicador del pivote y el lineal (continua en el pivote; pendiente decreciente, monótona; las skills pesadas siguen pegando más fuerte).
static func effective_power(potencia: int, level: int) -> floatPotencia efectiva (F-1 resuelto, §6 v1.2; suavizado a nivel alto v1.3): la potencia declarada de la skill escala con el nivel del portador — sin esto, la mitigación se come el crecimiento del ATK y el TTK diverge (validación v0, F-1). El suavizado (level_power_multiplier) solo actúa por encima del pivote.
static func damage(potencia: int, level: int, atk: int, elem_damage_pct: float, elem_resist_pct: float, is_crit: bool, crit_damage_pct: float, mitigation_rating: int, element: StringName = &"neutral", crit_damage_reduce_pct: float = 0.0) -> intPasos 2-6 de §6 v1.2, redondeando SOLO al final: base = potencia_efectiva × (1 + ATK/100) → elemental → crítico → mitigación MULTIPLICATIVA ×100/(100+DEF) → suelo 1. El crítico multiplica antes de la mitigación; con rating multiplicativo la armadura reduce el golpe crítico en la misma proporción (identidad intacta). R2/§4 «Neutral ni se potencia ni se resiste» (opción B, plan §2.1): un golpe &"neutral" IGNORA tanto la potenciación (%daño elemental) COMO la resistencia elemental global; los elementos (fuego/agua/…) aplican ambas. El pase mayor —matriz de %elem POR elemento (opción A del plan)— es decisión del diseñador y NO se implementa aquí. v2 · D2/D4/D5: el crítico pasa por effective_crit_damage() (saturación asintótica) y luego por el Crit Damage Reduce del DEFENSOR; la mitigación tiene suelo (mitigation_max_pct). Las tres etapas están DENTRO de esta función a propósito — el preview del HUD las hereda sin duplicar nada.
static func effective_crit_damage(raw_pct: float) -> floatD2 · Saturación del multiplicador crítico (decisión del diseñador 2026-07-29). crit_damage_cap deja de ser un techo DURO (era un minf en el snapshot de StatBlock, que hacía muerto todo el %crit-damage por encima) y pasa a SOFT cap: hasta ahí el valor crudo entra tal cual; por encima, cada punto rinde cada vez menos y el multiplicador EFECTIVO tiende —sin alcanzarla— a crit_damage_asymptote. eff = raw si raw ≤ soft eff = soft + (asym − soft)·(raw − soft) / ((raw − soft) + (asym − soft)) Continua en el soft cap (en raw = soft el segundo término es 0), monótona creciente y acotada por la asíntota. Un perfil degenerado (asym ≤ soft) cae al comportamiento de techo duro en el soft cap en vez de producir NaN.
static func heal_amount(potencia: int, level: int, atk_magic: int) -> intCuración §2 (R3): potencia efectiva × (1 + ATK mágico/100) — el espejo del paso de daño SIN crítico ni mitigación; suelo 1. Los consumibles quedan planos a propósito (decisión P-56: las pociones no escalan).
static func expected_damage(potencia: int, level: int, attacker: DerivedStats, defender: DerivedStats, physical: bool) -> floatDaño esperado (para el spreadsheet y el preview de rango de daño): hit% × [(1−crit%)×daño + crit%×daño_crítico] + (1−hit%) × refilón (P-71): la esquiva ya no es daño 0 — el término de fallo aporta el 30 % del normal.
DerivedStatsextends RefCountedderived_stats.gd
Snapshot de derivados calculado por StatBlock.recompute() (sistemas_core §3). NUNCA se persiste (§2.2): se deriva y se recalcula. La evasión se guarda BRUTA; los caps soft/hard se aplican en CombatMath al resolver el impacto, para que preview y resolución usen la misma función (§6).
var max_hp: int = 1var ap: int = 6var mp: int = 3var atk_melee: int = 0var atk_ranged: int = 0var atk_magic: int = 0var atk_defensive: int = 0 # escala escudos y defensivas con CON (CLS-2, espejo §3)var precision_pct: float = 100.0 # cap `precision_cap` ya aplicadovar evasion_raw_pct: float = 0.0 # SIN caps (se aplican en CombatMath)var crit_chance_pct: float = 5.0 # cap `crit_cap` ya aplicado (el exceso fue a crit_damage_pct, D3)var crit_damage_pct: float = 200.0Multiplicador crítico BRUTO (v2/D2): ya NO viene capado — el crit_damage_cap es un SOFT cap y la saturación asintótica vive en CombatMath.damage(), para que preview y resolución compartan la curva.
var crit_damage_reduce_pct: float = 0.0D4 · Crit Damage Reduce: % de la parte CRÍTICA del golpe recibido que esta unidad recorta (cap crit_damage_reduce_cap ya aplicado).
var initiative: int = 0var def_phys: int = 0var barrier_mag: int = 0var res_status_pct: float = 0.0 # cap `res_status_cap` ya aplicadovar res_push_pct: float = 0.0 # resistencia al daño de colisión (P-03); cap `res_push_cap`var elem_damage_pct: float = 0.0var elem_resist_pct: float = 0.0 # cap `elem_resist_cap` ya aplicado (v2: + res_per_con×CON, D6)var xp_bonus_pct: float = 0.0var drop_bonus_pct: float = 0.0 # cap +100 ya aplicadovar luck: int = 0LUK efectivo (base + planos de equipo): SOLO para el desempate de iniciativa (§7, R5) — no es un derivado de fórmula.
var range_bonus: int = 0PO (Alcance) — decisión del diseñador 2026-07-29 (D11): celdas EXTRA que la unidad suma al alcance de las skills que declaran range_modifiable (D10). Base 0 y SIN término de atributo: sale SOLO del equipo (GearBonus.range_bonus, pasivas del árbol incluidas) y de los buffs &"range_bonus" (paso 5 de §3). Puede quedar NEGATIVO — un debuff de PO es contenido legal.
GearBonusextends RefCountedgear_bonus.gd
Agregado plano del equipo para el pipeline de StatBlock (sistemas_core §3): entra en el paso 1 (planos de atributo) y en el paso 4 (bonos derivados). En E1 esto se construye sumando los campos de los ItemDefinition equipados; el harness de E1-DSGN-01 lo construye a mano por arquetipo.
var attr_flat: Dictionary[StringName, int] = {} # str/agi/con/luk/spr/dex/wisvar weapon_atk: int = 0 # ATK de arma (melee y distancia, §3)var magic_atk: int = 0 # "+ equipo" del ATK mágicovar armor_def: int = 0 # DEF física planavar barrier_flat: int = 0 # Barrera mágica planavar hp_flat: int = 0var ap_bonus: int = 0 # cap total 8 (§3)var mp_bonus: int = 0 # cap total 5 (§3)var range_bonus: int = 0PO (Alcance, D11 · 2026-07-29): celdas extra de alcance para las skills con range_modifiable. Sin cap en v1 (el contenido lo regula); puede ser negativo.
var precision_pct: float = 0.0var evasion_pct: float = 0.0var crit_chance_pct: float = 0.0var crit_damage_pct: float = 0.0var crit_damage_reduce_pct: float = 0.0D4 (v2) · planos de Crit Damage Reduce: se suman al aporte por CON del derivado antes de su cap (crit_damage_reduce_cap).
var initiative_flat: int = 0var res_status_pct: float = 0.0var res_push_pct: float = 0.0 # resistencia al daño de colisión por empuje (P-03)var elem_damage_pct: float = 0.0 # %daño elemental (Engarces del Brazalete, §4)var elem_resist_pct: float = 0.0 # %res elementalvar xp_pct: float = 0.0var drop_pct: float = 0.0const FIELD_KEYS: Array[StringName] = …Campos numéricos direccionables por clave (§3): los usan las pasivas del árbol (E1-TREE-02) y los modifiers de ItemDefinition (E1-INV-03).
static func none() -> GearBonusfunc apply_modifiers(modifiers: Dictionary[StringName, float]) -> voidSuma un diccionario de modificadores: claves del catálogo §3 (FIELD_KEYS) o atributos §2 (van a attr_flat). Las claves desconocidas (mecánicas de notables, P-25) se ignoran deliberadamente.
func add(other: GearBonus) -> voidSuma otro GearBonus sobre este (fusión árbol + equipo, E1-INV-03).
StatBlockextends RefCountedstat_block.gd
Pipeline de derivados de sistemas_core §3, en su orden fijo (determinista y testeable headless, §18): (1) atributo efectivo = base + planos de equipo; (2) breakpoints SOLO sobre la base; (3) derivados por fórmula con el atributo efectivo; (4) bonos derivados planos del equipo; los buffs de combate (paso 5) modifican el snapshot al aplicarse (E1-SKL-04). Los nodos del árbol (D-11) entran en E1-TREE: aquí queda el hueco declarado. F-4 resuelto (v1.2): los breakpoints de AGI (+1 % evasión /10 base) y WIS (+2 % XP, +1 % drop /10 base) prometidos en §2 se AÑADIERON a las fórmulas de §3 (decisión del diseñador, 2026-07-07). Perfil v2 (2026-07-29): §3 gana el Crit Damage Reduce (D4), la conversión del EXCESO de chance de crítico en daño crítico (D3) y el aporte de CON a las resistencias de empuje/elemental (D6). El multiplicador crítico ya no se capa aquí — su saturación vive en CombatMath (D2). ED-7.0: los coeficientes y caps de §3 salen del PERFIL de balance (BalanceProfileDefinition) — su default ES el valor de producción, así que aquí no hay literales de balance. La firma pública de recompute() se mantiene en 3 argumentos: el perfil NO es un parámetro (el sandbox de ED-7.2 lo conmuta con BalanceProfileDefinition.set_active(), no pasándolo por cada llamada).
const ATTRS: Array[StringName] = [&"str", &"agi", &"con", &"luk", &"spr", &"dex", &"wis"]static func recompute(base: Dictionary[StringName, int], gear: GearBonus, level: int) -> DerivedStatsBase = valores base persistidos (default 1, §2). Nivel = nivel DENTRO del job actual (§8). El resultado nunca se persiste (§2.2).
static func point_cost(current_base: int) -> intCoste en puntos de subir un atributo BASE en +1 según el tramo (§2.1).
StatSheetextends RefCountedstat_sheet.gd
FK-4 · LA ENTIDAD del componente stats (D-FW-5, plan §6.2): base + nivel + FUENTES de gear con nombre → snapshot(). Es la fachada que unifica a los tres productores del ejemplar, que hoy arman base+gear+nivel cada uno a su manera y llaman StatBlock.recompute: · CharacterProgress.derived() (jugador: base persistida + árbol + equipo) · MobDefinition.build_stats() (mob: gear implícito weapon_atk/armor_def/hp_flat) · ArchetypeBuilds.player_stats/mob_stats (arquetipos del harness) Todos podrían reescribirse como StatSheet.new(base, level).with_gear(...) sin mover un número: el snapshot es EXACTAMENTE StatBlock.recompute sobre la suma de fuentes (aditiva, orden irrelevante). Las fuentes tienen id (&"equipment", &"sets", &"passives", …) para poder reemplazar UNA sin recomponer el resto; with_gear añade una anónima. El snapshot jamás se persiste (§2.2 del ejemplar): se recalcula siempre.
var base: Dictionary[StringName, int] = {}var level: int = 1StatSheet.new(base_attrs: Dictionary[StringName, int] = {}, start_level: int = 1)Constructor.
func with_gear(gear: GearBonus) -> StatSheetEncadenable: StatSheet.new(base, 10).with_gear(gear).snapshot().
func set_source(id: StringName, gear: GearBonus) -> StatSheetFuente con nombre (reemplaza la anterior del mismo id). Encadenable.
func remove_source(id: StringName) -> StatSheetfunc has_source(id: StringName) -> boolfunc source_ids() -> Array[StringName]func set_level(new_level: int) -> StatSheetfunc set_base_attr(attr: StringName, value: int) -> StatSheetfunc total_gear() -> GearBonusSuma de TODAS las fuentes (mismo vocabulario que GearBonus.add).
func snapshot() -> DerivedStatsDerivados frescos: StatBlock.recompute(base, total_gear(), level).
Qué configura el juego
Su data/balance/default.tres (ruta BalanceProfileDefinition.PROFILE_PATH) o set_active() en sandbox/tests. Sin archivo, los defaults del esquema son producción v2 de Stremus (data/balance/default.tres del kit no tiene overrides).
Fixture mínima
testing/stat_fixtures.gd — StatFixtures.canonical_base()/canonical_gear()/canonical_stats() (el ejemplo canónico) y base(overrides).
stat_fixtures.gdcódigo completo
# res://addons/fpx/stats/testing/stat_fixtures.gd
class_name StatFixtures
extends RefCounted
## Fixture mínima del componente `stats`: el EJEMPLO CANÓNICO de sistemas_core
## §6 de Stremus (STR 45, arma +40, nivel 10 → `atk_melee == 150`), que es el
## contrato numérico compartido con Stremus (COMO-EMPEZAR.md §3 regla 3).
## Todo en memoria: ni perfil `.tres` ni contenido del juego.
const CANONICAL_LEVEL := 10
const CANONICAL_ATK_MELEE := 150 # 2×45 + 5×⌊45/10⌋ + 40
## Base con los 7 atributos a 1 y los `overrides` aplicados.
static func base(overrides: Dictionary[StringName, int] = {}) -> Dictionary[StringName, int]:
var out: Dictionary[StringName, int] = {}
for attr: StringName in StatBlock.ATTRS:
out[attr] = int(overrides.get(attr, 1))
return out
## Base del ejemplo canónico: STR 45, resto 1.
static func canonical_base() -> Dictionary[StringName, int]:
return base({&"str": 45})
## Gear del ejemplo canónico: solo un arma +40.
static func canonical_gear() -> GearBonus:
var gear := GearBonus.new()
gear.weapon_atk = 40
return gear
## Snapshot del ejemplo canónico (nivel 10).
static func canonical_stats() -> DerivedStats:
return StatBlock.recompute(canonical_base(), canonical_gear(), CANONICAL_LEVEL)Cómo se prueba
tests/stats/ — stat_block_test (ejemplos resueltos del doc, breakpoints, caps), combat_math_taper_test (defaults de producción congelados), stat_fixtures_test, stat_sheet_test (equivalencia con los productores, fuentes con nombre, conmutatividad).
Ejemplar oro (Stremus)
scripts/core/entities/, scripts/core/combat/combat_math.gd, scripts/resources/balance_profile_definition.gd (main@0be95b4). Los tres productores de base+gear+nivel que StatSheet unifica: CharacterProgress.derived() (jugador), MobDefinition.build_stats() (mob, mob_definition.gd:348), ArchetypeBuilds (harness). Contrato numérico compartido: el ejemplo canónico §6 (STR 45 + arma 40 @ Lv10 → atk_melee == 150).
Deuda declarada
- FLAG-STATS-SCHEMA: atributos data-driven solo si un juego exige OTROS atributos (D-FW-4).
- FLAG-STATS-BUFFS: la capa 5 (buffs de combate) vive en
combat(FK-5). - FLAG-STATS-POINTCOST:
StatBlock.point_costsigue en código; el perfil ya existe enprogressiony el retro-porte a fachada se hace en Stremus. - FLAG-STATS-MIXED-PROFILE: un solo perfil mezcla coeficientes de stats con constantes de combate/XP; se mantiene (un perfil = un balance).
- Tests que se quedan en Stremus:
balance_profile_test(compara con su.tres),attributes_test,damage_preview_test(UI).