Files
aetherbound-guild/runtime/entry/battle_sim_bridge.gd
T

316 lines
13 KiB
GDScript

class_name ABGBattleSimBridge
extends RefCounted
## M80-B5-lite: run the authoritative simulation for one battle and translate it
## into the presentation's bounded event beats.
##
## The simulation owns the mechanics (readiness, targeting, damage, knockouts,
## outcome). The presentation keeps its nine-beat vocabulary: opening, up to
## seven selected player-facing beats, then the result. Receipts, snapshots and
## the result envelope stay with BattleService.
##
## M80-P1: only declared player-facing journal records may take a display slot.
## Bookkeeping records are dropped by name and unknown kinds are dropped and
## reported through `beat_diagnostics`, never silently coerced into `damage`.
const Simulation = preload("res://domain/battle_simulation.gd")
const Authoring = preload("res://domain/battle_stat_authoring.gd")
const ProfessionActions = preload("res://domain/profession_actions.gd")
const ContentRegistry = preload("res://p10_0/content_registry.gd")
const EquipmentHooks = preload("res://domain/equipment_hooks.gd")
const DISPLAY_BEATS := 7 # phases 1..7 between opening and result
## Player-facing journal kinds and the one presentation family each may take.
## Every resolved action row resolves into exactly one of these kinds:
## `damage`/`reorder` deal damage on the shared `_apply_damage` path;
## `ranged`/`magic`/`healing`/`guard` are their own families; `construct` and
## `supply` resolve into the support family; `tempo` and `delay` announce a
## readiness/positional effect (the M77 telegraph family); `cast_start` is the
## wind-up announcement the M77 contract calls the major telegraph.
const ACTION_PRESENTATION := {
"damage": "damage",
"collision": "damage",
"reorder": "damage",
"ranged": "ranged",
"magic": "magic",
"healing": "healing",
"guard": "guard",
"construct": "guard",
"supply": "guard",
"tempo": "telegraph",
"delay": "telegraph",
"cast_start": "telegraph",
}
## Journal kinds the simulation logs for bookkeeping only. They are not
## player-facing actions or announcements: they take no display slot and are
## never coerced into a family. This is the documented fallback for records that
## are not player-facing; a kind outside both tables is skipped and reported so
## the focused gate fails instead of guessing.
const BOOKKEEPING_KINDS := [
"cast_resolve", "cast_cancelled", "cast_delayed",
"status", "status_resisted", "status_expire",
"movement", "link",
"summon", "summon_rejected", "summon_follow", "summon_expired", "summon_drain",
"tag_mirror", "transfer", "death_trigger", "signature",
]
## Coverage order: one earliest beat per family is selected first so every
## family that occurs in the journal is reachable at play size. `knockout` is
## included so a fallen unit is shown whenever the journal proves one.
const COVERAGE_ORDER := ["knockout", "ranged", "magic", "healing", "guard", "telegraph", "damage"]
static func run(seed: String, formation: Array, recruits: Array, enemy_ids: Array, region_index: int, enemy_meta: Dictionary) -> Dictionary:
var ally_rows: Array = []
for index in range(formation.size()):
var recruit_id := String(formation[index])
ally_rows.append(_ally_row(recruit_id, index, _recruit_by_id(recruits, recruit_id)))
var enemy_rows: Array = []
for index in range(enemy_ids.size()):
var enemy_id := String(enemy_ids[index])
enemy_rows.append(_enemy_row(enemy_id, index, region_index, Dictionary(enemy_meta.get(enemy_id, {}))))
return _run_rows(seed, ally_rows, enemy_rows)
static func run_encounter(seed: String, formation: Array, recruits: Array, encounter: Array, region_index: int, enemy_meta: Dictionary) -> Dictionary:
## M104: an authored encounter row owns the rank for this occurrence. The
## legacy ID-only path above remains unchanged for P10.0 and M103; M80
## contract probes use this path so a row marked elite cannot be silently
## replaced by the catalog identity's default class.
var ally_rows: Array = []
for index in range(formation.size()):
var recruit_id := String(formation[index])
ally_rows.append(_ally_row(recruit_id, index, _recruit_by_id(recruits, recruit_id)))
var enemy_rows: Array = []
for index in range(encounter.size()):
var entry: Dictionary = Dictionary(encounter[index])
var enemy_id := String(entry.get("enemy_id", ""))
var meta: Dictionary = Dictionary(enemy_meta.get(enemy_id, {})).duplicate(true)
if entry.has("class"):
meta["class"] = String(entry.get("class", "ordinary"))
if entry.has("level"):
meta["level"] = int(entry.get("level", 1))
enemy_rows.append(_enemy_row(enemy_id, index, region_index, meta))
return _run_rows(seed, ally_rows, enemy_rows)
static func _run_rows(seed: String, ally_rows: Array, enemy_rows: Array) -> Dictionary:
# Untyped on purpose: the script preload is the dependency, so a fresh-archive
# `--script` run never depends on editor class-cache registration order.
var simulation = Simulation.new()
simulation.setup(seed, ally_rows, enemy_rows)
var result := simulation.run()
var report := _beat_report(result.journal)
var selected := _select_beats(report.beats)
if not report.unknown_names.is_empty():
# Runtime surfacing: the attempt harness treats errors as a failed run, so
# a new unclassified journal kind cannot ship silently. `beat_diagnostics`
# stays out of the battle state because that state is hash/receipt-bound.
push_error("M80-P1 bridge: unclassified journal kinds %s are dropped, not coerced" % str(report.unknown_names))
return {
"ok": true,
"outcome": String(result.outcome),
"ticks": int(result.tick),
"allies": result.allies,
"enemies": result.enemies,
"journal": result.journal,
"beats": selected,
"beat_diagnostics": {
"selected_families": _families(selected),
"skipped_kinds": report.skipped,
"unknown_kinds": report.unknown_names,
},
"knocked_out_ids": _knocked_out_ids(result.journal, result.allies),
"events": _events(selected, String(result.outcome)),
}
static func _beat_report(journal: Array) -> Dictionary:
# Only declared player-facing records become beats. Every beat carries its
# journal sequence so selection stays deterministic and the playback order
# is reproducible. Knockouts are derived once per fallen unit.
var beats: Array[Dictionary] = []
var skipped := {}
var unknown := {}
var fallen_seen := {}
for index in range(journal.size()):
var entry: Dictionary = Dictionary(journal[index])
var source_kind := String(entry.get("kind", ""))
if not ACTION_PRESENTATION.has(source_kind):
if BOOKKEEPING_KINDS.has(source_kind):
skipped[source_kind] = int(skipped.get(source_kind, 0)) + 1
else:
unknown[source_kind] = int(unknown.get(source_kind, 0)) + 1
continue
var target_id := String(entry.get("target", ""))
beats.append({
"seq": beats.size(),
"tick": int(entry.get("tick", 0)),
"actor_id": String(entry.get("actor", "")),
"target_id": target_id,
"kind": String(ACTION_PRESENTATION[source_kind]),
"source_kind": source_kind,
"amount": int(entry.get("amount", 0)),
"crit": bool(entry.get("crit", false)),
})
if int(entry.get("hp_after", 1)) <= 0 and not target_id.is_empty() and not fallen_seen.has(target_id):
fallen_seen[target_id] = true
beats.append({
"seq": beats.size(),
"tick": int(entry.get("tick", 0)),
"actor_id": String(entry.get("actor", "")),
"target_id": target_id,
"kind": "knockout",
"source_kind": source_kind,
"amount": 0,
"crit": false,
})
var unknown_names: Array = unknown.keys()
unknown_names.sort()
return {"beats": beats, "skipped": skipped, "unknown_names": unknown_names, "unknown": unknown}
static func _select_beats(beats: Array) -> Array[Dictionary]:
# Deterministic and bounded: cover every occurring family once, then fill the
# remaining slots with the earliest unselected beats, then restore journal
# order. Never invents or repeats a record to fill the seven slots.
var selected: Array[Dictionary] = []
var used := {}
for family in COVERAGE_ORDER:
if selected.size() >= DISPLAY_BEATS:
break
for record in beats:
var entry: Dictionary = Dictionary(record)
if String(entry.get("kind", "")) != family:
continue
used[int(entry.get("seq", -1))] = true
selected.append(entry)
break
for record in beats:
if selected.size() >= DISPLAY_BEATS:
break
var entry: Dictionary = Dictionary(record)
if used.has(int(entry.get("seq", -1))):
continue
used[int(entry.get("seq", -1))] = true
selected.append(entry)
selected.sort_custom(func(a, b): return int(a.get("seq", 0)) < int(b.get("seq", 0)))
return selected
static func _families(beats: Array) -> Array[String]:
var families: Array[String] = []
for record in beats:
var family := String(Dictionary(record).get("kind", ""))
if not family.is_empty() and not families.has(family):
families.append(family)
families.sort()
return families
static func _events(beats: Array, outcome: String) -> Array[Dictionary]:
# The nine presentation phases: opening, seven selected beats, result. The
# shape and captions are unchanged; a journal that proves fewer than seven
# beats holds its last selected beat for the remaining slots.
var events: Array[Dictionary] = []
events.append({"phase": 0, "kind": "opening", "caption_id": "battle_opening", "actor_id": "", "target_id": "", "amount": 0, "outcome": "observed"})
for index in range(DISPLAY_BEATS):
var beat: Dictionary = Dictionary(beats[mini(index, beats.size() - 1)]) if not beats.is_empty() else {}
var kind := String(beat.get("kind", "telegraph"))
if kind == "result":
kind = "telegraph"
var caption := _caption_for(kind)
events.append({
"phase": index + 1,
"kind": kind,
"caption_id": caption,
"actor_id": String(beat.get("actor_id", "")),
"target_id": String(beat.get("target_id", "")),
"amount": int(beat.get("amount", 0)),
"crit": bool(beat.get("crit", false)),
"outcome": "observed",
})
events.append({"phase": 8, "kind": "result", "caption_id": "victory" if outcome == "victory" else "defeat", "actor_id": "", "target_id": "", "amount": 0, "outcome": outcome})
return events
static func _caption_for(kind: String) -> String:
match kind:
"ranged": return "ranged_contact"
"magic": return "magic_break"
"healing": return "healing_link"
"guard": return "protection_intercepts"
"telegraph": return "incoming_threat"
"knockout": return "knockout"
return "decisive_damage"
static func _knocked_out_ids(journal: Array, allies: Array) -> Array[String]:
var ids: Array[String] = []
for row in allies:
var entry: Dictionary = Dictionary(row)
if bool(entry.get("fallen", false)) and String(entry.get("id", "")).begins_with("ally-"):
ids.append(String(entry.id).trim_prefix("ally-"))
return ids
static func _recruit_by_id(recruits: Array, recruit_id: String) -> Dictionary:
# The company stores `recruit_id`; probe and sweep fixtures may pass `id`.
# Accept both so a fixture mistake can never silently flatten the party into
# default PF-B01 rows.
for recruit in recruits:
var entry: Dictionary = Dictionary(recruit)
if String(entry.get("recruit_id", "")) == recruit_id or String(entry.get("id", "")) == recruit_id:
return entry
return {}
static func _ally_row(recruit_id: String, position: int, recruit: Dictionary) -> Dictionary:
var profession_id := String(recruit.get("profession_id", "PF-B01"))
# Section 9 reads `profession_base_hp/tempo/initiative`; the raw
# `profession_bases` table uses short keys and never reached the simulation.
var bases := Authoring.profession_row(profession_id)
var row := {
"id": "ally-%s" % recruit_id,
"position": position,
"level": int(recruit.get("level", 1)),
"profession_id": profession_id,
"trait_ids": [String(recruit.get("trait_id", ""))],
"might": int(recruit.get("might", 8)), "finesse": int(recruit.get("finesse", 8)),
"insight": int(recruit.get("insight", 8)), "vigor": int(recruit.get("vigor", 8)),
"resolve": int(recruit.get("resolve", 8)),
"armor_guard": 2, "armor_ward": 1, "weapon_power": 3, "focus_power": 1,
}
row.merge(bases, false)
# Section 9/5: HP persists between battles and a MaxHP change preserves the
# HP percentage. A carried value therefore enters the battle as the current
# HP rather than a full heal.
if int(recruit.get("hp", 0)) > 0:
row["hp"] = int(recruit["hp"])
if int(recruit.get("guard", 0)) >= 0 and recruit.has("guard"):
row["guard"] = int(recruit["guard"])
if bool(recruit.get("ready_with_priority", false)):
row["ready_with_priority"] = true
var item_id := str(recruit.get("equipped_item_id", ""))
if not item_id.is_empty():
var support := Authoring.equipment_row(item_id, int(row.level), ContentRegistry.snapshot())
for key in ["weapon_power", "focus_power", "armor_guard", "armor_ward"]:
row[key] = int(support[key])
var hooks: Dictionary = EquipmentHooks.table(ContentRegistry.snapshot())
var hook: Dictionary = Dictionary(hooks.get(item_id, {}))
if not hook.is_empty() and bool(hook.get("implemented", false)):
row["equipment_hooks"] = [hook]
return row
static func _enemy_row(enemy_id: String, position: int, region_index: int, meta: Dictionary) -> Dictionary:
var class_id := String(meta.get("class", "ordinary"))
var row := Authoring.enemy_row(enemy_id, class_id, region_index)
row["id"] = "enemy-%s" % enemy_id
row["position"] = position
row["level"] = maxi(1, int(meta.get("level", region_index + 1)))
row["profession_id"] = ""
return row