Files
aetherbound-guild/runtime/domain/battle_simulation.gd
T

1758 lines
70 KiB
GDScript

class_name ABGBattleSimulation
extends RefCounted
## M80-B1 deterministic battle core.
##
## Implements the authoritative timing, attribute, targeting and damage rules of
## `docs/02_GAMEPLAY_AND_BALANCE.md`:
## * 100 ms ticks with the section 8.1 priority order (simplified where later
## stages add casts, channels and summons);
## * section 9 derived statistics;
## * section 10 readiness: `Readiness = min(150, Readiness + Tempo*0.1)`, act at
## >= 100 and keep the overflow;
## * section 11 target construction order (side, reach, protection, policy);
## * section 15 damage with labeled critical rolls and the documented floors;
## * section 18 Fell -> line compaction, and section 19 outcome evaluation.
##
## The simulation is pure: no engine singletons, no time source beyond its own
## tick counter, and every stochastic step draws from a labelled hash so adding
## a proc cannot shift another stream.
const TICK_MS := 100
const READINESS_CAP := 150.0
const READINESS_THRESHOLD := 100.0
const MAX_TICKS := 1200
## M80-I10 tuning: Guard may soak at most half of a hit, and a Recruit may
## perform a limited number of healing actions per battle. Both are authored
## pressure rules that stop a full-capacity Company from negating all damage.
const GUARD_ABSORB_RATIO := 0.35
const HEAL_CHARGES_PER_BATTLE := 2
## Wind-up units are declared 0/2/5 by the Profession tables; one unit is four
## ticks so a steady cast lands at 0.8 s and a slow cast at 2.0 s, inside the
## documented 0.3-4.0 s channel band.
const WINDUP_TICKS_PER_UNIT := 4
const MAJOR_WINDUP_TICKS := 12
const ProfessionActions = preload("res://domain/profession_actions.gd")
const TraitHooks = preload("res://domain/trait_hooks.gd")
enum Side { ALLY = 0, ENEMY = 1 }
var root_seed := ""
var tick := 0
var allies: Array[Dictionary] = []
var enemies: Array[Dictionary] = []
var journal: Array[Dictionary] = []
var draw_indices: Dictionary = {}
var crosswind: Dictionary = {}
var chorus_tags: Array = []
var forward_claim: Dictionary = {}
var equipment_trigger_events := 0
var chorus_scheduled := false
var outcome := ""
var outcome_tick := -1
var fallen_count := 0
var committed_footprint := {}
var summons_lost := {}
var summon_serial := 0
func setup(next_root_seed: String, ally_rows: Array, enemy_rows: Array) -> void:
root_seed = next_root_seed
allies = []
enemies = []
for row in ally_rows:
allies.append(_actor(Dictionary(row), Side.ALLY))
for row in enemy_rows:
enemies.append(_actor(Dictionary(row), Side.ENEMY))
journal = []
draw_indices = {}
outcome = ""
outcome_tick = -1
fallen_count = 0
tick = 0
_record_committed_footprint()
func _record_committed_footprint() -> void:
for side in [Side.ALLY, Side.ENEMY]:
var total := 0
for actor in (allies if side == Side.ALLY else enemies):
total += int(actor.footprint)
committed_footprint[side] = total
func run() -> Dictionary:
while outcome.is_empty() and tick < MAX_TICKS:
tick += 1
_step()
if outcome.is_empty():
outcome = "timeout"
outcome_tick = tick
_mark_ready_carry()
return {"outcome": outcome, "tick": outcome_tick, "journal": journal.duplicate(true), "allies": _snapshot(allies), "enemies": _snapshot(enemies)}
func _mark_ready_carry() -> void:
# EQ-276: an unused once-per-battle trigger carries readiness priority into
# the next battle. The engine's once-per-battle triggers are the spent flags
# below, so "unused" means none of them fired during this battle.
for actor in allies:
if bool(actor.fallen) or int(_trait_effect(actor, "on_signature").get("ready_carry", 0)) <= 0:
continue
if bool(actor.get("tag_copy_spent", false)) or bool(actor.get("cleanse_share_spent", false)) \
or bool(actor.get("interrupt_guard_spent", false)):
continue
actor.ready_with_priority = true
func roll_bp(label: String, actor_id: String, sequence: int, target_id: String) -> int:
# Labelled determinism (section 8.2): independent streams per label, drawn in
# a stable order, so content procs never shift another stream's results.
var key := "%s|%s|%s" % [label, actor_id, target_id]
var index := int(draw_indices.get(key, 0))
draw_indices[key] = index + 1
var digest := ("%s|%s|%d|%s|%d" % [root_seed, label, sequence, actor_id, index]).sha256_text()
return int(digest.substr(0, 8).hex_to_int()) % 10000
static func derived(row: Dictionary) -> Dictionary:
# Section 9 derived statistics. Values round down to 1/100 after the full
# expression, so callers see the authored two decimals.
var level := int(row.get("level", 1))
var might := float(row.get("might", 8))
var finesse := float(row.get("finesse", 8))
var insight := float(row.get("insight", 8))
var vigor := float(row.get("vigor", 8))
var resolve := float(row.get("resolve", 8))
return {
"physical_power": _floor2(float(row.get("weapon_power", 0)) + 2.00 * might + 0.50 * finesse),
"arcane_power": _floor2(float(row.get("focus_power", 0)) + 2.00 * insight + 0.50 * resolve),
"healing_power": _floor2(float(row.get("focus_power", 0)) + 1.20 * insight + 1.20 * resolve),
"max_hp": _floor2(float(row.get("profession_base_hp", 200)) + 8.0 * level + 10.0 * vigor),
"guard": _floor2(float(row.get("armor_guard", 0)) + 1.20 * vigor + 0.60 * might),
"ward": _floor2(float(row.get("armor_ward", 0)) + 1.20 * resolve + 0.60 * insight),
"potency": _floor2(insight + 0.50 * finesse + float(row.get("flat_potency", 0))),
"tenacity": _floor2(resolve + 0.50 * vigor + float(row.get("flat_tenacity", 0))),
"tempo": _floor2(clampf(float(row.get("profession_base_tempo", 30)) + 0.40 * finesse + float(row.get("flat_tempo", 0)), 20.0, 100.0)),
"initiative": _floor2(clampf(float(row.get("profession_base_initiative", 10)) + 0.25 * finesse + float(row.get("flat_initiative", 0)), 0.0, 95.0)),
}
static func _floor2(value: float) -> float:
return floorf(value * 100.0) / 100.0
func damage_preview(source: Dictionary, target: Dictionary, action: Dictionary, crit := false) -> Dictionary:
# Section 15.1 damage for one already-selected source/target/action.
var stats_source: Dictionary = source.stats
var stats_target: Dictionary = target.stats
var power := float(stats_source.get(str(action.get("power_source", "physical_power")), 0.0))
var raw := maxf(0.0, float(action.get("base", 0)) + float(action.get("coefficient", 1.0)) * power)
var defense := clampf(float(stats_target.get("guard", 0.0)) - float(action.get("pierce", 0.0)), -75.0, 500.0)
var defense_multiplier := 100.0 / (100.0 + defense) if defense >= 0.0 else 2.0 - 100.0 / (100.0 - defense)
var crit_chance := clampi(int(action.get("crit_bp", 0)) + int(20.0 * (float(stats_source.get("finesse", 0.0)) - float(stats_target.get("tenacity", 0.0)))), 0, 5000)
var crit_multiplier := clampf(float(action.get("crit_multiplier", 1.5)), 1.25, 2.00) if crit else 1.0
var screen_reduction := int(_trait_effect(target, "before_damage").get("damage_reduction", 0)) \
+ int(target.get("pending_reduction", 0))
var mitigated := maxf(1.0, raw * defense_multiplier * crit_multiplier - float(screen_reduction))
var protection_stacks := int(target.get("protection_stacks", 0))
var screened := mitigated * maxf(0.70, 1.0 - 0.15 * float(protection_stacks))
if str(action.get("protection_mode", "screened")) == "direct":
screened = mitigated
var damage_floor := ceili(0.04 * raw)
return {
"final": maxi(1, maxi(damage_floor, int(floorf(screened)))),
"raw": raw,
"crit_chance_bp": crit_chance,
"defense_multiplier": defense_multiplier,
"crit_multiplier": crit_multiplier,
"protection_stacks": protection_stacks,
}
func _actor(row: Dictionary, side: int) -> Dictionary:
var stats := derived(row)
return {
"id": str(row.get("id", "")),
"side": int(side),
"position": int(row.get("position", 0)),
"reach": int(row.get("reach", 1)),
"stats": stats,
"hp": int(row.get("hp", int(stats.max_hp))),
"guard": int(row.get("guard", int(stats.guard))),
"readiness": READINESS_THRESHOLD if bool(row.get("ready_with_priority", false)) \
else float(stats.initiative),
"fallen": false,
"action_sequence": 0,
"profession_id": str(row.get("profession_id", "")),
"footprint": int(row.get("footprint", 1)),
"immovable": bool(row.get("immovable", false)),
"rooted": bool(row.get("rooted", false)),
"traits": _hook_rows(row),
"equipment_hooks": Array(row.get("equipment_hooks", [])),
"profession_table": _profession_table(row),
"resource": 0,
"heal_charges": int(row.get("heal_charges", HEAL_CHARGES_PER_BATTLE)),
"cast": {},
"link_id": str(row.get("link_id", "")),
"statuses": [],
"is_summon": bool(row.get("is_summon", false)),
"summon_ticks": int(row.get("summon_ticks", 0)),
"last_target": "",
"action": Dictionary(row.get("action", {
"id": "basic",
"base": 8,
"coefficient": 1.0,
"power_source": "physical_power",
"reach": 1,
"protection_mode": "screened",
"pierce": 0,
"crit_bp": 500,
"crit_multiplier": 1.5,
})),
}
const CROSSWIND_WINDOW_TICKS := 30
func _step() -> void:
if not crosswind.is_empty() and tick >= int(crosswind.get("expires", 0)):
_resolve_crosswind_expiry()
# 1-4: statuses, casts and projectiles arrive in later stages; the core only
# needs expiry-free actors.
# 3: periodic cast, status and summon clocks advance before actions resolve.
_tick_casts()
_tick_statuses()
_tick_summons()
# 5: actions for ready and legal actors.
var ready_actors: Array[Dictionary] = []
for actor in _living(allies) + _living(enemies):
if not Dictionary(actor.cast).is_empty():
continue
actor.readiness = minf(READINESS_CAP, float(actor.readiness) + float(actor.stats.tempo) * 0.1)
if float(actor.readiness) >= READINESS_THRESHOLD:
ready_actors.append(actor)
ready_actors.sort_custom(func(a, b): return float(a.readiness) > float(b.readiness))
# 7: batch the resolved actions against one pre-batch state.
var batch: Array[Dictionary] = []
for actor in ready_actors:
var action := _choose_row(actor)
if action.is_empty():
continue
var chosen := _select_target_for(actor, action)
if chosen.is_empty():
continue
actor.readiness = float(actor.readiness) - READINESS_THRESHOLD
var windup_ticks := int(action.get("windup", 0)) * WINDUP_TICKS_PER_UNIT
if windup_ticks > 0:
actor.cast = {
"action_id": str(action.get("id", "")),
"action": action.duplicate(true),
"target_id": str(chosen.id),
"ticks_left": windup_ticks,
"windup_ticks": windup_ticks,
}
journal.append({
"tick": tick, "actor": str(actor.id), "target": str(chosen.id),
"kind": "cast_start", "action": str(action.get("id", "")),
"ticks_left": windup_ticks, "amount": 0,
})
continue
var events_before := equipment_trigger_events
batch.append(_resolve_attack(actor, chosen, action))
_check_triggerless(actor, events_before, action, chosen)
for record in batch:
_apply_damage(record)
# 8-9: mark Fallen, compact lines and evaluate the outcome.
_mark_fallen()
_compact()
_evaluate_outcome()
func _resolve_attack(actor: Dictionary, target: Dictionary, action: Dictionary) -> Dictionary:
if action.is_empty():
action = Dictionary(actor.action)
actor.action_sequence = int(actor.action_sequence) + 1
if bool(actor.get("is_summon", false)):
_drain_device_duration(actor)
if str(action.get("kind", "damage")) == "healing":
actor.heal_charges = maxi(0, int(actor.heal_charges) - 1)
if bool(action.get("deploy", false)):
return _deploy_summon(actor, action, target)
var target_effect := _trait_effect(actor, "before_target")
if int(target_effect.get("status_apply", 0)) > 0:
var declared: Dictionary = Dictionary(action.get("status", {}))
var mark := declared if not declared.is_empty() else {"id": "marked", "sign": "hindrance", "ticks": 40, "chance_bp": 6000}
_apply_status(target, actor, mark, {"tick": tick, "actor": str(actor.id), "target": str(target.id)})
if int(target_effect.get("damage_bonus", 0)) > 0:
actor.pending_damage_bonus = int(actor.get("pending_damage_bonus", 0)) + int(target_effect.damage_bonus)
var banked_damage := int(actor.get("pending_damage_bonus", 0))
if banked_damage > 0:
actor.pending_damage_bonus = 0
action = action.duplicate(true)
action["base"] = float(action.get("base", 0)) + float(banked_damage)
if int(_trait_effect(actor, "before_cast").get("interrupt_strike", 0)) > 0:
actor.pending_interrupt = true
_apply_cooldown_cut(actor, "before_cast")
_apply_reach_gain(actor, "before_cast")
var cast_tempo := int(_trait_effect(actor, "before_cast").get("tempo_bonus", 0))
if cast_tempo > 0 and float(actor.stats.tempo) < 100.0:
actor.stats.tempo = minf(100.0, float(actor.stats.tempo) + float(cast_tempo))
var bonuses := _trait_effect(actor, "before_damage")
if int(bonuses.get("damage_bonus", 0)) > 0:
action = action.duplicate(true)
action["base"] = float(action.get("base", 0)) + float(bonuses.damage_bonus)
if int(bonuses.get("status_apply", 0)) > 0:
var hook_status := {"id": "marked", "sign": "hindrance", "ticks": 40, "chance_bp": 6000}
var declared: Dictionary = Dictionary(action.get("status", {}))
if not declared.is_empty():
hook_status = declared
_apply_status(target, actor, hook_status, {"tick": tick, "actor": str(actor.id), "target": str(target.id)})
var follow_up := _trait_effect(actor, "after_damage")
if int(follow_up.get("guard_bonus", 0)) > 0:
actor.guard = int(actor.guard) + int(follow_up.guard_bonus)
if int(follow_up.get("neighbor_guard_chip", 0)) > 0:
var victim_line: Array[Dictionary] = allies if int(target.side) == Side.ALLY else enemies
var victim_index := victim_line.find(target)
for offset in [1, -1]:
var neighbor_index: int = victim_index + int(offset)
if neighbor_index < 0 or neighbor_index >= victim_line.size():
continue
var neighbor: Dictionary = victim_line[neighbor_index]
if bool(neighbor.fallen) or str(neighbor.id) == str(target.id):
continue
var chip := mini(int(neighbor.guard), int(follow_up.neighbor_guard_chip))
neighbor.guard = int(neighbor.guard) - chip
journal.append({"tick": tick, "actor": str(actor.id), "target": str(neighbor.id),
"kind": "damage", "amount": 0, "guard_chipped": chip, "hp_after": int(neighbor.hp)})
break
_apply_cooldown_cut(actor, "after_target")
_apply_cooldown_cut(actor, "on_cast_complete")
_check_chorus_frame(actor)
_apply_transfer(actor, "on_cast_complete")
if int(_trait_effect(actor, "on_cast_complete").get("movement_bonus", 0)) > 0:
var complete_from := int(actor.position)
if _forced_swap(actor, -1):
journal.append({"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "movement", "steps": 1, "from": complete_from, "to": int(actor.position),
"hp_after": int(actor.hp)})
_fire_crosswind_line(actor)
if int(follow_up.get("movement_bonus", 0)) > 0:
var step_from := int(actor.position)
if _forced_swap(actor, -1):
journal.append({"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "movement", "steps": 1, "from": step_from, "to": int(actor.position),
"hp_after": int(actor.hp)})
_fire_crosswind_line(actor)
var gained := int(bonuses.get("resource_gain", 0))
if bool(action.get("gain_edge", false)) or bool(action.get("gain_aim", false)) \
or bool(action.get("gain_charge", false)) or bool(action.get("gain_remedy", false)) \
or bool(action.get("gain_parts", false)) or bool(action.get("gain_omen", false)) \
or bool(action.get("gain_verse", false)) or bool(action.get("gain_supply", false)):
gained += 1
if gained > 0:
actor.resource = int(actor.resource) + gained
_apply_resource_gain(actor, "on_resource_gain")
_apply_transfer(actor, "on_resource_gain")
var crit_bp: int = int(damage_preview(actor, target, action).crit_chance_bp)
var roll := roll_bp("critical", str(actor.id), int(actor.action_sequence), str(target.id))
var crit: bool = roll < crit_bp
if crit:
var crit_effect := _trait_effect(actor, "on_critical")
if int(crit_effect.get("damage_bonus", 0)) > 0:
action = action.duplicate(true)
action["base"] = float(action.get("base", 0)) + float(crit_effect.damage_bonus)
var preview: Dictionary = damage_preview(actor, target, action, crit)
if int(target.get("pending_reduction", 0)) > 0:
target.pending_reduction = 0
var interrupt_ready := bool(action.get("interrupt", false)) or bool(actor.get("pending_interrupt", false))
actor.pending_interrupt = false
return {
"tick": tick,
"actor": str(actor.id),
"side": int(actor.side),
"target": str(target.id),
"action": str(action.get("id", "basic")),
"kind": str(action.get("kind", "damage")),
"crit": crit,
"crit_roll": roll,
"amount": int(preview.final),
"status": _status_of(action),
"cleanse": bool(action.get("cleanse", false)),
"interrupt": interrupt_ready,
"link": bool(action.get("link", false)),
"relink": bool(action.get("relink", false)),
"extend_status": bool(action.get("extend_status", false)),
"push": int(action.get("push", 0)),
"pull": int(action.get("pull", 0)),
"protection_stacks": int(preview.protection_stacks),
"source": actor,
"victim": target,
}
func _apply_damage(record: Dictionary) -> void:
if not record.has("victim"):
# Deploy records already journal their own outcome and have no damage
# component to apply.
return
var victim: Dictionary = record.victim
if bool(victim.fallen):
return
var amount := int(record.amount)
if str(record.get("kind", "damage")) == "guard":
# A guard-kind action raises the target's Guard pool instead of dealing
# damage. Without this branch the row's amount was applied as self-damage
# while the pool stayed empty, so `self_guard_below_half` never cleared
# and the actor looped on its refresh row for the rest of the battle.
var cap := int(victim.stats.guard) * 2
var before_guard := int(victim.guard)
victim.guard = mini(cap, int(victim.guard) + amount)
if int(victim.guard) != before_guard:
var protection := _trait_effect(victim, "on_protection_change")
if int(protection.get("guard_bonus", 0)) > 0:
victim.guard = mini(cap, int(victim.guard) + int(protection.guard_bonus))
_apply_status_work(record, Dictionary(record.get("source", {})), victim)
record.guard_after = int(victim.guard)
record.hp_after = int(victim.hp)
record.erase("source")
record.erase("victim")
journal.append(record)
return
if str(record.get("kind", "damage")) == "healing":
var max_hp := int(victim.stats.max_hp)
var heal_bonus := int(_trait_effect(victim, "after_heal").get("heal_bonus", 0))
amount += heal_bonus
victim.hp = mini(max_hp, int(victim.hp) + amount)
_apply_status_work(record, Dictionary(record.get("source", {})), victim)
record.healed = amount
record.hp_after = int(victim.hp)
record.guard_after = int(victim.guard)
record.erase("source")
record.erase("victim")
journal.append(record)
return
var guard := int(victim.guard)
var absorbed := mini(guard, int(floor(float(amount) * GUARD_ABSORB_RATIO)))
victim.guard = guard - absorbed
if guard > 0 and int(victim.guard) == 0:
var barrier := _trait_effect(victim, "on_barrier_break")
if int(barrier.get("guard_bonus", 0)) > 0:
victim.guard = int(barrier.guard_bonus)
var to_hp := amount - absorbed
victim.hp = maxi(0, int(victim.hp) - to_hp)
record.absorbed = absorbed
record.hp_after = int(victim.hp)
record.guard_after = int(victim.guard)
var source: Dictionary = record.get("source", {})
if bool(record.get("link", false)) or bool(record.get("relink", false)):
# A joining thread binds the source to its target; a repair replaces a
# broken thread with a fresh one (section 17 `link_broken`).
source.link_id = str(victim.id)
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "link", "repaired": bool(record.get("relink", false)), "amount": 0,
"hp_after": int(victim.hp)})
if bool(record.get("interrupt", false)) and not Dictionary(victim.cast).is_empty():
if int(_trait_effect(victim, "on_interrupt").get("interrupt_guard", 0)) > 0 \
and not bool(victim.get("interrupt_guard_spent", false)):
victim.interrupt_guard_spent = true
var held: Dictionary = Dictionary(victim.cast)
held["ticks_left"] = int(held.get("ticks_left", 0)) + int(MAJOR_WINDUP_TICKS)
victim.cast = held
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "cast_delayed", "reason": "INTERRUPT_GUARD",
"action": str(held.get("action_id", "")), "amount": 0})
_apply_status_work(record, source, victim)
return
var cancelled: Dictionary = Dictionary(victim.cast)
victim.cast = {}
journal.append({
"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "cast_cancelled", "reason": "AO_INTERRUPTED",
"action": str(cancelled.get("action_id", "")), "amount": 0,
})
_apply_cooldown_cut(victim, "on_interrupt")
_apply_status_work(record, source, victim)
var push := int(record.get("push", 0))
var pull := int(record.get("pull", 0))
record.erase("source")
record.erase("victim")
journal.append(record)
if int(victim.hp) > 0 and (push > 0 or pull > 0) and not source.is_empty():
_apply_forced_movement(victim, source, push, pull)
static func _status_of(action: Dictionary) -> Dictionary:
# `apply_hindered` is the shorthand the Profession tables already declare;
# the explicit `status` object wins when a row needs its own identity.
if not Dictionary(action.get("status", {})).is_empty():
return Dictionary(action.get("status", {}))
if bool(action.get("apply_hindered", false)):
return {"id": "hindered", "sign": "hindrance", "ticks": 40, "chance_bp": 6000}
return {}
func _tick_casts() -> void:
for actor in allies + enemies:
if bool(actor.fallen) or Dictionary(actor.cast).is_empty():
continue
var cast: Dictionary = Dictionary(actor.cast)
cast["ticks_left"] = int(cast.get("ticks_left", 0)) - 1
if int(cast.ticks_left) > 0:
actor.cast = cast
continue
var action: Dictionary = Dictionary(cast.get("action", {}))
var target := _find_actor(str(cast.get("target_id", "")))
if target.is_empty() or bool(target.get("fallen", false)):
journal.append({"tick": tick, "actor": str(actor.id), "target": str(cast.get("target_id", "")),
"kind": "cast_cancelled", "reason": "TARGET_FALLEN",
"action": str(cast.get("action_id", "")), "amount": 0})
actor.cast = {}
_apply_cooldown_cut(actor, "on_interrupt")
continue
_apply_damage(_resolve_attack(actor, target, action))
journal.append({"tick": tick, "actor": str(actor.id), "target": str(target.id),
"kind": "cast_resolve", "action": str(cast.get("action_id", "")), "amount": 0})
actor.cast = {}
func _find_actor(actor_id: String) -> Dictionary:
for actor in allies + enemies:
if str(actor.id) == actor_id:
return actor
return {}
func _major_windup_actor(living_enemies: Array[Dictionary]) -> Dictionary:
var best := {}
for enemy in living_enemies:
var cast: Dictionary = Dictionary(enemy.cast)
if cast.is_empty():
continue
if int(cast.get("windup_ticks", 0)) < MAJOR_WINDUP_TICKS:
continue
if best.is_empty() or int(enemy.position) < int(Dictionary(best).get("position", 1 << 30)):
best = enemy
return best
func _any_casting_actor(living_enemies: Array[Dictionary]) -> Dictionary:
var best := {}
for enemy in living_enemies:
if Dictionary(enemy.cast).is_empty():
continue
if best.is_empty() or int(enemy.position) < int(Dictionary(best).get("position", 1 << 30)):
best = enemy
return best
func _living_footprint(side: int) -> int:
var line: Array[Dictionary] = allies if side == Side.ALLY else enemies
var total := 0
for actor in line:
if not bool(actor.fallen):
total += int(actor.footprint)
return total
func _deploy_summon(actor: Dictionary, action: Dictionary, target: Dictionary) -> Dictionary:
# The deployer's clock reads its summon hook on a successful insertion.
# Section 14.2: a summon is legal only while its footprint fits the
# deployment allowance of two; a rejected insertion spends nothing.
var footprint := maxi(1, int(action.get("summon_footprint", 1)))
var side := int(actor.side)
var allowance := int(committed_footprint.get(side, 0)) + 2
if _living_footprint(side) + footprint > allowance:
journal.append({
"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "summon_rejected", "reason": "NO_SUMMON_SPACE", "footprint": footprint,
})
return {"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"action": str(action.get("id", "")), "kind": "construct", "amount": 0}
var line: Array[Dictionary] = allies if side == Side.ALLY else enemies
var index := 0
for candidate in range(line.size()):
if str(line[candidate].id) == str(actor.id):
index = candidate
break
var summon := {
"id": "summon-%s-%d" % [str(actor.id), int(summon_serial)],
"side": side,
"position": int(actor.position) + 1,
"reach": 1,
"stats": {"max_hp": 120 + 4 * int(Dictionary(action.get("summon", {})).get("power", 10)),
"tempo": 22.0, "initiative": 5.0, "guard": 2.0, "ward": 1.0,
"physical_power": float(Dictionary(action.get("summon", {})).get("power", 10)),
"potency": 0.0, "tenacity": 5.0, "finesse": 5.0},
"hp": 120 + 4 * int(Dictionary(action.get("summon", {})).get("power", 10)),
"guard": 2,
"readiness": 5.0,
"fallen": false,
"action_sequence": 0,
"profession_id": "",
"footprint": footprint,
"immovable": false,
"rooted": true,
"traits": [],
"equipment_hooks": [],
"profession_table": {},
"resource": 0,
"heal_charges": 0,
"cast": {},
"statuses": [],
"is_summon": true,
"owner_id": str(actor.id),
"summon_ticks": int(action.get("summon_ticks", 120)),
"last_target": "",
"action": {"id": "construct_strike", "base": 6, "coefficient": 1.0, "power_source": "physical_power",
"reach": 1, "kind": "damage", "target": "frontmost", "crit_bp": 0, "crit_multiplier": 1.5},
}
line.insert(mini(index + 1, line.size()), summon)
for position in range(line.size()):
line[position].position = position
summon_serial = int(summon_serial) + 1
_apply_cooldown_cut(actor, "on_summon")
if int(_trait_effect(actor, "on_summon").get("damage_reduction", 0)) > 0:
actor.pending_reduction = int(actor.get("pending_reduction", 0)) \
+ int(_trait_effect(actor, "on_summon").damage_reduction)
_apply_reach_gain(actor, "on_summon")
journal.append({
"tick": tick, "actor": str(actor.id), "target": str(summon.id),
"kind": "summon", "footprint": footprint, "position": int(summon.position), "hp_after": int(summon.hp),
"action": str(action.get("id", "")), "amount": 0,
})
return {"tick": tick, "actor": str(actor.id), "target": str(summon.id),
"action": str(action.get("id", "")), "kind": "construct", "amount": 0}
func _tick_summons() -> void:
for side in [Side.ALLY, Side.ENEMY]:
var line: Array[Dictionary] = allies if side == Side.ALLY else enemies
for actor in line:
if bool(actor.fallen) or not bool(actor.is_summon):
continue
actor.summon_ticks = int(actor.summon_ticks) - 1
if int(actor.summon_ticks) > 0:
continue
actor.fallen = true
summons_lost[side] = int(summons_lost.get(side, 0)) + 1
journal.append({"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "summon_expired", "hp_after": int(actor.hp)})
func _fire_fallen_hooks(actor: Dictionary) -> void:
# Section 18: the death trigger resolves once at its priority; the line
# collapse trigger follows the batch that removed the actor.
for hook in ["on_fallen", "after_collapse"]:
var effect := _trait_effect(actor, hook)
var owner_id := str(actor.get("id", ""))
if hook == "on_fallen":
_apply_transfer(actor, hook)
for side in [int(actor.side)]:
var line: Array[Dictionary] = allies if side == Side.ALLY else enemies
for survivor in line:
if bool(survivor.fallen) or str(survivor.id) == owner_id:
continue
if int(effect.get("heal_bonus", 0)) > 0:
survivor.hp = mini(int(survivor.stats.max_hp), int(survivor.hp) + int(effect.heal_bonus))
if int(effect.get("guard_bonus", 0)) > 0:
survivor.guard = int(survivor.guard) + int(effect.guard_bonus)
if int(effect.get("reach_bonus", 0)) > 0:
survivor.reach = int(survivor.reach) + int(effect.reach_bonus)
if int(effect.get("movement_immunity", 0)) > 0:
survivor.immovable = true
if not effect.is_empty():
journal.append({"tick": tick, "actor": owner_id, "target": owner_id,
"kind": "death_trigger", "hook": hook, "amount": 0, "hp_after": int(actor.hp)})
func _apply_status_work(record: Dictionary, source: Dictionary, victim: Dictionary) -> void:
if bool(record.get("cleanse", false)):
_cleanse(victim, source, record)
var extend := bool(record.get("extend_status", false))
if extend:
_extend_status(victim, source, record)
var status: Dictionary = Dictionary(record.get("status", {}))
if not status.is_empty():
_apply_status(victim, source, status, record)
func _apply_status(victim: Dictionary, source: Dictionary, status: Dictionary, record: Dictionary) -> void:
# Section 15: a resistible status rolls once against Potency versus
# Tenacity; a roll at or above the chance is recorded as resisted and
# changes nothing.
var potency := int(Dictionary(source.get("stats", {})).get("potency", 0))
var tenacity := int(Dictionary(victim.get("stats", {})).get("tenacity", 0))
var chance := clampi(int(status.get("chance_bp", 6000)) - 5 * (tenacity - potency), 0, 9500)
var roll := roll_bp("status", str(source.get("id", "")), int(source.get("action_sequence", 0)), str(victim.id))
var sign := str(status.get("sign", "hindrance"))
if roll >= chance:
journal.append({
"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "status_resisted", "status_id": str(status.get("id", "")),
"roll": roll, "chance_bp": chance, "hp_after": int(victim.hp),
})
return
var status_ticks := int(status.get("ticks", 30))
if sign == "hindrance" and not _scope_rows(source, "before_target", "debuffs_prefer_fastest").is_empty():
status_ticks = maxi(1, status_ticks - 1)
var entry := {
"id": str(status.get("id", "")),
"sign": sign,
"ticks": status_ticks,
}
var existing: Array = Array(victim.statuses)
for index in range(existing.size()):
if str(Dictionary(existing[index]).get("id", "")) == str(entry.id):
existing[index] = entry
victim.statuses = existing
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "status", "status_id": str(entry.id), "sign": sign, "ticks": int(entry.ticks), "refreshed": true})
_fire_status_hook(victim, "on_status_apply")
return
existing.append(entry)
victim.statuses = existing
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "status", "status_id": str(entry.id), "sign": sign, "ticks": int(entry.ticks)})
_fire_status_hook(victim, "on_status_apply")
_mirror_tag(victim, source, status)
func _mirror_tag(victim: Dictionary, source: Dictionary, status: Dictionary) -> void:
# A tag-copy row mirrors the first tag that lands on its owner onto the
# nearest opponent with the opposite sign, once per battle. The spent flag is
# raised before the mirrored application so two wearers cannot echo forever.
if int(source.side) == int(victim.side) or bool(victim.get("tag_copy_spent", false)):
return
if int(_trait_effect(victim, "on_status_apply").get("tag_copy", 0)) <= 0:
return
var opposing: Array[Dictionary] = enemies if int(victim.side) == Side.ALLY else allies
var nearest := {}
for candidate in opposing:
if bool(candidate.fallen):
continue
if nearest.is_empty() or absi(int(candidate.position) - int(victim.position)) \
< absi(int(Dictionary(nearest).position) - int(victim.position)):
nearest = candidate
if nearest.is_empty():
return
victim.tag_copy_spent = true
var sign := String(status.get("sign", "hindrance"))
var mirrored := {
"id": str(status.get("id", "")),
"sign": "boon" if sign == "hindrance" else "hindrance",
"ticks": int(status.get("ticks", 30)),
"chance_bp": 10000,
}
_apply_status(nearest, victim, mirrored, {"tick": tick, "actor": str(victim.id), "target": str(nearest.id)})
journal.append({"tick": tick, "actor": str(victim.id), "target": str(nearest.id),
"kind": "tag_mirror", "status_id": str(mirrored.id), "sign": str(mirrored.sign),
"reflected_from": str(source.get("id", "")), "amount": 0, "hp_after": int(nearest.hp)})
func _cleanse(victim: Dictionary, source: Dictionary, record: Dictionary) -> void:
var kept: Array = []
var removed: Array[String] = []
for status in Array(victim.statuses):
var entry: Dictionary = Dictionary(status)
if str(entry.get("sign", "hindrance")) == "hindrance":
removed.append(str(entry.get("id", "")))
else:
kept.append(entry)
victim.statuses = kept
for status_id in removed:
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "status_expire", "status_id": status_id, "expired": true, "hp_after": int(victim.hp)})
_fire_status_hook(victim, "on_status_expire")
_share_cleanse(victim)
func _share_cleanse(victim: Dictionary) -> void:
# A cleanse-share row lets the cleansed unit's opposing wearer shed its own
# oldest hindrance at the same moment, once per battle and only while the
# cleansed unit belongs to the other side.
var opposing: Array[Dictionary] = allies if int(victim.side) == Side.ENEMY else enemies
for candidate in opposing:
if bool(candidate.fallen) or bool(candidate.get("cleanse_share_spent", false)):
continue
if int(_trait_effect(candidate, "on_status_expire").get("cleanse_share", 0)) <= 0:
continue
var oldest := ""
for status in Array(candidate.statuses):
var entry: Dictionary = Dictionary(status)
if String(entry.get("sign", "hindrance")) == "hindrance":
oldest = String(entry.get("id", ""))
break
if oldest.is_empty():
candidate.cleanse_share_spent = true
continue
var kept: Array = []
for status in Array(candidate.statuses):
if String(Dictionary(status).get("id", "")) != oldest:
kept.append(status)
candidate.statuses = kept
candidate.cleanse_share_spent = true
journal.append({"tick": tick, "actor": str(candidate.id), "target": str(candidate.id),
"kind": "status_expire", "status_id": oldest, "expired": true, "shared_from": str(victim.id),
"hp_after": int(candidate.hp)})
_fire_status_hook(candidate, "on_status_expire")
func _extend_status(victim: Dictionary, source: Dictionary, record: Dictionary) -> void:
var statuses: Array = Array(victim.statuses)
for index in range(statuses.size()):
var entry: Dictionary = Dictionary(statuses[index])
if str(entry.get("sign", "hindrance")) != "hindrance":
continue
entry["ticks"] = int(entry.get("ticks", 0)) + int(record.get("extend_ticks", 30))
statuses[index] = entry
victim.statuses = statuses
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(victim.id),
"kind": "status", "status_id": str(entry.get("id", "")), "sign": str(entry.get("sign", "hindrance")),
"ticks": int(entry.ticks), "extended": true})
return
func _fire_status_hook(actor: Dictionary, hook: String) -> void:
var effect := _trait_effect(actor, hook)
if int(effect.get("guard_bonus", 0)) > 0:
actor.guard = int(actor.guard) + int(effect.guard_bonus)
if int(effect.get("damage_bonus", 0)) > 0:
actor.pending_damage_bonus = int(actor.get("pending_damage_bonus", 0)) + int(effect.damage_bonus)
if int(effect.get("damage_reduction", 0)) > 0:
# A screen declared on a status hook is banked and spent by the next hit.
actor.pending_reduction = int(actor.get("pending_reduction", 0)) + int(effect.damage_reduction)
if int(effect.get("heal_bonus", 0)) > 0:
actor.hp = mini(int(actor.stats.max_hp), int(actor.hp) + int(effect.heal_bonus))
_apply_reach_gain(actor, hook)
func _tick_statuses() -> void:
for actor in allies + enemies:
if bool(actor.fallen):
continue
var kept: Array = []
for status in Array(actor.statuses):
var entry: Dictionary = Dictionary(status)
entry["ticks"] = int(entry.get("ticks", 0)) - 1
if int(entry.ticks) > 0:
kept.append(entry)
continue
journal.append({"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "status_expire", "status_id": str(entry.get("id", "")), "hp_after": int(actor.hp)})
_fire_status_hook(actor, "on_status_expire")
actor.statuses = kept
func _scope_rows(actor: Dictionary, hook: String, scope: String) -> Array:
var rows: Array = []
for row in Array(actor.get("traits", [])):
var entry: Dictionary = Dictionary(row)
if bool(entry.get("implemented", false)) and String(entry.get("hook", "")) == hook \
and String(Dictionary(entry.get("effect", {})).get("scope", "")) == scope:
rows.append(entry)
return rows
func _fire_crosswind_line(actor: Dictionary) -> void:
# EN-R1-09: an advance draws the line between the opposing foremost and
# rearmost; the next swap crossing it is Hindered, an uncrossed line
# converts to one weak area hit when the window expires.
if _scope_rows(actor, "on_advance", "hinders_swaps_crossing_the_line").is_empty():
return
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
var living := _living(opposing)
if living.is_empty():
return
var front := int(living[0].position)
var rear := int(living[living.size() - 1].position)
crosswind = {
"source": actor,
"line": int(round(float(front + rear) * 0.5)),
"expires": tick + CROSSWIND_WINDOW_TICKS,
}
journal.append({"tick": tick, "actor": str(actor.id), "target": "", "kind": "signature",
"signature": "EN-R1-09", "phase": "line_drawn", "line": int(crosswind.line), "hp_after": 0})
func _resolve_crosswind_expiry() -> void:
var source: Dictionary = Dictionary(crosswind.get("source", {}))
var opposing: Array[Dictionary] = enemies if int(source.get("side", Side.ALLY)) == Side.ALLY else allies
var action := Dictionary(source.get("action", {}))
var damage := maxi(1, int(floor(float(action.get("base", 8)) * float(action.get("coefficient", 0.9)) * 0.5)))
for target in _living(opposing):
_apply_damage({"tick": tick, "actor": str(source.get("id", "")), "target": str(target.id),
"kind": "damage", "amount": damage, "hp_after": int(target.hp)})
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": "", "kind": "signature",
"signature": "EN-R1-09", "phase": "uncrossed_hit", "amount": damage, "hp_after": 0})
crosswind = {}
func _check_crosswind_swap(actor: Dictionary, from_position: int, to_position: int) -> void:
if crosswind.is_empty():
return
var line := float(crosswind.get("line", 0))
if (float(from_position) < line) == (float(to_position) < line):
return
var source: Dictionary = Dictionary(crosswind.get("source", {}))
var line_side: Array[Dictionary] = enemies if int(source.get("side", Side.ALLY)) == Side.ALLY else allies
if not line_side.has(actor):
return
_apply_status(actor, source, {"id": "hindered", "sign": "hindrance", "ticks": 40, "chance_bp": 9500}, {})
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(actor.id),
"kind": "signature", "signature": "EN-R1-09", "phase": "crossed_hindered", "hp_after": int(actor.hp)})
crosswind = {}
func _log_equipment_trigger(actor: Dictionary, hook: String) -> void:
# EQ-316: the battle counts distinct applied allied trigger tags; three of
# them schedule one repeat.
if chorus_scheduled or int(actor.side) != Side.ALLY:
return
for entry in chorus_tags:
if str(Dictionary(entry).get("tag", "")) == hook:
return
chorus_tags.append({"actor": actor, "tag": hook})
journal.append({"tick": tick, "actor": str(actor.id), "target": str(actor.id),
"kind": "signature", "signature": "EQ-316", "phase": "tag", "tag": hook, "hp_after": int(actor.hp)})
func _check_chorus_frame(actor: Dictionary) -> void:
# EQ-316 option A: the declared hook is the wearer's completed cast; only
# that path may schedule the repeat, and only while the frame is equipped.
if chorus_scheduled or int(actor.side) != Side.ALLY:
return
if not _has_hook_kind(actor, "on_cast_complete", "repeat"):
return
if chorus_tags.size() >= 3:
_schedule_chorus()
func _schedule_chorus() -> void:
# Minimal reading (M80-I37): the oldest applied tag is repeated at once with
# its non-damaging effect kinds only; every contributing item gains cooldown.
chorus_scheduled = true
var oldest: Dictionary = Dictionary(chorus_tags[0])
var source: Dictionary = Dictionary(oldest.get("actor", {}))
var tag := str(oldest.get("tag", ""))
var effect := _trait_effect(source, tag)
var applied := 0
if int(effect.get("guard_bonus", 0)) > 0:
source.guard = int(source.guard) + int(effect.guard_bonus)
applied += 1
if int(effect.get("movement_bonus", 0)) > 0:
source.momentum_bonus = int(source.get("momentum_bonus", 0)) + int(effect.movement_bonus)
applied += 1
if int(effect.get("damage_reduction", 0)) > 0:
source.pending_reduction = int(source.get("pending_reduction", 0)) + int(effect.damage_reduction)
applied += 1
journal.append({"tick": tick, "actor": str(source.get("id", "")), "target": str(source.get("id", "")),
"kind": "signature", "signature": "EQ-316", "phase": "repeated", "tag": tag,
"non_damaging": true, "applied": applied, "hp_after": int(source.get("hp", 0))})
var contributors: Array = []
for entry in chorus_tags:
var contributor: Dictionary = Dictionary(Dictionary(entry).get("actor", {}))
if not contributors.has(str(contributor.get("id", ""))):
contributors.append(str(contributor.get("id", "")))
contributor.readiness = maxf(0.0, float(contributor.readiness) - float(contributor.stats.tempo) * 0.1)
journal.append({"tick": tick, "actor": str(contributor.get("id", "")),
"target": str(contributor.get("id", "")), "kind": "signature", "signature": "EQ-316",
"phase": "cooldown", "hp_after": int(contributor.get("hp", 0))})
func _fastest_actor(units: Array[Dictionary]) -> Dictionary:
var fastest: Dictionary = {}
for unit in units:
if fastest.is_empty() or float(unit.readiness) > float(fastest.readiness):
fastest = unit
return fastest
func _marked_enemy_of(actor: Dictionary) -> Dictionary:
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
for candidate in _living(opposing):
for entry in Array(candidate.statuses):
if str(Dictionary(entry).get("id", "")) == "marked":
return candidate
return {}
func _check_triggerless(actor: Dictionary, events_before: int, action: Dictionary, target: Dictionary) -> void:
# EQ-273: three of the wearer's basics without any item trigger apply
# Exposed on the third; any item trigger clears the count.
if _scope_rows(actor, "on_cast_complete", "triggerless_exposed_counter").is_empty():
return
if str(action.get("id", "")) != str(Dictionary(actor.action).get("id", "")):
return
if equipment_trigger_events > events_before:
actor.triggerless_basics = 0
return
actor.triggerless_basics = int(actor.get("triggerless_basics", 0)) + 1
if int(actor.triggerless_basics) < 3:
return
actor.triggerless_basics = 0
_apply_status(target, actor, {"id": "exposed", "sign": "hindrance", "ticks": 40, "chance_bp": 9500}, {})
journal.append({"tick": tick, "actor": str(actor.id), "target": str(target.id),
"kind": "signature", "signature": "EQ-273", "phase": "exposed", "hp_after": int(target.hp)})
func _check_route_map(actor: Dictionary, from_position: int, to_position: int) -> void:
# EQ-155: stepping toward the Marked enemy preserves (refreshes) its Mark;
# stepping away removes it. Minimal reading: the effect acts on the enemy's
# current Mark with no ownership system (M80-I40).
if to_position == from_position:
return
if _scope_rows(actor, "on_advance", "step_direction_preserves_mark").is_empty():
return
var marked: Dictionary = _marked_enemy_of(actor)
if marked.is_empty():
return
if to_position < from_position:
for entry in Array(marked.statuses):
var status: Dictionary = Dictionary(entry)
if str(status.get("id", "")) == "marked":
status["ticks"] = maxi(40, int(status.get("ticks", 0)))
journal.append({"tick": tick, "actor": str(actor.id), "target": str(marked.id),
"kind": "signature", "signature": "EQ-155", "phase": "preserved", "hp_after": int(marked.hp)})
return
_remove_status(marked, "marked")
journal.append({"tick": tick, "actor": str(actor.id), "target": str(marked.id),
"kind": "signature", "signature": "EQ-155", "phase": "removed", "hp_after": int(marked.hp)})
func _remove_status(victim: Dictionary, status_id: String) -> void:
var kept: Array = []
for entry in Array(victim.statuses):
if str(Dictionary(entry).get("id", "")) == status_id:
continue
kept.append(entry)
victim.statuses = kept
func _check_forward_claim(actor: Dictionary) -> void:
# EQ-010: the first time the wearer becomes foremost it marks the current
# foremost enemy; moving rearward ends that mark; once per battle.
var key := str(actor.id)
if _has_hook_kind(actor, "on_advance", "mark"):
if int(actor.position) == 0:
if not forward_claim.has(key):
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
var living := _living(opposing)
if not living.is_empty():
var target: Dictionary = living[0]
_apply_status(target, actor, {"id": "marked", "sign": "hindrance", "ticks": 40, "chance_bp": 6000}, {})
forward_claim[key] = {"target": target, "ended": false}
journal.append({"tick": tick, "actor": key, "target": str(target.id), "kind": "signature",
"signature": "EQ-010", "phase": "applied", "hp_after": int(target.hp)})
return
if forward_claim.has(key):
var claim: Dictionary = Dictionary(forward_claim[key])
if not bool(claim.get("ended", false)):
var marked: Dictionary = Dictionary(claim.get("target", {}))
_remove_status(marked, "marked")
claim["ended"] = true
forward_claim[key] = claim
journal.append({"tick": tick, "actor": key, "target": str(marked.get("id", "")), "kind": "signature",
"signature": "EQ-010", "phase": "ended", "hp_after": int(marked.get("hp", 0))})
func _has_hook_kind(actor: Dictionary, hook: String, kind: String) -> bool:
for row in Array(actor.get("traits", [])):
var entry: Dictionary = Dictionary(row)
if not bool(entry.get("implemented", false)) or String(entry.get("hook", "")) != hook:
continue
var row_kind := String(entry.get("kind", ""))
if row_kind.is_empty():
row_kind = String(Dictionary(entry.get("effect", {})).get("kind", ""))
if row_kind == kind:
return true
return false
func _follow_devices(actor: Dictionary) -> void:
# EQ-310: on every movement of the wearer the device follows into the slot
# behind it and acts after arriving; each of its actions shortens its life.
if not _has_hook_kind(actor, "on_advance", "footprint"):
return
var line: Array[Dictionary] = allies if int(actor.side) == Side.ALLY else enemies
var wearer_index := -1
for candidate in range(line.size()):
if str(line[candidate].id) == str(actor.id):
wearer_index = candidate
break
if wearer_index < 0:
return
var devices: Array[Dictionary] = []
for candidate in line:
if bool(candidate.get("is_summon", false)) and not bool(candidate.fallen) \
and str(candidate.get("owner_id", "")) == str(actor.id):
devices.append(candidate)
var moved := 0
for device in devices:
var from_index := -1
for candidate in range(line.size()):
if str(line[candidate].id) == str(device.id):
from_index = candidate
break
if from_index < 0:
continue
line.remove_at(from_index)
var insert_at := mini(wearer_index + 1, line.size())
line.insert(insert_at, device)
device.readiness = maxf(float(device.readiness), READINESS_THRESHOLD)
moved += 1
for candidate in range(line.size()):
if str(line[candidate].id) == str(actor.id):
wearer_index = candidate
break
for position in range(line.size()):
line[position].position = position
if moved > 0:
journal.append({"tick": tick, "actor": str(actor.id), "target": "", "kind": "summon_follow",
"devices": moved, "hp_after": int(actor.hp)})
func _drain_device_duration(device: Dictionary) -> void:
var line: Array[Dictionary] = allies if int(device.side) == Side.ALLY else enemies
for candidate in line:
if str(candidate.id) == str(device.get("owner_id", "")):
if _has_hook_kind(candidate, "on_advance", "footprint"):
device.summon_ticks = maxi(0, int(device.summon_ticks) - 1)
journal.append({"tick": tick, "actor": str(device.id), "target": str(candidate.id),
"kind": "summon_drain", "ticks_left": int(device.summon_ticks), "hp_after": int(device.hp)})
return
func _has_hindrance(actor: Dictionary) -> bool:
for status in Array(actor.statuses):
if str(Dictionary(status).get("sign", "hindrance")) == "hindrance":
return true
return false
func _apply_forced_movement(victim: Dictionary, source: Dictionary, push: int, pull: int) -> void:
# Section 14.1: `push N` walks the target toward its rear through up to N
# adjacent block swaps, `pull N` toward its front. A step blocked by an
# Immovable actor or the line boundary deals one collision event:
# max(1, floor(0.08 * source PhysicalPower)).
var direction := 1 if push > 0 else -1
var push_hook := "on_push" if direction > 0 else "on_pull"
var banked := int(source.get("momentum_bonus", 0))
source.momentum_bonus = 0
var steps := maxi(push, pull) + int(_trait_effect(source, push_hook).get("movement_bonus", 0)) + banked
var from_position := int(victim.position)
var moved := 0
var blocked := false
for _step in range(steps):
if not _forced_swap(victim, direction):
blocked = true
break
moved += 1
if moved > 0 and direction < 0:
var advance := _trait_effect(victim, "on_advance")
if int(advance.get("guard_bonus", 0)) > 0:
victim.guard = int(victim.guard) + int(advance.guard_bonus)
if int(advance.get("movement_bonus", 0)) > 0:
victim.momentum_bonus = int(victim.get("momentum_bonus", 0)) + int(advance.movement_bonus)
if int(advance.get("damage_bonus", 0)) > 0:
victim.pending_damage_bonus = int(victim.get("pending_damage_bonus", 0)) + int(advance.damage_bonus)
if int(advance.get("rear_guard", 0)) > 0:
var advance_line: Array[Dictionary] = allies if int(victim.side) == Side.ALLY else enemies
var behind := advance_line.find(victim) + 1
if behind < advance_line.size() and not bool(advance_line[behind].fallen):
advance_line[behind].guard = int(advance_line[behind].guard) + int(advance.rear_guard)
_apply_cooldown_cut(victim, "on_advance")
_fire_crosswind_line(victim)
if moved > 0:
journal.append({
"tick": tick,
"actor": str(source.get("id", "")),
"target": str(victim.id),
"kind": "movement",
"steps": moved,
"from": from_position,
"to": int(victim.position),
"hp_after": int(victim.hp),
})
if blocked:
var push_effect := _trait_effect(source, "on_push")
if int(push_effect.get("guard_bonus", 0)) > 0:
source.guard = int(source.guard) + int(push_effect.guard_bonus)
var power := float(Dictionary(source.get("stats", {})).get("physical_power", 0.0))
var collision := maxi(1, int(floor(0.08 * power)))
victim.hp = maxi(0, int(victim.hp) - collision)
journal.append({
"tick": tick,
"actor": str(source.get("id", "")),
"target": str(victim.id),
"kind": "collision",
"amount": collision,
"hp_after": int(victim.hp),
})
func _forced_swap(actor: Dictionary, direction: int) -> bool:
# Footprint blocks move indivisibly; this core swaps single-footprint actors
# and refuses the step when either side is Immovable or the boundary is hit.
var line: Array[Dictionary] = allies if int(actor.side) == Side.ALLY else enemies
var index := -1
for candidate in range(line.size()):
if str(line[candidate].id) == str(actor.id):
index = candidate
break
if index == -1:
return false
var neighbor_index := index + direction
if neighbor_index < 0 or neighbor_index >= line.size():
return false
var neighbor: Dictionary = line[neighbor_index]
if bool(actor.immovable) or bool(neighbor.immovable) or bool(neighbor.fallen):
return false
var actor_position := int(actor.position)
var neighbor_position := int(neighbor.position)
actor.position = neighbor_position
neighbor.position = actor_position
line[index] = neighbor
line[neighbor_index] = actor
_check_crosswind_swap(actor, actor_position, neighbor_position)
_follow_devices(actor)
_check_forward_claim(actor)
_check_route_map(actor, actor_position, neighbor_position)
return true
static func _hook_rows(row: Dictionary) -> Array:
# Trait rows and equipment hooks share one shape so `_trait_effect` applies
# both through the same section-17 hook vocabulary.
var rows := TraitHooks.for_actor(Array(row.get("trait_ids", [])))
for hook in Array(row.get("equipment_hooks", [])):
rows.append(Dictionary(hook))
return rows
static func _profession_table(row: Dictionary) -> Dictionary:
var profession_id := str(row.get("profession_id", ""))
if profession_id.is_empty():
return {}
return ProfessionActions.table(profession_id)
func _trait_effect(actor: Dictionary, hook: String) -> Dictionary:
# Applies the implemented hook rows for this actor and returns the summed
# declarative effect. Unimplemented rows are skipped until their system lands.
var total := {"reach_bonus": 0, "damage_bonus": 0, "guard_bonus": 0, "heal_bonus": 0,
"resource_gain": 0, "haste": 0, "damage_reduction": 0, "tempo_bonus": 0,
"status_apply": 0, "movement_bonus": 0, "cooldown_cut": 0, "transfer": 0,
"neighbor_guard_chip": 0, "rear_guard": 0, "interrupt_strike": 0,
"interrupt_guard": 0, "movement_immunity": 0, "tag_copy": 0, "cleanse_share": 0,
"ready_carry": 0}
for row in Array(actor.get("traits", [])):
var entry: Dictionary = Dictionary(row)
if not bool(entry.get("implemented", false)) or String(entry.get("hook", "")) != hook:
continue
var effect: Dictionary = Dictionary(entry.get("effect", {}))
var kind := String(effect.get("kind", ""))
if total.has(kind):
total[kind] = int(total[kind]) + int(effect.get("value", 0))
if int(effect.get("value", 0)) != 0 or entry.has("kind"):
if String(effect.get("scope", "")) != "triggerless_exposed_counter":
equipment_trigger_events += 1
_log_equipment_trigger(actor, hook)
return total
func _choose_row(actor: Dictionary) -> Dictionary:
# Section 10: scan the Profession priority rows top to bottom and start the
# first row whose visible condition holds and whose target is legal.
var table: Dictionary = actor.profession_table
if table.is_empty():
return Dictionary(actor.action)
for row in Array(table.get("priority", [])):
var candidate: Dictionary = Dictionary(row)
if not _condition_holds(actor, str(candidate.get("condition", "always"))):
continue
if str(candidate.get("kind", "damage")) == "healing" and int(actor.heal_charges) <= 0:
continue
var target := _select_target_for(actor, candidate)
if target.is_empty():
continue
var condition := str(candidate.get("condition", "always"))
if condition.contains("_ready"):
# A ready token is spent by the row it unlocks; the core does not yet
# model the declared per-resource refill rules beyond the gain flags.
actor.resource = maxi(0, int(actor.resource) - 1)
return candidate
return {}
func _condition_holds(actor: Dictionary, condition: String) -> bool:
var living_allies := _living(allies if int(actor.side) == Side.ALLY else enemies)
var living_enemies := _living(enemies if int(actor.side) == Side.ALLY else allies)
match condition:
"always":
return true
"neighbor_lethal":
if living_allies.size() < 2:
return false
var neighbor: Dictionary = living_allies[mini(int(actor.position) + 1, living_allies.size() - 1)]
return int(neighbor.hp) <= int(neighbor.stats.max_hp) * 0.35
"self_guard_below_half":
return int(actor.guard) < int(actor.stats.guard) / 2
"new_target_available":
return _has_new_target(actor, living_enemies)
"front_gap_open":
return int(actor.position) > 0
"ally_lethal_forecast":
return _has_ally_below(living_allies, 0.35)
"poise_ready":
return _resource_ready(actor)
"edge_ready_and_low_target":
return _resource_ready(actor) and _has_enemy_below(living_enemies, 0.35)
"charge_ready_and_clustered_pair":
return _resource_ready(actor) and _has_clustered_pair(living_enemies)
"aim_ready_and_rear_caster":
return _resource_ready(actor) and _has_rear_caster(actor, living_enemies)
"verse_ready_and_slower_ally":
return _resource_ready(actor) and _has_slower_ally(actor, living_allies)
"bond_ready_and_ally_below_threshold":
return _resource_ready(actor) and _has_ally_below(living_allies, 0.50)
"supply_ready_and_urgent_item":
return _resource_ready(actor) and _has_ally_below(living_allies, 0.50)
"remedy_ready_and_ally_status":
return _resource_ready(actor) and _has_ally_with_hindrance(living_allies)
"omen_ready_and_enemy_status":
return _resource_ready(actor) and _has_enemy_with_hindrance(living_enemies)
"marked_target_visible":
return _has_enemy_with_hindrance(living_enemies)
"self_marked_and_neighbor_open":
return _has_hindrance(actor) and living_allies.size() > 1
"summon_present":
return _has_living_summon(actor)
"device_ready":
return _has_living_summon(actor) and int(actor.resource) <= 3
"device_destroyed_and_parts_ready":
return _resource_ready(actor) and int(summons_lost.get(int(actor.side), 0)) > 0
"link_broken":
return _link_is_broken(actor)
"rear_neighbor_endangered":
return _rear_neighbor_endangered(actor, living_allies)
"summon_present":
return _has_living_summon(actor)
"device_ready":
return _has_living_summon(actor) and int(actor.resource) <= 3
"device_destroyed_and_parts_ready":
return _resource_ready(actor) and int(summons_lost.get(int(actor.side), 0)) > 0
# Audited in M99: every condition named by the tables now has a system.
# Anything not matched here would be a hole, and the gate records the
# enabled/pending split so a stale list cannot hide one.
"enemy_major_windup":
return not _major_windup_actor(living_enemies).is_empty()
"link_broken":
return _link_is_broken(actor)
return false
func _resource_ready(actor: Dictionary) -> bool:
return int(actor.resource) >= 1
func _lowest_hp_ally_with_hindrance(actor: Dictionary, living_allies: Array[Dictionary]) -> Dictionary:
var best := {}
for ally in living_allies:
if not _has_hindrance(ally):
continue
if best.is_empty() or int(ally.hp) < int(Dictionary(best).get("hp", 1 << 30)):
best = ally
return best
func _frontmost_enemy_with_hindrance(living_enemies: Array[Dictionary]) -> Dictionary:
var best := {}
for enemy in living_enemies:
if not _has_hindrance(enemy):
continue
if best.is_empty() or int(enemy.position) < int(Dictionary(best).get("position", 1 << 30)):
best = enemy
return best
func _link_is_broken(actor: Dictionary) -> bool:
# A thread is broken when its bound unit is Fallen or gone; the enable
# Profession holds a row that repairs it.
var link_id := str(actor.get("link_id", ""))
if link_id.is_empty():
return false
var linked := _find_actor(link_id)
if linked.is_empty():
return true
return bool(linked.get("fallen", false))
func _has_living_summon(actor: Dictionary) -> bool:
var line: Array[Dictionary] = allies if int(actor.side) == Side.ALLY else enemies
for candidate in line:
if not bool(candidate.fallen) and bool(candidate.is_summon):
return true
return false
func _has_ally_with_hindrance(living_allies: Array[Dictionary]) -> bool:
for ally in living_allies:
if _has_hindrance(ally):
return true
return false
func _has_enemy_with_hindrance(living_enemies: Array[Dictionary]) -> bool:
for enemy in living_enemies:
if _has_hindrance(enemy):
return true
return false
func _has_new_target(actor: Dictionary, living_enemies: Array[Dictionary]) -> bool:
var last := str(actor.last_target)
for enemy in living_enemies:
if str(enemy.id) != last:
return true
return false
func _has_ally_below(living_allies: Array[Dictionary], ratio: float) -> bool:
for ally in living_allies:
if int(ally.hp) <= int(ally.stats.max_hp) * ratio:
return true
return false
func _has_enemy_below(living_enemies: Array[Dictionary], ratio: float) -> bool:
for enemy in living_enemies:
if int(enemy.hp) <= int(enemy.stats.max_hp) * ratio:
return true
return false
func _has_clustered_pair(living_enemies: Array[Dictionary]) -> bool:
var depth := -2
for enemy in living_enemies:
var position := int(enemy.position)
if position == depth + 1:
return true
depth = position
return false
func _has_rear_caster(actor: Dictionary, living_enemies: Array[Dictionary]) -> bool:
var rear_from := int(ceil(float(living_enemies.size()) / 2.0))
for enemy in living_enemies:
var kind := str(Dictionary(enemy.action).get("kind", "damage"))
if int(enemy.position) >= rear_from and kind in ["ranged", "magic"]:
return true
return false
func _has_slower_ally(actor: Dictionary, living_allies: Array[Dictionary]) -> bool:
var tempo := float(actor.stats.tempo)
for ally in living_allies:
if str(ally.id) != str(actor.id) and float(ally.stats.tempo) < tempo:
return true
return false
func _rear_neighbor_endangered(actor: Dictionary, living_allies: Array[Dictionary]) -> bool:
var index := -1
for candidate in range(living_allies.size()):
if str(living_allies[candidate].id) == str(actor.id):
index = candidate
break
if index == -1:
return false
var rear := -1
var best := 1 << 30
for candidate in range(living_allies.size()):
var position := int(living_allies[candidate].position)
if position > int(actor.position) and position < best:
best = position
rear = candidate
if rear == -1:
return false
var neighbor: Dictionary = living_allies[rear]
return int(neighbor.hp) <= int(neighbor.stats.max_hp) * 0.35
func _select_target_for(actor: Dictionary, action: Dictionary) -> Dictionary:
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
var policy := str(action.get("target", "frontmost"))
if not _scope_rows(actor, "before_target", "debuffs_prefer_fastest").is_empty() \
and str(_status_of(action).get("sign", "")) == "hindrance":
var fastest := _fastest_actor(_living(opposing))
if not fastest.is_empty():
journal.append({"tick": tick, "actor": str(actor.id), "target": str(fastest.id),
"kind": "signature", "signature": "EQ-029", "phase": "retargeted", "hp_after": int(fastest.hp)})
return fastest
var reach_bonus := int(_trait_effect(actor, "before_target").get("reach_bonus", 0))
if reach_bonus > 0:
action = action.duplicate(true)
var reach_value = action.get("reach", 1)
if reach_value is int:
action["reach"] = int(reach_value) + reach_bonus
match policy:
"self":
return actor
"ally_status":
return _lowest_hp_ally_with_hindrance(actor, opposing)
"enemy_status":
return _frontmost_enemy_with_hindrance(opposing)
"active_cast":
return _any_casting_actor(opposing)
"ally_lowest_hp", "ally_lethal_forecast", "ally_lowest_resource", "ally_slower", "ally_urgent_item", "ally_rear_endangered":
return _ally_target(actor, policy)
"lowest_hp_ratio":
var pool := _living(opposing)
if pool.is_empty():
return {}
pool.sort_custom(func(a, b): return float(a.hp) / float(a.stats.max_hp) < float(b.hp) / float(b.stats.max_hp))
return pool[0]
"rearmost":
var rear := _living(opposing)
return rear[rear.size() - 1] if not rear.is_empty() else {}
"device_slot":
return {}
_:
var target := _select_target(actor)
if not target.is_empty() and policy == "frontmost":
actor.last_target = str(target.id)
return target
func _ally_target(actor: Dictionary, policy: String) -> Dictionary:
var living_allies := _living(allies if int(actor.side) == Side.ALLY else enemies)
if living_allies.is_empty():
return {}
living_allies.sort_custom(func(a, b): return float(a.hp) / float(a.stats.max_hp) < float(b.hp) / float(b.stats.max_hp))
return living_allies[0]
func _select_target(actor: Dictionary) -> Dictionary:
# Section 11 order: side, reach, protection, then the `frontmost` policy with
# a labeled tie-break.
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
var reach := int(actor.action.get("reach", actor.reach))
var candidates := _reachable(actor, opposing, int(reach))
if candidates.is_empty():
return {}
var rules := _target_rules(actor, "before_target")
if "dead_target_neighbor" in rules:
var follower := _neighbor_of_fallen(actor, candidates)
if not follower.is_empty():
actor.last_target = str(follower.id)
return follower
var best: Dictionary = candidates[0]
var best_depth := int(best.position)
for candidate in candidates:
if int(candidate.position) < best_depth:
best = candidate
best_depth = int(candidate.position)
var ties: Array[Dictionary] = []
for candidate in candidates:
if int(candidate.position) == best_depth:
ties.append(candidate)
if ties.size() > 1:
var tie_rules := _target_rules(actor, "before_target")
if "tie_rearmost" in tie_rules:
# `sort_custom` is not stable, so the rear-most member of a tie is
# read from the authored line order rather than from array order.
var rear: Dictionary = ties[0]
var rear_index := -1
for candidate in ties:
var index := opposing.find(candidate)
if index > rear_index:
rear = candidate
rear_index = index
return rear
var roll := roll_bp("target_tie", str(actor.id), int(actor.action_sequence) + 1, str(best.id))
best = ties[roll % ties.size()]
return best
func _apply_transfer(actor: Dictionary, hook: String) -> void:
# A transfer row hands a carried pool to the ally who needs it most: the
# living side member with the lowest resource, or guard when the owner has
# already fallen. Nothing moves when the owner holds nothing.
var amount := int(_trait_effect(actor, hook).get("transfer", 0))
if amount <= 0:
return
var pool := int(actor.resource)
var source := "resource"
if bool(actor.fallen) or pool <= 0:
pool = int(actor.guard)
source = "guard"
if pool <= 0:
return
var line: Array[Dictionary] = allies if int(actor.side) == Side.ALLY else enemies
var receiver := {}
for candidate in line:
if bool(candidate.fallen) or str(candidate.id) == str(actor.id):
continue
if receiver.is_empty() or int(candidate.resource) < int(receiver.resource):
receiver = candidate
if receiver.is_empty():
return
var moved := mini(amount, pool)
if source == "resource":
actor.resource = pool - moved
receiver.resource = int(receiver.resource) + moved
else:
actor.guard = pool - moved
receiver.guard = int(receiver.guard) + moved
journal.append({"tick": tick, "actor": str(actor.id), "target": str(receiver.id),
"kind": "transfer", "hook": hook, "source": source, "amount": moved,
"hp_after": int(receiver.hp)})
func _apply_resource_gain(actor: Dictionary, hook: String) -> void:
# A resource row adds to whatever the action already earned, so the hook
# fires on any gain rather than only after another resource row.
var gain := int(_trait_effect(actor, hook).get("resource_gain", 0))
if gain > 0:
actor.resource = int(actor.resource) + gain
func _apply_reach_gain(actor: Dictionary, hook: String) -> void:
# A reach row widens the owner's own reach for the rest of the fight.
var gain := int(_trait_effect(actor, hook).get("reach_bonus", 0))
if gain <= 0:
return
actor.reach = int(actor.reach) + gain
if actor.action.get("reach") is int:
actor.action["reach"] = int(actor.action.reach) + gain
func _apply_cooldown_cut(actor: Dictionary, hook: String) -> void:
# Section 10 readiness: a cooldown row refunds readiness worth `value` tenths
# of a second at the actor's current tempo, so the next action arrives sooner.
var cut := int(_trait_effect(actor, hook).get("cooldown_cut", 0))
if cut <= 0:
return
actor.readiness = minf(READINESS_CAP,
float(actor.readiness) + float(cut) * float(actor.stats.tempo) * 0.1)
func _target_rules(actor: Dictionary, hook: String) -> Array[String]:
# Targeting rows alter the decision, not the numbers: a rule is read from
# the applied hook row that names it.
var rules: Array[String] = []
for row in Array(actor.get("traits", [])):
var entry: Dictionary = Dictionary(row)
if not bool(entry.get("implemented", false)) or String(entry.get("hook", "")) != hook:
continue
var effect: Dictionary = Dictionary(entry.get("effect", {}))
if String(effect.get("kind", "")) == "targeting" and not String(effect.get("rule", "")).is_empty():
rules.append(String(effect.rule))
return rules
func _neighbor_of_fallen(actor: Dictionary, candidates: Array[Dictionary]) -> Dictionary:
# Section 11 continuation: when the recorded target has fallen the next
# action follows its nearest living neighbour, ties going to the front.
var anchor_id := str(actor.get("last_target", ""))
if anchor_id.is_empty():
return {}
var anchor := {}
for row in allies + enemies:
if str(row.id) == anchor_id:
anchor = row
break
if anchor.is_empty() or not bool(anchor.get("fallen", false)):
return {}
var best := {}
var best_distance := 9999
for candidate in candidates:
var distance := absi(int(candidate.position) - int(Dictionary(anchor).get("position", 0)))
if distance < best_distance or (distance == best_distance and not best.is_empty()
and int(candidate.position) < int(Dictionary(best).get("position", 0))):
best = candidate
best_distance = distance
return best
func _reachable(actor: Dictionary, opposing: Array[Dictionary], reach: int) -> Array[Dictionary]:
# Reach N may select through at most N-1 intervening living units; `line`
# reaches any legal unit (section 11).
var living: Array[Dictionary] = []
for candidate in opposing:
if not bool(candidate.fallen):
living.append(candidate)
living.sort_custom(func(a, b): return int(a.position) < int(b.position))
if str(actor.action.get("reach", reach)) == "line" or reach >= living.size():
return living
var allowed := mini(living.size(), maxi(1, reach))
return living.slice(0, allowed)
func _living(line: Array[Dictionary]) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for actor in line:
if not bool(actor.fallen):
result.append(actor)
return result
func _mark_fallen() -> void:
for actor in allies + enemies:
if not bool(actor.fallen) and int(actor.hp) <= 0:
actor.fallen = true
fallen_count += 1
_fire_fallen_hooks(actor)
if bool(actor.is_summon):
var side := int(actor.side)
summons_lost[side] = int(summons_lost.get(side, 0)) + 1
func _compact() -> void:
# Section 18: living units close vacancies preserving relative order.
_compact_line(allies)
_compact_line(enemies)
func _compact_line(line: Array[Dictionary]) -> void:
var living: Array[Dictionary] = []
for actor in line:
if not bool(actor.fallen):
living.append(actor)
living.sort_custom(func(a, b): return int(a.position) < int(b.position))
for index in range(living.size()):
living[index].position = index
if living.is_empty():
return
func _evaluate_outcome() -> void:
# Section 19: breaking the enemy line wins; losing every ally loses. A
# timeout is decided by the caller after MAX_TICKS.
if _living(enemies).is_empty():
outcome = "victory"
outcome_tick = tick
elif _living(allies).is_empty():
outcome = "defeat"
outcome_tick = tick
func _snapshot(line: Array[Dictionary]) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for actor in line:
rows.append({
"id": str(actor.id),
"position": int(actor.position),
"hp": int(actor.hp),
"guard": int(actor.guard),
"fallen": bool(actor.fallen),
"readiness": int(float(actor.readiness)),
"max_hp": int(Dictionary(actor.stats).max_hp),
"ready_with_priority": bool(actor.get("ready_with_priority", false)),
})
return rows