class_name ABGBattleResultPageController extends RefCounted ## UI-independent OUT-001 Battle Result view-data assembly. ## ## BattleService remains responsible for outcome settlement, reward, knockout ## acknowledgement and recovery. This controller only validates the settled ## boundary, copies the facts the existing Result View renders and exposes ## read-only diagnosis/Continue eligibility. const SETTLED_STATUS := "result" func snapshot( company: Dictionary, battle: Dictionary, result: Dictionary = {}, diagnosis_open := false, notice := "", locale := "zh_CN" ) -> Dictionary: if company.is_empty(): return {"ok": false, "code": "COMPANY_REQUIRED"} var raw_recruits = company.get("owned_recruits", []) if not raw_recruits is Array: return {"ok": false, "code": "RESULT_COMPANY_INVALID"} var raw_contract = company.get("active_contract", {}) if not raw_contract is Dictionary or Dictionary(raw_contract).is_empty(): return {"ok": false, "code": "RESULT_CONTRACT_REQUIRED"} if battle.is_empty(): return {"ok": false, "code": "RESULT_BATTLE_REQUIRED"} if String(battle.get("status", "")) != SETTLED_STATUS: return {"ok": false, "code": "RESULT_BATTLE_NOT_SETTLED"} var operation_id := String(battle.get("operation_id", "")) if operation_id.is_empty(): return {"ok": false, "code": "RESULT_OPERATION_REQUIRED"} var members := _members_by_id(raw_recruits) if members.is_empty(): return {"ok": false, "code": "RESULT_COMPANY_EMPTY"} var contract: Dictionary = Dictionary(raw_contract).duplicate(true) var sourced_result := result if sourced_result.is_empty(): sourced_result = battle.get("result", {}) if not sourced_result is Dictionary or Dictionary(sourced_result).is_empty(): return {"ok": false, "code": "RESULT_REQUIRED"} var copied_result: Dictionary = Dictionary(sourced_result).duplicate(true) var persisted_result = battle.get("result", {}) if persisted_result is Dictionary and not Dictionary(persisted_result).is_empty(): var persisted_result_hash := String(Dictionary(persisted_result).get("result_hash", "")) var supplied_result_hash := String(copied_result.get("result_hash", "")) if not persisted_result_hash.is_empty() and not supplied_result_hash.is_empty() and persisted_result_hash != supplied_result_hash: return {"ok": false, "code": "RESULT_IDENTITY_MISMATCH"} var result_operation_id := String(copied_result.get("operation_id", "")) if not result_operation_id.is_empty() and result_operation_id != operation_id: return {"ok": false, "code": "RESULT_OPERATION_MISMATCH"} var receipt_state := _settlement_receipt(contract, battle, operation_id) if not bool(receipt_state.get("ok", false)): return receipt_state var settlement_receipt: Dictionary = receipt_state.receipt var phase_events = battle.get("phase_events", []) if not phase_events is Array or phase_events.is_empty(): return {"ok": false, "code": "RESULT_EVENTS_REQUIRED"} var copied_events: Array = _copy_array(phase_events) var decisive_hash := String(copied_result.get("decisive_event_hash", "")) var decisive_event := _find_event(copied_events, decisive_hash) if decisive_event.is_empty(): return {"ok": false, "code": "RESULT_DECISIVE_EVENT_MISSING"} var result_knockouts = copied_result.get("knocked_out_ids", battle.get("knocked_out_ids", [])) if not result_knockouts is Array: return {"ok": false, "code": "RESULT_KNOCKOUTS_INVALID"} var copied_knockouts: Array = _copy_array(result_knockouts) var battle_knockouts = battle.get("knocked_out_ids", []) if battle_knockouts is Array and not battle_knockouts.is_empty() and copied_knockouts != battle_knockouts: return {"ok": false, "code": "RESULT_KNOCKOUT_IDENTITY_MISMATCH"} var knockout_id_set := {} var knockout_identities: Array = [] for raw_id in copied_knockouts: var recruit_id := String(raw_id) if recruit_id.is_empty() or not members.has(recruit_id): return {"ok": false, "code": "RESULT_KNOCKOUT_RECRUIT_UNKNOWN"} if knockout_id_set.has(recruit_id): return {"ok": false, "code": "RESULT_KNOCKOUT_DUPLICATE"} knockout_id_set[recruit_id] = true knockout_identities.append({ "recruit_id": recruit_id, "recruit": Dictionary(members[recruit_id]).duplicate(true), "battle_local": true, "returns_next_stage": true, }) var reward_state := _reward_state(battle.get("reward_draft", {})) if not bool(reward_state.get("ok", false)): return reward_state var copied_reward_draft: Dictionary = reward_state.draft var reward_pending := bool(reward_state.pending) var continue_target := _continue_target(not copied_knockouts.is_empty(), reward_pending) var causal_ids: Array = [] for event in copied_events: if event is Dictionary: var event_id := String(event.get("event_id", event.get("event_hash", ""))) if not event_id.is_empty(): causal_ids.append(event_id) var observation: Dictionary = Dictionary(battle.get("observation", {})).duplicate(true) var causal_chain := { "events": copied_events.duplicate(true), "event_ids": causal_ids, "count": copied_events.size(), "decisive_event_id": String(decisive_event.get("event_id", "")), "decisive_event_hash": String(decisive_event.get("event_hash", decisive_hash)), "event_journal_hash": String(copied_result.get("event_journal_hash", observation.get("event_journal_hash", ""))), "source_id": String(decisive_event.get("actor_id", decisive_event.get("source_actor_id", ""))), "target_id": String(decisive_event.get("target_id", decisive_event.get("reserved_target_id", ""))), } var copied_recruits := _copy_array(raw_recruits) var xp_by_recruit: Dictionary = Dictionary(copied_result.get("xp_by_recruit", {})).duplicate(true) var survivor_states: Array = [] for recruit in copied_recruits: if not recruit is Dictionary: continue var recruit_id := String(recruit.get("recruit_id", "")) survivor_states.append({ "recruit_id": recruit_id, "recruit": Dictionary(recruit).duplicate(true), "knocked_out": knockout_id_set.has(recruit_id), "xp": Dictionary(xp_by_recruit.get(recruit_id, {})).duplicate(true), }) var copied_battle: Dictionary = battle.duplicate(true) var copied_contract: Dictionary = { "contract_id": String(contract.get("contract_id", "")), "run_id": String(contract.get("run_id", "")), "status": String(contract.get("status", "result")), "difficulty": String(contract.get("difficulty", "standard")), "coin": int(contract.get("coin", copied_result.get("coin_after", 0))), "line_capacity": int(contract.get("line_capacity", battle.get("front_to_rear_ids", []).size())), "contract_hash": String(contract.get("contract_hash", "")), } var receipt_identity := { "operation_id": operation_id, "battle_operation_id": String(settlement_receipt.get("battle_operation_id", operation_id)), "receipt_hash": String(settlement_receipt.get("receipt_hash", "")), "outcome_hash": String(settlement_receipt.get("outcome_hash", copied_result.get("outcome_hash", ""))), "result_hash": String(copied_result.get("result_hash", "")), } return { "ok": true, "page_id": "battle_result", "locale": String(locale), "company": { "company_id": String(company.get("company_id", "")), "owned_count": members.size(), "line_capacity": int(contract.get("line_capacity", battle.get("front_to_rear_ids", []).size())), "recruits": copied_recruits, }, "contract": copied_contract, "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": String(battle.get("snapshot_hash", "")), "phase_index": int(battle.get("phase_index", 0)), "total_phases": int(battle.get("total_phases", copied_events.size())), "front_to_rear_ids": _copy_array(battle.get("front_to_rear_ids", [])), "enemy_ids": _copy_array(battle.get("enemy_ids", [])), "phase_events": copied_events.duplicate(true), "knocked_out_ids": copied_knockouts.duplicate(true), "copy": copied_battle, }, "result": copied_result, "outcome": { "kind": String(copied_result.get("outcome", "")), "cause": String(copied_result.get("outcome_cause", "")), "outcome_hash": String(copied_result.get("outcome_hash", "")), }, "decisive_event": decisive_event, "causal_chain": causal_chain, "economy": { "coin_before": int(copied_result.get("coin_before", 0)), "coin_after": int(copied_result.get("coin_after", 0)), "reward_coin": int(copied_result.get("reward_coin", 0)), "xp_award": int(copied_result.get("xp_award", 0)), "xp_per_recruit": int(copied_result.get("xp_per_recruit", 0)), "xp_by_recruit": xp_by_recruit, }, "knockouts": { "ids": copied_knockouts.duplicate(true), "count": copied_knockouts.size(), "identities": knockout_identities, "battle_local": true, "returns_next_stage": true, }, "survivors": { "members": survivor_states, "count": survivor_states.size() - copied_knockouts.size(), }, "reward": { "draft": copied_reward_draft, "present": bool(reward_state.present), "status": String(reward_state.status), "pending": reward_pending, "options": _copy_array(copied_reward_draft.get("options", [])), "options_count": Array(copied_reward_draft.get("options", [])).size(), }, "receipt": { "settlement": settlement_receipt, "identity": receipt_identity, "count": int(receipt_state.count), "duplicate_safe": bool(receipt_state.duplicate_safe), }, "diagnosis": { "open": bool(diagnosis_open), "state": "open" if bool(diagnosis_open) else "closed", "notice": String(notice), "detail_available": true, }, "continuation": { "eligible": true, "target": continue_target, "reason": _continue_reason(not copied_knockouts.is_empty(), reward_pending), "reward_pending": reward_pending, "knockout_review_required": not copied_knockouts.is_empty(), }, "commands": { "diagnosis_enabled": true, "continue_enabled": true, "safe_exit_enabled": true, "reward_review_enabled": reward_pending, "knockout_review_enabled": not copied_knockouts.is_empty(), }, } func _settlement_receipt(contract: Dictionary, battle: Dictionary, operation_id: String) -> Dictionary: var matches: Array = [] var raw_receipts = contract.get("battle_settlement_receipts", []) if raw_receipts is Array: for raw_receipt in raw_receipts: if raw_receipt is Dictionary and String(raw_receipt.get("operation_id", "")) == operation_id: matches.append(Dictionary(raw_receipt).duplicate(true)) if matches.is_empty(): var battle_receipt = battle.get("settlement_receipt", {}) if battle_receipt is Dictionary and not Dictionary(battle_receipt).is_empty(): if String(battle_receipt.get("operation_id", operation_id)) == operation_id: matches.append(Dictionary(battle_receipt).duplicate(true)) if matches.is_empty(): return {"ok": false, "code": "RESULT_SETTLEMENT_RECEIPT_REQUIRED"} if matches.size() > 1: return {"ok": false, "code": "RESULT_SETTLEMENT_RECEIPT_DUPLICATE"} var receipt: Dictionary = matches[0] var receipt_battle_id := String(receipt.get("battle_operation_id", operation_id)) if not receipt_battle_id.is_empty() and receipt_battle_id != operation_id: return {"ok": false, "code": "RESULT_RECEIPT_OPERATION_MISMATCH"} return { "ok": true, "receipt": receipt, "count": matches.size(), "duplicate_safe": matches.size() == 1, } func _reward_state(raw_draft) -> Dictionary: if raw_draft == null: raw_draft = {} if not raw_draft is Dictionary: return {"ok": false, "code": "RESULT_REWARD_DRAFT_INVALID"} var draft: Dictionary = Dictionary(raw_draft).duplicate(true) if draft.is_empty(): return {"ok": true, "present": false, "pending": false, "status": "none", "draft": {}} var raw_options = draft.get("options", []) if not raw_options is Array: return {"ok": false, "code": "RESULT_REWARD_OPTIONS_INVALID"} var ids := {} for raw_option in raw_options: if not raw_option is Dictionary: return {"ok": false, "code": "RESULT_REWARD_OPTION_INVALID"} var reward_id := String(raw_option.get("reward_id", "")) if reward_id.is_empty(): return {"ok": false, "code": "RESULT_REWARD_ID_INVALID"} if ids.has(reward_id): return {"ok": false, "code": "RESULT_REWARD_ID_DUPLICATE"} ids[reward_id] = true var status := String(draft.get("status", "open")) return { "ok": true, "present": true, "pending": status == "open", "status": status, "draft": draft, } func _continue_target(has_knockout: bool, reward_pending: bool) -> String: if reward_pending: return "battle_knockout_review" if has_knockout else "battle_reward_choice" return "battle_knockout_review" if has_knockout else "battle_recovery" func _continue_reason(has_knockout: bool, reward_pending: bool) -> String: if reward_pending and has_knockout: return "knockout_then_reward" if reward_pending: return "reward_choice" if has_knockout: return "knockout_review" return "recovery" func _find_event(events: Array, event_hash: String) -> Dictionary: if event_hash.is_empty(): return {} for raw_event in events: if raw_event is Dictionary and String(raw_event.get("event_hash", "")) == event_hash: return Dictionary(raw_event).duplicate(true) return {} 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