feat(runtime): add battle live page controller

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:10:03 +08:00
co-authored by Codex
parent 61149f2a4f
commit 31ed44fa5b
2 changed files with 332 additions and 0 deletions
@@ -0,0 +1,207 @@
class_name ABGBattleLivePageController
extends RefCounted
## UI-independent BAT-002 Live Battle view-data assembly.
##
## BattleService remains responsible for observation preview/commit, snapshot
## retry, settlement and every receipt boundary. This controller only validates
## and copies the facts that the existing Live Battle View is allowed to show.
const VALID_STATES: Array[String] = [
"waiting_for_next_event",
"event_forecast",
"action_committed",
"event_resolving",
"event_resolved",
"paused",
"inspect_handoff",
"pause_controls_handoff",
"outcome_locked",
"invalid_event_data",
"snapshot_retrying",
"loading_resume",
"interrupted_background",
"no_resumable_battle",
]
func snapshot(
company: Dictionary,
battle: Dictionary,
observation: Dictionary,
current_event: Dictionary,
state := "waiting_for_next_event",
resume_state := "waiting_for_next_event",
paused := true,
speed := 1.0,
notice := "",
error_code := "",
interruption: Dictionary = {},
pause_log_open := false,
locale := "zh_CN"
) -> Dictionary:
if company.is_empty():
return {"ok": false, "code": "COMPANY_REQUIRED"}
var raw_contract = company.get("active_contract", {})
if not raw_contract is Dictionary or Dictionary(raw_contract).is_empty():
return {"ok": false, "code": "LIVE_CONTRACT_REQUIRED"}
if battle.is_empty():
return {"ok": false, "code": "LIVE_BATTLE_REQUIRED"}
if String(battle.get("status", "")) not in ["active", "outcome_locked"]:
return {"ok": false, "code": "LIVE_BATTLE_INVALID"}
if state not in VALID_STATES or resume_state not in VALID_STATES:
return {"ok": false, "code": "LIVE_STATE_INVALID"}
var raw_observation = observation
if not raw_observation is Dictionary or Dictionary(raw_observation).is_empty():
return {"ok": false, "code": "LIVE_OBSERVATION_REQUIRED"}
var copied_observation: Dictionary = Dictionary(raw_observation).duplicate(true)
var operation_id := String(battle.get("operation_id", ""))
if operation_id.is_empty() or String(copied_observation.get("operation_id", "")) != operation_id:
return {"ok": false, "code": "LIVE_OPERATION_MISMATCH"}
var battle_snapshot_hash := String(battle.get("snapshot_hash", ""))
var observation_snapshot_hash := String(copied_observation.get("snapshot_hash", ""))
if not battle_snapshot_hash.is_empty() and not observation_snapshot_hash.is_empty() and battle_snapshot_hash != observation_snapshot_hash:
return {"ok": false, "code": "LIVE_SNAPSHOT_MISMATCH"}
if String(copied_observation.get("status", "")) not in ["waiting_for_next_event", "outcome_locked"]:
return {"ok": false, "code": "LIVE_OBSERVATION_INVALID"}
if state == "outcome_locked" and String(battle.get("status", "")) != "outcome_locked" and String(copied_observation.get("status", "")) != "outcome_locked":
return {"ok": false, "code": "LIVE_OUTCOME_LOCK_MISSING"}
var contract: Dictionary = Dictionary(raw_contract).duplicate(true)
var members := _members_by_id(company.get("owned_recruits", []))
var party_ids = battle.get("front_to_rear_ids", [])
if not party_ids is Array or party_ids.is_empty():
return {"ok": false, "code": "LIVE_PARTY_INVALID"}
var party_validation := _validate_ids(party_ids, members)
if not party_validation.ok:
return party_validation
var enemy_ids = battle.get("enemy_ids", [])
if not enemy_ids is Array or enemy_ids.is_empty():
return {"ok": false, "code": "LIVE_ENEMY_LINE_INVALID"}
var copied_event := Dictionary(current_event).duplicate(true)
var copied_events := _copy_array(battle.get("phase_events", []))
var copied_interrupt := Dictionary(interruption).duplicate(true)
var ordered_recruits: Array[Dictionary] = []
for position in range(party_validation.ids.size()):
var recruit_id := String(party_validation.ids[position])
ordered_recruits.append({
"position": position,
"recruit_id": recruit_id,
"recruit": Dictionary(members[recruit_id]).duplicate(true),
})
var copied_battle: Dictionary = battle.duplicate(true)
var state_flags := _state_flags(state)
var commands := _commands_for_state(state)
return {
"ok": true,
"page_id": "battle_live",
"locale": String(locale),
"company": {
"company_id": String(company.get("company_id", "")),
"owned_count": members.size(),
"line_capacity": int(contract.get("line_capacity", party_validation.ids.size())),
},
"contract": {
"contract_id": String(contract.get("contract_id", "")),
"run_id": String(contract.get("run_id", "")),
"contract_hash": String(contract.get("contract_hash", "")),
},
"battle": {
"operation_id": operation_id,
"battle_hash": String(battle.get("battle_hash", "")),
"status": String(battle.get("status", "")),
"snapshot_id": String(battle.get("snapshot_id", "")),
"snapshot_hash": battle_snapshot_hash,
"front_to_rear_ids": party_validation.ids.duplicate(true),
"enemy_ids": _copy_array(enemy_ids),
"phase_index": int(battle.get("phase_index", 0)),
"total_phases": int(battle.get("total_phases", 0)),
"phase_events": copied_events,
"copy": copied_battle,
},
"observation": copied_observation,
"party": {
"front_to_rear_ids": party_validation.ids.duplicate(true),
"ordered_recruits": ordered_recruits,
},
"enemies": {
"ids": _copy_array(enemy_ids),
"count": enemy_ids.size(),
},
"event": copied_event,
"recent_events": copied_events.slice(maxi(0, copied_events.size() - 4), copied_events.size()),
"state": {
"name": String(state),
"resume_state": String(resume_state),
"paused": bool(paused),
"speed": maxf(1.0, float(speed)),
"notice": String(notice),
"error_code": String(error_code),
"pause_log_open": bool(pause_log_open),
"interruption": copied_interrupt,
"flags": state_flags,
},
"commands": commands,
}
func _state_flags(state: String) -> Dictionary:
return {
"waiting": state == "waiting_for_next_event",
"event_active": state in ["event_forecast", "action_committed", "event_resolving", "event_resolved"],
"paused": state in ["paused", "inspect_handoff", "pause_controls_handoff"],
"inspect": state == "inspect_handoff",
"pause_controls": state == "pause_controls_handoff",
"outcome_locked": state == "outcome_locked",
"interrupted": state == "interrupted_background",
"invalid": state in ["invalid_event_data", "no_resumable_battle"],
"loading": state in ["snapshot_retrying", "loading_resume"],
}
func _commands_for_state(state: String) -> Dictionary:
var controls_locked := state in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"]
return {
"pause_enabled": not controls_locked and state not in ["inspect_handoff", "pause_controls_handoff"],
"speed_enabled": not controls_locked and state not in ["action_committed", "event_resolving", "inspect_handoff", "pause_controls_handoff"],
"inspect_enabled": state not in ["outcome_locked", "invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle", "pause_controls_handoff"],
"settings_enabled": state not in ["invalid_event_data", "snapshot_retrying", "loading_resume", "interrupted_background", "no_resumable_battle"],
"next_boundary_enabled": state not in ["action_committed", "event_resolving", "invalid_event_data", "snapshot_retrying", "loading_resume", "no_resumable_battle"],
"retry_snapshot_enabled": state == "invalid_event_data",
"safe_exit_enabled": state in ["invalid_event_data", "interrupted_background", "no_resumable_battle"],
"outcome_handoff_enabled": state == "outcome_locked",
"interruption_review_enabled": state == "interrupted_background",
}
func _validate_ids(values: Array, members: Dictionary) -> Dictionary:
var ids: Array = []
for value in values:
var recruit_id := String(value)
if not members.has(recruit_id):
return {"ok": false, "code": "LIVE_RECRUIT_UNKNOWN"}
if recruit_id in ids:
return {"ok": false, "code": "LIVE_RECRUIT_DUPLICATE"}
ids.append(recruit_id)
return {"ok": true, "code": "LIVE_PARTY_VALID", "ids": ids}
func _members_by_id(values) -> Dictionary:
var result := {}
if not values is Array:
return result
for value in values:
if value is Dictionary:
var copied: Dictionary = Dictionary(value).duplicate(true)
var recruit_id := String(copied.get("recruit_id", ""))
if not recruit_id.is_empty():
result[recruit_id] = copied
return result
func _copy_array(values) -> Array:
var result: Array = []
if not values is Array:
return result
for value in values:
result.append(value.duplicate(true) if value is Dictionary or value is Array else value)
return result
@@ -0,0 +1,125 @@
extends SceneTree
const Controller = preload("res://entry/battle_live_page_controller.gd")
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var controller := Controller.new()
var company := _fixture_company()
var battle := _fixture_battle()
var observation := _fixture_observation()
var event := _fixture_event()
var ready: Dictionary = controller.snapshot(company, battle, observation, event, "event_resolved", "event_resolved", false, 2.0, "", "", {}, true, "en")
expect(ready.ok and ready.page_id == "battle_live", "snapshot identifies Live Battle")
expect(ready.company.owned_count == 4 and ready.company.line_capacity == 4, "snapshot copies Company capacity")
expect(ready.battle.operation_id == "battle-op-001" and ready.battle.phase_index == 1, "snapshot copies battle identity and phase")
expect(ready.observation.snapshot_hash == "snapshot-hash-001" and ready.observation.next_phase_index == 1, "snapshot copies observation identity")
expect(ready.event.event_id == "event-001" and ready.recent_events.size() == 1, "snapshot copies current and recent event facts")
expect(ready.party.ordered_recruits.size() == 4 and ready.enemies.count == 3, "snapshot binds Party and enemy lines")
expect(ready.state.speed == 2.0 and ready.state.pause_log_open, "snapshot copies playback presentation facts")
expect(ready.commands.pause_enabled and ready.commands.inspect_enabled and ready.commands.settings_enabled, "event-resolved commands remain eligible")
var paused: Dictionary = controller.snapshot(company, battle, observation, event, "paused", "event_resolved", true, 1.0)
expect(paused.ok and paused.state.flags.paused and paused.commands.pause_enabled and paused.commands.speed_enabled, "paused state keeps pause and speed controls")
var inspect: Dictionary = controller.snapshot(company, battle, observation, event, "inspect_handoff", "paused", true, 1.0)
expect(inspect.ok and inspect.state.flags.inspect and not inspect.commands.pause_enabled and not inspect.commands.speed_enabled, "inspect handoff freezes playback controls")
var locked_battle := battle.duplicate(true)
locked_battle.status = "outcome_locked"
var locked_observation := observation.duplicate(true)
locked_observation.status = "outcome_locked"
var locked: Dictionary = controller.snapshot(company, locked_battle, locked_observation, event, "outcome_locked", "outcome_locked", true, 1.0)
expect(locked.ok and locked.state.flags.outcome_locked and locked.commands.outcome_handoff_enabled and not locked.commands.inspect_enabled, "outcome lock exposes only OUT-001 handoff")
var interrupted: Dictionary = controller.snapshot(company, battle, observation, {}, "interrupted_background", "event_resolved", true, 1.0, "resume", "", {"snapshot_hash": "snapshot-hash-001"})
expect(interrupted.ok and interrupted.state.flags.interrupted and interrupted.commands.interruption_review_enabled and interrupted.commands.safe_exit_enabled, "interruption exposes BAT-006 and safe exit")
var invalid: Dictionary = controller.snapshot(company, battle, observation, {}, "invalid_event_data", "event_resolved", true, 1.0, "invalid", "OBSERVATION_EVENT_INVALID")
expect(invalid.ok and invalid.commands.retry_snapshot_enabled and invalid.commands.safe_exit_enabled and not invalid.commands.pause_enabled, "invalid event exposes retry and safe exit")
var copied_battle: Dictionary = ready.battle.copy
copied_battle.phase_events.append({"event_id": "changed"})
ready.observation.outcome_lock = {"changed": true}
ready.event.state_delta.amount = 99
expect(battle.phase_events.size() == 1 and String(battle.phase_events[0].get("event_id", "")) == "event-001", "battle facts are deep copied")
expect(observation.get("outcome_lock", {}).is_empty(), "observation facts are deep copied")
expect(event.state_delta.amount == 3, "event facts are deep copied")
var missing: Dictionary = controller.snapshot({}, battle, observation, event)
expect(not missing.ok and missing.code == "COMPANY_REQUIRED", "missing Company is rejected")
var no_observation: Dictionary = controller.snapshot(company, battle, {}, event)
expect(not no_observation.ok and no_observation.code == "LIVE_OBSERVATION_REQUIRED", "missing observation is rejected")
var mismatch := observation.duplicate(true)
mismatch.snapshot_hash = "different"
var rejected_mismatch: Dictionary = controller.snapshot(company, battle, mismatch, event)
expect(not rejected_mismatch.ok and rejected_mismatch.code == "LIVE_SNAPSHOT_MISMATCH", "snapshot mismatch is rejected")
if failures.is_empty():
print("ABG_M26_BATTLE_LIVE_CONTROLLER_OK company=4 party=4 enemies=3 states=waiting,event,paused,inspect,outcome,interrupted,invalid receipt=copied commands=pause,speed,inspect,retry,safe_exit copies=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _fixture_company() -> Dictionary:
return {
"company_id": "company-001",
"owned_recruits": [
{"recruit_id": "r-1", "rendered_name_snapshot": {"display_name": "Ari"}},
{"recruit_id": "r-2", "rendered_name_snapshot": {"display_name": "Bram"}},
{"recruit_id": "r-3", "rendered_name_snapshot": {"display_name": "Cera"}},
{"recruit_id": "r-4", "rendered_name_snapshot": {"display_name": "Dain"}},
],
"active_contract": {"contract_id": "CTR-R1-01", "run_id": "run-001", "line_capacity": 4, "contract_hash": "contract-hash-001"},
}
func _fixture_battle() -> Dictionary:
return {
"status": "active",
"operation_id": "battle-op-001",
"battle_hash": "battle-hash-001",
"snapshot_id": "snapshot-001",
"snapshot_hash": "snapshot-hash-001",
"front_to_rear_ids": ["r-1", "r-2", "r-3", "r-4"],
"enemy_ids": ["enemy-1", "enemy-2", "enemy-3"],
"phase_index": 1,
"total_phases": 9,
"phase_events": [_fixture_event()],
}
func _fixture_observation() -> Dictionary:
return {
"status": "waiting_for_next_event",
"operation_id": "battle-op-001",
"snapshot_id": "snapshot-001",
"snapshot_hash": "snapshot-hash-001",
"receipt_hash": "receipt-hash-001",
"next_phase_index": 1,
"event_journal_hash": "journal-hash-001",
"outcome_lock": {},
}
func _fixture_event() -> Dictionary:
return {
"event_id": "event-001",
"event_hash": "event-hash-001",
"event_tick": 1,
"kind": "damage",
"actor_id": "r-1",
"target_id": "enemy-1",
"amount": 3,
"state_delta": {"amount": 3, "direction": "loss"},
}
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("M26 Battle Live Controller: %s" % message)