fpx · combat
El motor táctico por turnos completo: CombatState determinista con semilla, FSM, acciones como Commands con motivos tipados, eventos, zonas, vínculos, ecos e IA de datos.
El ZIP incluye: addons/fpx/combat, addons/fpx/grid, addons/fpx/stats, addons/fpx/items, addons/fpx/inventory, addons/fpx/loot + sus tests + data/balance/default.tres.
Qué es
El motor táctico por turnos completo del ejemplar: CombatState determinista (RNG con semilla y perfil de balance fijados al construir), CombatStateMachine (FSM de fases), acciones como Commands (Move/Attack/Defend/Skill/UseItem/EndTurn con validate() sin efectos secundarios + execute() y motivos tipados — la MISMA capa que reejecuta el servidor MMO), CombatEvent como único canal hacia la vista, cola de iniciativa, zonas persistentes, vínculos de cesión/redirección (ShareLink/RedirectLink), ecos post-muerte (DeathEcho), EncounterSetup (el contrato de entrada), esquemas de datos SkillDefinition/EffectDefinition/ProcDefinition/BossPhaseDefinition, e IA de datos (AIBehavior: melee agresivo, kiter a distancia, patrón de jefe por fases).
Instalación
- Descarga
fpx-combat-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/
│ ├── combat/
│ ├── grid/
│ ├── stats/
│ ├── items/
│ ├── inventory/
│ └── loot/
└── 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/combat.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# El contrato de entrada es EncounterSetup: tu juego lo construye desde sus
# datos. La fixture trae un 2v2 IA vs IA listo (melee agresivos vs kiters).
var setup := CombatFixtures.sim_encounter()
# CombatState es determinista: RNG con semilla y perfil de balance fijados al
# construir. Misma semilla → misma batalla (el servidor MMO reejecuta ESTA capa).
var sm := CombatStateMachine.new()
sm.events_ready.connect(func(events: Array[CombatEvent]) -> void:
for event: CombatEvent in events:
match event.type:
CombatEvent.Type.DAMAGE_DEALT:
show_damage(event.data["unit_id"], event.data["amount"])
CombatEvent.Type.UNIT_MOVED:
animate_path(event.data["unit_id"], event.data["path"])
CombatEvent.Type.COMBAT_ENDED:
end_screen(event.data["victory"]))
sm.start(setup, 7)
# Acciones como Commands: validate() sin efectos secundarios + execute().
# La MISMA capa corre en cliente y servidor; los motivos son tipados.
var attack := AttackAction.new()
attack.actor_id = 1
attack.target_id = 3
sm.submit_action(attack) # una acción inválida del jugador no pasa;
sm.playback_finished() # la vista avisa cuando terminó de animar
# Turno de la IA: decide con el estado real del tablero.
var actor: UnitState = sm.combat.active_unit()
if actor.ai != null:
sm.submit_action(actor.ai.decide_action(sm.combat, actor.id))
# El botón «Fin de turno» del HUD (el turno del jugador JAMÁS se cierra solo):
sm.end_turn_requested()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 |
|---|---|
EncounterSetup | map_id, map_size, cells, units, hazards, condición de Sostener… — lo que el juego construye |
CombatState | new(setup, rng_seed, …) · apply(action) -> Array[CombatEvent] · get_unit(id) · consultas de tablero/turno · _spawn_loot al cierre |
CombatStateMachine | fases (despliegue, turno del jugador, IA, resolución, fin) |
CombatAction y subclases | validate(state) -> StringName (OK = &"") · execute(state) -> Array[CombatEvent] |
UnitState | make(id, name, is_player, cell, max_hp, …) · hp/ap/mp/stats/skills/procs/buffs; campos del género (push_cells_bonus, share_amount_bonus, boss_*, xp_reward, loot_table) con defaults neutros |
InitiativeQueue | orden de turno estable por iniciativa |
SkillDefinition / EffectDefinition / ProcDefinition / BossPhaseDefinition (Resource) | el vocabulario de habilidades, efectos, procs y fases de jefe |
AIBehavior (+ MeleeAggressiveBehavior, RangedKiterBehavior, BossPatternBehavior) | decide(state, unit) -> CombatAction |
UseItemAction.effects | registro ConsumableEffects (de items) que el juego amplía |
Firmas completas (del código)
CombatEventextends RefCountedcombat_event.gd
Hecho consumado que el núcleo devuelve a la presentación (A5, plan §6.3). La escena de combate los consume en cola a su propio ritmo (tweens, animaciones); NUNCA viajan por EventBus (son intra-combate).
var type: Typevar data: Dictionary # payload por convención: {"unit_id": int, "path": Array[Vector2i], ...}static func make(event_type: Type, payload: Dictionary) -> CombatEventCombatStateextends RefCountedcombat_state.gd
Estado determinista del combate (E0-CBT-01, A5): unidades, grid con ocupación, cola de iniciativa y RNG con seed. RefCounted puro: un test GdUnit4 juega combates completos sin árbol de escena, y dos corridas con la misma seed producen el mismo estado final.
var grid: GridMap2Dvar units: Dictionary[int, UnitState] = {}var queue: InitiativeQueuevar rng := RandomNumberGenerator.new()var round_number: int = 0var balance: BalanceProfileDefinition = nullPerfil de balance del combate (ED-7.0): se fija UNA vez al construirlo, así un combate entero se resuelve con un balance estable aunque el editor conmute el perfil sandbox a mitad de sesión. Cliente y zone server cargan el MISMO .tres por la MISMA ruta ⇒ el espejo online sigue determinista (REGLA DURA #4). Las acciones (AttackAction/SkillAction) y el HUD leen por aquí — jamás un número propio.
var loot_pool: Array[Dictionary] = [] # {"item": ItemDefinition, "count": int}Botín ACUMULADO del combate (E1-LOOT-01, decisión 2026-07-07): cada mob muerto tira su tabla aquí y TODO se entrega al final en la pantalla de resumen (§15.4) — nada de saqueo por celda. La entrega al inventario la resuelve GameState (A5: el núcleo no lo conoce).
var gold_pool: int = 0var loot_by_player: Dictionary[int, Array] = {}C2 · Botín POR JUGADOR (decisión del diseñador 2026-08-04: «con el loot no es lo mismo para cada uno»): {unit_id del jugador: [{"item", "count"}]}. Cada jugador tira las tablas D12 de CADA mob muerto por su cuenta, con SU +%drop de WIS (R4) — no se reparte un pool común. loot_pool sigue siendo el del PRIMER jugador: en single-player y en online-solo ES el suyo (cero regresión), y es lo que viaja como "pool" en el cuerpo base del ENC_END (espectadores incluidos).
var gold_by_player: Dictionary[int, int] = {}C2-GOLD · ORO POR JUGADOR (RATIFICADO 2026-08-05: «el oro también varía por jugador, mata la duplicación»): {unit_id del jugador: oro tirado}. Espejo exacto de loot_by_player — cada uno tira SU roll_gold por cada mob muerto. gold_pool queda como el del PRIMER jugador (agregado informativo del cuerpo base del ENC_END); la ENTREGA de cada cual sale de AQUÍ. QUÉ CAMBIA EN LA ECONOMÍA (para no leerlo como un recorte): antes UNA tirada se acreditaba a N combatientes sin grupo —el mismo oro copiado, que es la duplicación que había que matar—; ahora hay N tiradas y cada una se acredita UNA vez. El faucet total de una batalla abierta sigue creciendo con el número de jugadores; lo que desaparece es que dos personas cobren la MISMA moneda.
var hazard_cells: Dictionary[Vector2i, int] = {}Arena del boss (E1-BOSS-02): celdas ACTIVAS (celda → turnos de boss que le quedan) y celdas MARCADAS que estallarán al próximo turno del boss.
var telegraphed_cells: Array[Vector2i] = []var terrain_hazard_cells: Dictionary[Vector2i, bool] = {}Celdas de PELIGRO ESTÁTICAS del terreno de la arena (FLAG-MP-J, Biblia §4.3) — DISTINTAS de la arena DINÁMICA del boss (hazard_cells, per-turno): son terreno de AUTORÍA (MapDefinition.hazard_cells, vía EncounterSetup). Vacío = arena sin peligros (compat total). NO bloquean paso ni LoS; su daño (10 % PV máx) lo resuelve el NÚCLEO, así el server MMO lo hereda por el espejo determinista. terrain_hazard_element = tipo elemental (tag para el color/texto por bioma).
var terrain_hazard_element: StringName = &""var zones: Array[PersistentZone] = []Zonas persistentes (CLS-3): registradas aquí — la resolución de Commands las consulta y el server MMO hereda las mismas reglas. Expiran en el fin de turno del DUEÑO (resolve_end_of_turn, espejo de buffs §5) y caen si el dueño muere (_release_zones_of, espejo de &"provocado").
var shares: Array[ShareLink] = []Vínculos de cesión (CLS-4, SHARE &"stats_pct"/&"hp_pct"): registrados aquí — la resolución de Commands los consulta y el server MMO hereda las reglas. Se decrementan en el fin de turno del LANZADOR (espejo de zones) y caen si muere el lanzador O el receptor (_release_shares_of: el espejo vuelve).
var redirects: Array[RedirectLink] = []Vínculos de redirección (CLS-4, REDIRECT): apply_damage los consulta para desviar el % del daño final al dueño. Se decrementan en el fin de turno del DUEÑO y caen si muere el dueño o el protegido (_release_redirects_of).
var echoes: Array[DeathEcho] = []Ecos post-muerte (sistema de procs, DeathEcho): registrados al morir el portador de un proc &"on_owner_death". Tickean en el cambio de ronda (begin_round) beneficiando a su bando y, al expirar, pueden levantar al portador (revive_hp_pct). El server MMO hereda la misma resolución.
var sostener_active: bool = falseObjetivo "sostener" (SK-18, Trial de Sostener §6): copiado del EncounterSetup. sostener_active = el combate se gana aguantando, no matando. La condición la resuelven is_over()/result_victory() (victoria = aguantar sostener_rounds rondas con sostener_protected_alive() >= sostener_threshold; derrota = el protegido cae). Fuera de la Trial (rounds = 0) el combate es el de siempre.
var sostener_rounds: int = 0var sostener_protected_ids: Array[int] = []var sostener_threshold: int = 0CombatState.new(setup: EncounterSetup, rng_seed: int)Constructor.
func build_setup_events() -> Array[CombatEvent]func begin_round() -> Array[CombatEvent]func begin_turn() -> Array[CombatEvent]func resolve_end_of_turn() -> Array[CombatEvent]Resolución de efectos post-turno (GDD §2, sistemas_core §5): los DoTs de la unidad activa tickean al FINAL de su propio turno (snapshot, sin críticos) y después expiran duraciones de estados y buffs. Puede matar unidades, por eso la FSM consulta is_over() después de este hook.
func build_end_events() -> Array[CombatEvent]Evento terminal del combate (COMBAT_ENDED) con el desenlace y la recompensa. IDEMPOTENTE por contrato: desde C2 el cierre TIRA dados (la banda de XP de cada jugador), así que la tirada se MEMOIZA — llamar dos veces devuelve la misma XP y no vuelve a mover el RNG del combate. Sin esto, cada consulta del resultado (la FSM, el server al drenar, un test, la depuración) acuñaría XP distinta y desplazaría el stream que el espejo online comparte con la autoridad.
const HAZARD_BLOCKER_ID: int = -999Ocupante centinela del modo &"block": bloquea paso/empuje sin ser unidad.
const MAX_ZONES: int = 8Tope simple de zonas simultáneas (contrato CLS-3): crear una por encima EXPULSA la más antigua (FIFO) con su ZONE_EXPIRED (reason "cap"). ED-7.0: ESPEJO de compilación del campo max_zones del perfil — la resolución lee balance.max_zones (ver balance arriba).
func create_zone(owner_id: int, skill: SkillDefinition, zone_cells: Array[Vector2i]) -> Array[CombatEvent]Registra la zona de un cast (CLS-3): zone_cells son las MISMAS celdas del AoE resuelto (patrón trasladado/rotado por cells_in_aoe — jamás dos implementaciones). La configuración viene del contrato de datos de SkillDefinition (zone_turns / zone_stops_projectiles / zone_mp_malus).
func projectile_stopper(actor: UnitState, target: UnitState) -> PersistentZoneCampo de Anclaje (CLS-3, detener_proyectiles): la zona que INTERCEPTA un golpe ofensivo a distancia, o null si el disparo pasa. Contrato: atacante ENEMIGO del dueño, DESDE FUERA de la zona, contra un objetivo DENTRO, con alcance > 1 — "alcance" se evalúa como la distancia Manhattan REAL atacante→objetivo (semántica elegida: adyacente = melee y pasa, aunque el arma o la skill tengan rango). Ofensivo = bandos cruzados: curar/apoyar a los tuyos jamás se bloquea, y los aliados del dueño nunca son bloqueados (su bando es el de la zona). La llaman AttackAction y SkillAction en la RESOLUCIÓN — la misma capa de Commands que valida el server MMO (patrón CLS-2). El preview (preview_hit) NO la consulta: el aviso "este disparo será detenido" queda declarado como scope de UI.
func projectile_stopped_event(zone: PersistentZone, attacker_id: int, target_id: int) -> CombatEventfunc apply_share(giver_id: int, receiver_id: int, kind: StringName, magnitude: float, duration_turns: int, skill_id: StringName) -> Array[CombatEvent]Cesión con espejo negativo exacto (CLS-4, EffectType.SHARE). La llama SkillAction (la capa de Commands que el server MMO hereda). Variantes: - &"stats_pct": receptor +X / lanzador −X (share_stats_pct, vía vínculo). - &"hp_pct": vínculo que tickea en el TURN_END del lanzador (_share_hp_tick). - &"pa": SIN vínculo — dos buffs &"ap_bonus" que begin_turn presupuesta. Duraciones asimétricas a propósito: el receptor 1 (su próximo TURN_END lo expira tras consumirlo) y el lanzador 2 (su propio TURN_END llega en el MISMO turno del cast y lo decrementaría antes de cobrarse el malus). Los vínculos se ACUMULAN (cooldowns del catálogo regulan la frecuencia; los límites "un vínculo activo" del JSON quedan declarados para F3-MMO).
func apply_redirect(owner_id: int, protected_id: int, pct: float, duration_turns: int, charges: int, skill_id: StringName) -> Array[CombatEvent]Registra un vínculo de redirección (CLS-4, EffectType.REDIRECT). Un solo vínculo por protegido: reaplicar REEMPLAZA (espejo del escudo CLS-2 — jamás se suman coberturas). charges 0 = por duración; 1+ = por golpes.
func redirect_for(target_id: int) -> RedirectLinkVínculo activo que cubre a target_id, o null. Exige dueño VIVO y distinto del protegido (la muerte ya lo suelta; esto es el cinturón).
func dispatch_cast_procs(actor: UnitState, _skill: SkillDefinition) -> Array[CombatEvent]Hook de &"on_cast" (lo llama SkillAction tras ejecutar la skill): tras lanzar, dispara las procs de casteo del actor.
func is_over() -> boolfunc result_victory() -> boolfunc sostener_protected_alive() -> intProtegidos VIVOS del objetivo "sostener" (SK-18). Cuenta solo los ids marcados que siguen en pie — la caída de cualquiera por debajo del umbral rompe el sostenimiento (is_over → derrota).
func initiative_exhausted() -> boolfunc get_unit(unit_id: int) -> UnitStatefunc units_alive(player_side: bool) -> Array[UnitState]func active_id() -> intfunc active_unit() -> UnitStatefunc active_is_player() -> boolfunc active_ai() -> AIBehaviorfunc active_unit_can_act() -> boolconst BASIC_ATTACK_POWER: int = 10Espejos de compilación del perfil de balance (ED-7.0): el número de PRODUCCIÓN que citan tests y herramientas sin cargar el .tres. La resolución lee SIEMPRE por balance (arriba); que espejo y perfil coincidan lo cobra tests/combat/balance_profile_test.gd. Potencia del ataque básico de los MOBS (AttackAction). El del jugador ya es un .tres universal (P-05, data/skills/basicos) con esta misma potencia; los mobs migran a kits de skills en F3 (P-15).
const PUSH_COLLISION_POWER: int = 10Daño de colisión por celda NO recorrida de un empuje bloqueado (P-03, decisión del diseñador 2026-07-07). Se suma al daño del skill y lo mitiga res_push_pct; el bloqueador no recibe daño en v1.
const TERRAIN_HAZARD_HP_PCT: float = 0.15Fracción de PV MÁX que quema una celda de PELIGRO del terreno de la arena (FLAG-MP-J, Biblia §4.3): terminar el turno encima o entrar por empuje/ atracción cuesta este 15 % (v2). Piso duro de 1 (jamás un tick de 0).
func resolve_hit(actor: UnitState, target: UnitState, potencia: int, damage_type: StringName, scaling_stat: StringName, source: String, element: StringName = &"neutral") -> Array[CombatEvent]Resuelve un golpe con el pipeline §6 completo cuando ambas unidades tienen stats derivados (impacto → crítico → daño → mitigación), con rolls del RNG seeded del combate (determinista). Fallback plano de E0: sin stats, daño fijo del atacante sin rolls (S10).
func attack_of(actor: UnitState, scaling_stat: StringName) -> intATK efectivo según el stat de escalado (§6), con el buff &"atk_pct" y — Represalia (P-25) — el bonus mientras se defiende, aditivos entre sí. &"con" (CLS-2): ATK defensivo — escala escudos y defensivas del tank.
func mitigation_of(target: UnitState, damage_type: StringName) -> intMitigación del objetivo según el tipo de daño, con su buff/debuff (R1): física → armadura (&"armor_def"), mágica → barrera (&"barrier_mag"). Cesión de stats (CLS-4): el espejo ±X % también escala la mitigación — el receptor defiende más y el lanzador menos, exactamente en espejo.
func crit_damage_reduce_of(target: UnitState) -> floatD4 (v2) · Crit Damage Reduce del OBJETIVO con su buff/debuff (R1: el mismo canal buffed() que el resto del pipeline). Es lo que CombatMath.damage() usa para recortar la parte crítica; lo leen resolve_hit Y preview_hit — jamás dos implementaciones (principio Dofus §6). Sin stats derivados (fallback E0) no hay reduce: 0.
func guard_reduced(target: UnitState, amount: int) -> intReducción del OBJETIVO sobre un daño ya calculado: la mayor entre Defender (defend_reduction_pct —v2: 50 %— + Baluarte P-25, §5) y el buff &"damage_reduction_pct" (P-04: no se apilan, gana el mayor), con suelo de 1 (§6). La usan apply_damage Y el preview del HUD — jamás dos implementaciones (principio Dofus, §6). FLAG:GUARD-CAP (D8, decisión del diseñador 2026-07-29) · el buff &"damage_reduction_pct" se capa por damage_reduction_cap (50 %) ANTES de competir; la acción Defender NO pasa por ese cap y compone exactamente como hoy. Composición resultante de la etapa de guardia: · solo Defender → 50 % (+ Baluarte, sin techo: es una acción, cuesta el turno) · solo buffs → mín(suma efectiva del buff, 50 %) · Defender + buffs → el MAYOR de los dos, nunca la suma (P-04) Es decir: el techo de la etapa de guardia por vía de BUFFS es 50 %, y solo Defender+Baluarte puede pasar de ahí — a cambio de gastar la acción.
func preview_hit(actor: UnitState, target: UnitState, skill: SkillDefinition) -> DictionaryPreview del golpe para el HUD (v5, referencia preview_damage_to_mob... → D1-PREVIEW): las MISMAS funciones §6 que resolve_hit, SIN RNG. El «rango de daño» de Stremus NO es un intervalo aleatorio (el daño es determinista): son TRES valores DISCRETOS con sus pesos, que es lo que el jugador debe ver ANTES de confirmar (legibilidad Dofus): · "graze" refilón del fallo (P-71: esquivar ya no anula — entra el 30 % del normal SIN crítico, por la misma cadena posterior) · "normal" golpe limpio ya mitigado (DEF/barrera + Defender/buffs) · "crit" el mismo golpe con crítico (D2 saturado + D4 reduce) · "hit_pct" peso del golpe limpio (el resto de la masa es refilón) · "crit_pct" peso del crítico DENTRO del golpe limpio: el MISMO buffed(&"crit_chance_pct") contra el que compara el roll real Contra un ALIADO la skill no golpea (los efectos ofensivos solo tocan enemigos, contrato AoE 2026-07-19): devuelve la CURA resuelta ("heal", §2). {} si la skill no entrega nada al objetivo (potencia 0, solo-escudo, sin cura). INVARIANTE DURA (D1-PREVIEW): la preview es 100 % PRESENTACIONAL — jamás muta estado ni consume rng (anti seed-scumming: mirar el preview no puede mover la tirada del golpe). Aquí abajo solo hay LECTURAS.
func skill_power_of(actor: UnitState, skill: SkillDefinition) -> intPotencia del RANGO comprado de la skill (D-11): la MISMA lectura que SkillAction.execute — jamás dos convenciones para el mismo dato. Sin catálogo de rangos (power_by_rank vacío), la skill no pega: 0.
func preview_heal(actor: UnitState, target: UnitState, skill: SkillDefinition) -> DictionaryRama de CURA del preview (D1-PREVIEW), pura y sin RNG: la MISMA fórmula §2 que SkillAction._apply_effect — CombatMath.heal_amount con el ATK mágico por attack_of(&"spr") — así que la ficha jamás promete una cura que la resolución no dé. Solo ALIADOS (los efectos benéficos únicamente tocan al propio bando, contrato AoE 2026-07-19) y suma todos los HEAL de la skill. FLAG:HEAL-PREVIEW-CAP (D1-PREVIEW, default de este scope) · el número es lo que el hechizo RESTAURA, SIN capar por los PV que le falten al objetivo (como en Dofus: la ficha describe el hechizo, no el hueco). La alternativa —mostrar la cura REAL, que sobre un aliado a tope sería «+0»— es decisión del diseñador y cambia una línea: mini(total, target.max_hp - target.hp). Sin stats derivados (fallback E0) la cura es plana, igual que la resolución.
func apply_damage(target_id: int, amount: int, source_id: int, source: String = "") -> Array[CombatEvent]Aplica daño con la reducción del objetivo (guard_reduced, §5/§6). Si un vínculo de redirección (CLS-4) cubre al objetivo, el pct % del daño FINAL (ya reducido) se desvía al DUEÑO ANTES del escudo del protegido — el protegido recibe el resto. La parte del dueño SÍ pasa por SU escudo pero jamás se re-reduce (es daño final) ni se re-redirige (jamás se encadenan dueños). El escudo (CLS-2) absorbe el daño YA reducido antes del HP; si lo traga entero no hay DAMAGE_DEALT. HP nunca queda negativo.
func is_terrain_hazard(cell: Vector2i) -> bool¿La celda es peligro ESTÁTICO del terreno de la arena (FLAG-MP-J)? Distinto de la arena DINÁMICA del boss (hazard_cells). La consulta la IA (para no inmolarse) y la resolución de fin de turno / empuje. Vacío → siempre false (compat).
func apply_terrain_hazard(unit_id: int) -> Array[CombatEvent]Daño AMBIENTAL de una celda de peligro del terreno (FLAG-MP-J): 10 % de los PV MÁX (piso 1), PLANO como la arena del boss (source "peligro" — jamás pasa por resolve_hit ni hereda el impacto elemental de un cast). El DAMAGE_DEALT lleva element (tag del bioma) para el color/texto de la presentación; el daño en sí no aplica resistencia elemental (paridad con la arena dinámica). source_id −1 = el terreno. Piso duro: una unidad ya en pie recibe al menos 1. No-op si murió.
func apply_forced_move_hazard(unit_id: int, path: Array[Vector2i]) -> Array[CombatEvent]Peligro por un desplazamiento FORZADO (empuje/atracción, FLAG-MP-J): si el camino RECORRIDO entra en ≥1 celda de peligro del terreno, quema el MISMO daño AL ENTRAR (una sola vez por desplazamiento — la sinergia con la Disrupción de Stein: empujar al enemigo a la lava ES el juego). El caminar VOLUNTARIO no llama aquí (sólo el fin de turno encima paga). Vacío si no tocó peligro. La llama SkillAction (la capa de Commands que el server MMO hereda por el espejo).
func player_units() -> Array[UnitState]Jugadores del combate en orden de unit_id ASCENDENTE (vivos y caídos). Ese orden ES el determinismo de las tiradas por jugador (C2): server y espejo recorren la misma lista y consumen los mismos dados.
CombatStateMachineextends RefCountedcombat_state_machine.gd
FSM explícita del combate (E0-CBT-02, plan §6.3). Vive en el núcleo puro: ejecuta síncronamente y devuelve hechos consumados como CombatEvent; la presentación los consume en cola a su ritmo y llama playback_finished(). Dos detalles defensivos NO opcionales (§6.3): TURN_END consulta is_over() porque los efectos de fin de turno pueden decidir el combate; y una acción inválida de la IA fuerza TURN_END — un AIBehavior con un bug jamás puede dejar la FSM clavada en ENEMY_DECISION.
var state: State = State.COMBAT_SETUPvar combat: CombatStatefunc start(setup: EncounterSetup, rng_seed: int) -> voidfunc submit_action(action: CombatAction) -> voidfunc end_turn_requested() -> void: # botón "fin de turno" del HUD if state == State.PLAYER_DECISIONfunc playback_finished() -> void: # la presentación terminó de animar la cola if state != State.ACTION_EXECUTIONDeathEchoextends RefCounteddeath_echo.gd
Eco post-muerte (sistema de procs, el VERBO de la saga): cuando cae el portador de un proc &"on_owner_death", su "memoria/voz" permanece unos turnos más beneficiando a su bando. Vive registrado en CombatState.echoes (RefCounted puro, A5: el server MMO hereda la MISMA resolución determinista). Ciclo de vida (contrato del sistema de procs): - Nace al morir el portador (CombatState._deal_final_damage → dispatch de &"on_owner_death"): aplica sus effects a target en el ACTO y registra el eco con turns_left = duration. - TICKEA en el cambio de ronda (CombatState.begin_round, espejo de "N turnos" del portador ya caído): decrementa y, mientras siga vivo, RE-APLICA los efectos (refrescan su duración sobre los aliados). - Al EXPIRAR (turns_left ≤ 0): si revive_hp_pct > 0 y algún objetivo del bando sigue vivo, el portador se LEVANTA con ese % de su HP máx en su celda de caída (si está libre) — 1 sola vez, ya regulada por max_uses del proc. side_is_player congela el bando del portador (que ya está muerto: leer is_player de un cadáver seguiría siendo válido, pero el eco no debe depender de que la unidad exista). death_cell es dónde volver a colocarlo si revive.
var id: int = -1var owner_id: int = -1var side_is_player: bool = falsevar turns_left: int = 0var target: StringName = &"allies"var radius: int = 0var effects: Array[EffectDefinition] = []var revive_hp_pct: float = 0.0var skill_id: StringName = &""var death_cell: Vector2i = Vector2i.ZEROEncounterSetupextends RefCountedencounter_setup.gd
Datos de un encuentro YA RESUELTOS por la presentación (plan §6.3): la combat_scene resuelve el encounter contra Db/fixtures y le inyecta esto al núcleo. El núcleo jamás llama a un autoload ni conoce nodos (A5).
var map_id: StringName = &""var map_size: Vector2i = Vector2i(15, 15)var cells: Dictionary[Vector2i, CellData] = {}var units: Array[UnitState] = []var hazard_cells: Array[Vector2i] = []Celdas de PELIGRO estáticas de la arena (FLAG-MP-J, Biblia §4.3): terreno que quema al TERMINAR el turno encima o al ENTRAR por empuje/atracción (10 % PV máx). VACÍO = arena sin peligros. Las inyecta la presentación desde MapDefinition.hazard_cells — SP y ONLINE consumen el MISMO dato de la arena, así el espejo determinista (P4) hereda la mecánica gratis. hazard_element = tipo elemental (tag del bioma) para el feedback del daño; el daño es plano.
var hazard_element: StringName = &""var sostener_rounds: int = 0Objetivo "sostener" (SK-18, Trial de Sostener §6): condición de victoria NO-letal. sostener_rounds > 0 activa el modo — la victoria se GANA aguantando N rondas completas con la(s) unidad(es) protegida(s) viva(s), y se PIERDE si el protegido cae (o cae el jugador). JAMÁS se gana matando: la eliminación de enemigos no cierra el combate en este modo (§1 regla dura #4). sostener_protected_ids = ids de las unidades a proteger (aliadas marcadas); sostener_threshold = mínimo de protegidos vivos para seguir sosteniendo (0 = todos; el núcleo lo satura al total de protegidos declarados).
var sostener_protected_ids: Array[int] = []var sostener_threshold: int = 0InitiativeQueueextends RefCountedinitiative_queue.gd
Timeline de iniciativa fijada UNA vez al inicio del combate (sistemas_core §7): orden descendente, desempate por LUK efectivo (§7, R5) y después estable por id de unidad (E0-CBT-03). Sin re-roll por ronda. Las unidades muertas no se eliminan del orden: se saltan al avanzar.
var order: Array[int] = []static func build(units: Dictionary[int, UnitState]) -> InitiativeQueuefunc reset_round() -> voidfunc advance_to_next_living() -> intAvanza hasta la siguiente unidad viva de la ronda; devuelve su id o -1 si la ronda está agotada.
func has_next_living() -> bool¿Queda alguna unidad viva por actuar en esta ronda?
func current_id() -> intPersistentZoneextends RefCountedpersistent_zone.gd
Zona persistente de combate (CLS-3, plan_maestro_mmo.md (§10)): creada por una skill sobre su patrón AoE trasladado/rotado a la celda objetivo, con dueño, duración en turnos y efectos declarados. Vive registrada en CombatState.zones (RefCounted puro, A5: el server MMO hereda la misma resolución de Commands). Ciclo de vida (contrato CLS-3): - Expira en el fin de turno del DUEÑO (espejo del pipeline de buffs §5: CombatState.resolve_end_of_turn decrementa turns_left). - CAE si el dueño muere (espejo de &"provocado": una zona sin dueño jamás volvería a ver su TURN_END). - Tope simple CombatState.MAX_ZONES: crear una zona por encima del tope EXPULSA la más antigua (FIFO) con su ZONE_EXPIRED (reason "cap"). Una unidad "está dentro" si su celda pertenece a cells (is_inside).
var id: int = -1var owner_id: int = -1var skill_id: StringName = &""var cells: Array[Vector2i] = []var turns_left: int = 0var stops_projectiles: bool = falseCampo de Anclaje (detener_proyectiles): mientras la zona viva, todo golpe OFENSIVO de un enemigo del dueño, lanzado DESDE FUERA contra un objetivo DENTRO con alcance real > 1, se bloquea en la resolución (CombatState.projectile_stopper) y emite PROJECTILE_STOPPED.
var mp_malus: int = 0Malus de PM del Campo: aura RE-EVALUADA — los enemigos del dueño que EMPIEZAN su turno dentro pierden este PM del presupuesto (suelo 0); al salir no se persigue (CombatState.begin_turn).
func is_inside(cell: Vector2i) -> boolRedirectLinkextends RefCountedredirect_link.gd
Vínculo de redirección de daño (CLS-4, EffectType.REDIRECT): el pct % del daño FINAL que reciba el PROTEGIDO lo recibe el DUEÑO en su lugar (el protegido recibe el resto). Vive registrado en CombatState.redirects (RefCounted puro, A5: el server MMO hereda la misma resolución). Ciclo de vida (contrato CLS-4): - Se decrementa en el fin de turno del DUEÑO (espejo de las zonas CLS-3). - El dueño muere → cae ("owner_died"); el protegido muere → cae ("protected_died"): un muerto no necesita cobertura. - charges_left 0 = por duración; 1+ = cae tras absorber ese número de golpes ("charges") — la duración sigue siendo backstop (Interposición: pct 100 con 1 carga "hasta tu siguiente turno"). - Un solo vínculo por protegido: reaplicar REEMPLAZA (espejo del escudo CLS-2 — jamás se suman coberturas ni se encadenan dueños).
var id: int = -1var owner_id: int = -1var protected_id: int = -1var pct: float = 0.0var turns_left: int = 0var charges_left: int = 0var skill_id: StringName = &""ShareLinkextends RefCountedshare_link.gd
Vínculo de cesión (CLS-4, EffectType.SHARE): une al LANZADOR (giver) con el RECEPTOR mientras dura. Vive registrado en CombatState.shares (RefCounted puro, A5: el server MMO hereda la misma resolución de Commands). Ciclo de vida (contrato CLS-4): - Se decrementa en el fin de turno del LANZADOR (espejo de las zonas CLS-3: un vínculo de N turnos cast en el turno T vive hasta el TURN_END de T+N−1). - &"stats_pct": al registrarse aplica el espejo (receptor +X, lanzador −X en UnitState.share_stats_pct); al caer POR CUALQUIER razón el espejo vuelve. Si el receptor MUERE, ambos lados caen (CombatState._release_shares_of). - &"hp_pct": tickea en el fin de turno del lanzador (CombatState._share_hp_tick) drenando X % del HP MÁX del lanzador (piso duro 1 HP) hacia el receptor. - &"pa" NO registra vínculo: son dos buffs &"ap_bonus" de un solo turno (CombatState.apply_share).
var id: int = -1var giver_id: int = -1var receiver_id: int = -1var kind: StringName = &"" # &"stats_pct" | &"hp_pct"var magnitude: float = 0.0var turns_left: int = 0var skill_id: StringName = &""UnitStateextends RefCountedunit_state.gd
Estado lógico de una unidad en combate (A5): HP, PA, PM, celda, cooldowns. RefCounted puro, sin nodos; la vista (UnitView) se enlaza por id y solo reacciona a CombatEvents (plan §6.6). E0: stats planos hardcodeados (S10). F3 los sustituye por el StatBlock de sistemas_core §3 sin cambiar este contrato.
var id: int = -1var display_name: String = ""var is_player: bool = falsevar cell: Vector2i = Vector2i.ZEROvar max_hp: int = 1var hp: int = 1var max_ap: int = 6 # Puntos de Acción, base 6 (sistemas_core §1)var ap_left: int = 0var max_mp: int = 3 # Puntos de Movimiento, base 3 (sistemas_core §1)var mp_left: int = 0var initiative: int = 0var attack_damage: int = 10 # daño plano E0 (S10)var attack_range_min: int = 1var attack_range_max: int = 1var defending: bool = false # Defender: −`defend_reduction_pct` (v2: 50 %) hasta su próximo TURN_STARTvar cooldowns: Dictionary[StringName, int] = {}var ai: AIBehavior = null # null = unidad del jugadorvar level: int = 1var def_id: StringName = &""Id del MobDefinition de origen (&"" en jugadores/fixtures): los triggers &"kill" de las quests cuentan contra este id (E1-QST-01).
var xp_reward: int = 0 # XP que entrega al ser derrotado (MobDefinition, E1-CLS-02)var loot_table: LootTableDefinition = null # botín al morir (MobDefinition, E1-LOOT-01)var stats: DerivedStats = null # null = stats planos de E0 (fallback sin rolls)var skills: Array[SkillDefinition] = []var skill_ranks: Dictionary[StringName, int] = {} # id → rango 1..5 (E1-TREE lo sube)var procs: Array[ProcDefinition] = []Procs de pasivas con disparador (ProcDefinition): los recoge la unidad de sus nodos pasivos comprados (CharacterProgress.tree_procs → StremusEncounters.demo_player). El despacho vive en CombatState (determinismo por el RNG sembrado). Las procs de las ACTIVAS viajan en skills[].procs y también se despachan.
var proc_uses: Dictionary[int, int] = {}Veces que cada proc YA disparó este combate (clave = instancia del proc): max_uses > 0 lo consulta ("1 vez por combate" de los ecos). No viaja por el cable (se re-deriva del combate espejo, determinista).
var link_duration_bonus: int = 0Herencia Viva (pasiva de cesión): turnos EXTRA que duran tus vínculos de Cesión/redirección (SHARE/REDIRECT). Lo vuelca StremusEncounters.demo_player desde mechanic_bonus(&"link_duration_bonus"); lo consumen apply_share y apply_redirect. Default 0 = neutro (los .tres/tests existentes no cambian).
var share_amount_bonus: float = 0.0Herencia Viva rango 3 (SK-01): puntos EXTRA de cesión que suma tu Cesión (SHARE &"stats_pct"): +5 % de cesión al rango 3. Lo vuelca demo_player desde mechanic_bonus_by_rank(&"share_amount_bonus") (rango 3 del nodo); lo consume apply_share. Default 0 = neutro (rangos 1-2 y todo el que no la tenga).
var buffs: Dictionary[StringName, Dictionary] = {}Buffs/debuffs de stats (E1-SKL-04): stat → {"magnitude": float, "turns_left": int}. Los DEBUFF guardan magnitud NEGATIVA (R6). Reaplicar REFRESCA (§5): una entrada por stat, no se apilan (P-04/R6: gana el más fuerte en |valor|). Claves canónicas (R1, sistemas_core §3 paso 5 — el buff modifica el snapshot al RESOLVER): &"precision_pct", &"evasion_pct" (sobre la evasión BRUTA, antes de caps), &"crit_chance_pct", &"crit_damage_pct", &"armor_def", &"barrier_mag", &"elem_damage_pct", &"elem_resist_pct", &"res_status_pct", &"res_push_pct", &"drop_bonus_pct", &"crit_damage_reduce_pct" (D4/v2: % de la parte crítica que recorta el DEFENSOR, lo lee resolve_hit/preview_hit). Especiales: &"atk_pct" (multiplica el ATK en attack_of), &"damage_reduction_pct" (reducción del objetivo, guard_reduced — capado por damage_reduction_cap, D8), &"def_pct" (P-77: % sobre la mitigación FÍSICA, mitigation_of) y &"range_bonus" (PO, D11 2026-07-29: magnitud PLANA en celdas — NO un % —, la lee range_bonus() y puede ser negativa: un debuff de PO es legal). El vocabulario COMPLETO legible lo congela tests/world/buff_vocabulary_test.gd: una clave fuera de esa lista es contenido muerto silencioso.
var status_effects: Dictionary[StringName, Dictionary] = {}Estados alterados (§5): id → {"stacks": int, "turns_left": int, "tick_damage": int, "source_id": int}. tick_damage es SNAPSHOT del ATK del aplicador (§5); los DoTs no critean. source_id = quién lo aplicó (−1 si no importa): &"provocado" (CLS-2) fuerza los objetivos ofensivos hacia él.
var shield_hp: int = 0Escudo (CLS-2, EffectType.SHIELD): pool que absorbe daño ANTES del HP. Reaplicar REEMPLAZA el pool (no suma); el restante se conserva entre turnos y expira por duración en el TURN_END del portador (CombatState).
var shield_turns_left: int = 0var share_stats_pct: float = 0.0Cesión de stats (CLS-4, SHARE &"stats_pct"): suma NETA de los vínculos activos — positiva en el receptor (+X por vínculo), negativa en el lanzador (−X, espejo exacto). La consumen CombatState.attack_of (± % sobre TODOS los ATK) y mitigation_of (± % sobre armadura y barrera); preview == resolución porque ambos leen por ahí. La mantienen apply_share/_end_share — NUNCA escribirla a mano: el espejo debe volver al caer el vínculo.
var push_cells_bonus: int = 0 # celdas extra en TUS empujesvar dot_duration_bonus: int = 0 # turnos extra en los DoTs que aplicasvar defend_reduction_bonus_pct: float = 0.0 # Defender reduce `defend_reduction_pct` (v2: 50 %) + estovar atk_when_defending_pct: float = 0.0 # +ATK % mientras defiendesvar boss_phases: Array[BossPhaseDefinition] = []var boss_phase_index: int = 0var boss_cycle_index: int = 0var boss_turns_taken: int = 0var skills_cast_this_turn: Array[StringName] = []Skills lanzadas ESTE turno (begin_turn la limpia): el ciclo del boss avanza solo si su skill telegrafiada aparece aquí (E1-BOSS-01).
func is_boss() -> boolfunc boss_phase() -> BossPhaseDefinitionfunc boss_current_skill() -> SkillDefinitionSkill telegrafiada: la que el boss lanzará en su PRÓXIMA ventana.
static func make(unit_id: int, name: String, player: bool, start_cell: Vector2i, hp_max: int, damage: int, init_value: int, ap_max: int = 6, mp_max: int = 3) -> UnitStatefunc is_alive() -> boolconst DETENIDO_AP_MALUS: int = 2Malus de PA de &"detenido" (CLS-3, Detención Absoluta): −2 PA del presupuesto del turno mientras dure (además del PM = 0, espejo del root). ED-7.0: ESPEJO de compilación del campo detenido_ap_malus del perfil de balance — lo citan tests y herramientas que necesitan el número v1 sin cargar el .tres. La resolución lee SIEMPRE el perfil (begin_turn); que ambos coincidan lo cobra tests/combat/balance_profile_test.gd.
func begin_turn() -> voidInicio del turno propio: restaura PA/PM (GDD §2), expira Defender y decrementa cooldowns. Cooldown N: la skill vuelve a estar disponible en el turno propio N-ésimo después de lanzarla.
func is_on_cooldown(skill_key: StringName) -> boolfunc skill_rank(skill_id: StringName) -> intfunc buff_magnitude(stat: StringName) -> floatSuma efectiva de un buff de stat (una entrada por stat; §5, P-04).
func apply_buff(stat: StringName, magnitude: float, duration_turns: int) -> voidAplica/refresca un buff O debuff (magnitud negativa, R6): una entrada por stat (P-04) — gana el efecto MÁS FUERTE en valor absoluto (un debuff de −30 ya no "pierde" contra un buff suave de +10) y la duración se refresca.
func buffed(stat: StringName, base: float) -> floatDerivado del snapshot + buff/debuff activo del mismo stat (R1: es el paso 5 de §3 — la resolución lee SIEMPRE por aquí, jamás el snapshot a pelo).
func range_bonus() -> intPO (Alcance) EFECTIVO de la unidad — decisión del diseñador 2026-07-29 (D11): derivado del equipo (DerivedStats.range_bonus, base 0) MÁS el buff/debuff &"range_bonus" (paso 5 de §3, magnitud plana en celdas y legalmente negativa). Sin stats (E0/fixtures planas) cuenta solo el buff. ÚNICO lector del PO del repo: lo consume SkillDefinition.effective_range, que a su vez es la única fórmula de alcance (validación de Commands, pintado del HUD y ficha de skill leen por ahí — principio Dofus §6).
func apply_status(status_id: StringName, duration_turns: int, tick_damage: int, max_stacks: int, source_id: int = -1) -> intAplica/refresca un estado (§5): reaplicar refresca duración; solo los estados con max_stacks > 1 (Quemado) apilan. source_id = aplicador (CLS-2): &"provocado" lo necesita para forzar objetivos; reaplicar lo refresca también (el ÚLTIMO aplicador manda).
func taunt_source() -> intAplicador del &"provocado" activo, o −1 sin él (CLS-2). El invariante de que el aplicador está VIVO lo mantiene CombatState.apply_damage: al morir, el estado CAE en todas sus víctimas.
func apply_shield(amount: int, duration_turns: int) -> voidAplica/REEMPLAZA el escudo (CLS-2): la potencia ya viene escalada por el ATK del scaling_stat de la skill (SkillAction). Nunca suma pools.
Qué configura el juego
Sus .tres de skills/efectos/mobs/jefes, cómo construye el EncounterSetup (en Stremus: StremusEncounters, scripts/world/), el Inventory que inyecta a UseItemAction, el perfil de balance (stats), y qué hace con cada CombatEvent (vista, HUD, red).
Fixture mínima
testing/combat_fixtures.gd — CombatFixtures.open_map()/corridor_map()/weighted_map()/pillars_map()/water_map() (idénticos a GridFixtures) y sim_encounter() (2v2 IA vs IA en pillars_map: melee agresivo contra kiter). Es la mitad mecánica del antiguo fixtures.gd de Stremus (FW-8 cerrado en FK-5).
testing/skill_fixtures.gd — SkillFixtures.tajo()/estocada_profunda()/corte_en_arco()/embate()/guardia_ferrea()/grito_de_guerra()/ataque_basico()/paso_ligero()/aligerar(): réplica campo a campo de los .tres del kit Swordsman (contrato numérico con Stremus).
combat_fixtures.gdcódigo completo
# res://addons/fpx/combat/testing/combat_fixtures.gd
class_name CombatFixtures
extends RefCounted
## Fixtures de MECÁNICA del combate (FW-8 cerrado desde el Funpix Kit, FK-5):
## los 5 mapas deterministas de E0 (E0-TEST-01) y el encuentro IA vs IA de la
## sim, todo EN MEMORIA — cero `res://data`. La mitad de CONTENIDO (jugador
## desde CharacterProgress, arenas y mobs de datos, trial de Sostener) vive en
## `StremusEncounters` (scripts/world/stremus_encounters.gd): es el juego.
## Este archivo es idéntico a `addons/fpx/grid/testing/grid_fixtures.gd` +
## `sim_encounter` en el kit.
## Mapa 1: abierto, todo transitable con coste 1.
static func open_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells: Dictionary[Vector2i, CellData] = {}
for y in size.y:
for x in size.x:
cells[Vector2i(x, y)] = CellData.make()
return cells
## Mapa 2: muro vertical en x=7 con un hueco en y=7 (rodeos y LoS).
## El muro es "muro" de verdad: no transitable Y bloquea visión (§11).
static func corridor_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for y in size.y:
if y != 7:
cells[Vector2i(7, y)] = CellData.make(false, 1, true)
return cells
## Mapa 3: franja de terreno difícil (coste 3) en y=6..8, x=4..10, con un
## pasillo barato en y=5 (contrato de E0-PATH-02: el pathfinding lo prefiere).
static func weighted_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for y in range(6, 9):
for x in range(4, 11):
cells[Vector2i(x, y)] = CellData.make(true, 3, false)
return cells
## Mapa 4: pilares que bloquean visión, transitables alrededor.
static func pillars_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for pos: Vector2i in [Vector2i(5, 5), Vector2i(9, 5), Vector2i(5, 9), Vector2i(9, 9), Vector2i(7, 7)]:
cells[pos] = CellData.make(false, 1, true)
return cells
## Mapa 5: agua (no transitable, NO bloquea visión) y muro (ambos) — el par de
## casos independientes de E0-GRD-02 / sistemas_core §11.
static func water_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for x in range(3, 12):
cells[Vector2i(x, 4)] = CellData.make(false, 1, false) # agua
cells[Vector2i(7, 10)] = CellData.make(false, 1, true) # muro
return cells
## Encuentro para la sim IA vs IA (E0-TEST-03): bando "jugador" = melee_bruto,
## bando enemigo = ranged_kiter. Ambos bandos llevan AIBehavior; el driver del
## test lo consulta también en PLAYER_DECISION.
static func sim_encounter() -> EncounterSetup:
var setup := EncounterSetup.new()
setup.map_id = &"e0_sim_arena"
setup.map_size = Vector2i(15, 15)
setup.cells = pillars_map(setup.map_size)
var melee_a := UnitState.make(1, "Melee A", true, Vector2i(2, 6), 30, 8, 9, 2)
melee_a.ai = MeleeAggressiveBehavior.new()
var melee_b := UnitState.make(2, "Melee B", true, Vector2i(2, 8), 30, 8, 7, 2)
melee_b.ai = MeleeAggressiveBehavior.new()
var ranged_a := UnitState.make(3, "Ranged A", false, Vector2i(12, 6), 22, 7, 10, 2)
ranged_a.ai = RangedKiterBehavior.new()
ranged_a.attack_range_min = 2
ranged_a.attack_range_max = 4
var ranged_b := UnitState.make(4, "Ranged B", false, Vector2i(12, 8), 22, 7, 8, 2)
ranged_b.ai = RangedKiterBehavior.new()
ranged_b.attack_range_min = 2
ranged_b.attack_range_max = 4
setup.units = [melee_a, melee_b, ranged_a, ranged_b]
return setupCómo se prueba
tests/combat/ — 14 suites: las 11 puras (aoe_side_filter, balance_profile, balance_v2_systems, combat_xp, damage_preview, def_pct_mitigation, graze, guard_effects, persistent_zone, share_redirect, use_item) + 3 adaptadas sobre SkillFixtures (combat_core — economía PA/PM, FSM, victoria/derrota —, skill_action — daño exacto, AoE por bando, empuje, buffs, DoT —, mp_bonus_immediate). Las 22 restantes (contenido real de mobs/arenas/árboles, HUD, paneles) se quedan en Stremus.
Ejemplar oro (Stremus)
scripts/core/combat/, scripts/core/entities/{unit_state,initiative_queue}.gd, scripts/resources/{skill,effect,proc,boss_phase}_definition.gd, scripts/resources/*_behavior.gd (main@9cddb79+FW-8). El kit Swordsman + Coloso de Chatarra (2 fases, telegraph, arena dinámica) es el ejemplar de contenido; sus 25 suites de contenido/UI se quedan allí.
Deuda declarada
- FLAG-COMBAT-MONOLITH:
CombatState(1745 LOC) no se parte en la mudanza. - FLAG-COMBAT-FIXTURES-DUP:
CombatFixturesrepite los 5 mapas deGridFixtures(copia 1:1 de Stremus); delegar es un delta futuro. - FLAG-STATS-BUFFS (capa 5 de buffs vive aquí) · P-90 (
CRIT-EXCESS-BUFFS,GUARD-CAP).