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

191 lines
7.6 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, then the
## first seven resolved actions, then the result. Receipts, snapshots and the
## result envelope stay with BattleService.
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
const KIND_MAP := {
"damage": "damage",
"ranged": "ranged",
"magic": "magic",
"healing": "healing",
"guard": "guard",
"construct": "guard",
"reorder": "guard",
"delay": "telegraph",
"tempo": "telegraph",
"supply": "guard",
"movement": "telegraph",
"collision": "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, {}))))
var simulation: ABGBattleSimulation = Simulation.new()
simulation.setup(seed, ally_rows, enemy_rows)
var result := simulation.run()
return {
"ok": true,
"outcome": String(result.outcome),
"ticks": int(result.tick),
"allies": result.allies,
"enemies": result.enemies,
"journal": result.journal,
"beats": _beats(result.journal, result.outcome),
"knocked_out_ids": _knocked_out_ids(result.journal, result.allies),
"events": _events(result),
}
static func _beats(journal: Array, outcome: String) -> Array[Dictionary]:
var beats: Array[Dictionary] = []
var fallen_seen := {}
for record in journal:
var entry: Dictionary = Dictionary(record)
var killer_kind := String(entry.get("kind", "damage"))
beats.append({
"tick": int(entry.get("tick", 0)),
"actor_id": String(entry.get("actor", "")),
"target_id": String(entry.get("target", "")),
"kind": String(KIND_MAP.get(killer_kind, "damage")),
"amount": int(entry.get("amount", 0)),
"crit": bool(entry.get("crit", false)),
})
if int(entry.get("hp_after", 1)) <= 0 and not fallen_seen.has(String(entry.get("target", ""))):
fallen_seen[String(entry.get("target", ""))] = true
beats.append({
"tick": int(entry.get("tick", 0)),
"actor_id": String(entry.get("actor", "")),
"target_id": String(entry.get("target", "")),
"kind": "knockout",
"amount": 0,
"crit": false,
})
beats.append({"tick": 0, "actor_id": "", "target_id": "", "kind": "result", "outcome": outcome, "amount": 0, "crit": false})
return beats
static func _events(result: Dictionary) -> Array[Dictionary]:
# The nine presentation phases: opening, seven beats, result.
var beats: Array = Array(result.get("beats", []))
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 String(result.get("outcome", "")) == "victory" else "defeat", "actor_id": "", "target_id": "", "amount": 0, "outcome": String(result.get("outcome", "victory"))})
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