1758 lines
70 KiB
GDScript
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
|