316 lines
13 KiB
GDScript
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
|