From 31ed44fa5b280a2f9ae55fef99282ddab27d3aea Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 25 Aug 2026 18:10:03 +0800 Subject: [PATCH] feat(runtime): add battle live page controller Co-Authored-By: Codex --- runtime/entry/battle_live_page_controller.gd | 207 ++++++++++++++++++ .../battle_live_page_controller_test.gd | 125 +++++++++++ 2 files changed, 332 insertions(+) create mode 100644 runtime/entry/battle_live_page_controller.gd create mode 100644 runtime/tests/foundation/battle_live_page_controller_test.gd diff --git a/runtime/entry/battle_live_page_controller.gd b/runtime/entry/battle_live_page_controller.gd new file mode 100644 index 00000000..9a5cd6ae --- /dev/null +++ b/runtime/entry/battle_live_page_controller.gd @@ -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 diff --git a/runtime/tests/foundation/battle_live_page_controller_test.gd b/runtime/tests/foundation/battle_live_page_controller_test.gd new file mode 100644 index 00000000..9cab4d14 --- /dev/null +++ b/runtime/tests/foundation/battle_live_page_controller_test.gd @@ -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)