class_name ABGKnockoutReviewPageController extends RefCounted ## UI-independent OUT-002 Knockout Review view-data assembly. ## ## BattleService remains responsible for settlement, reward, recovery and ## persistence. This controller only validates/copies the settled knockout ## facts and exposes the existing acknowledgement route eligibility. func snapshot( company: Dictionary, battle: Dictionary, result: Dictionary = {}, 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": "KNOCKOUT_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": "KNOCKOUT_CONTRACT_REQUIRED"} if battle.is_empty(): return {"ok": false, "code": "KNOCKOUT_BATTLE_REQUIRED"} if String(battle.get("status", "")) != "result": return {"ok": false, "code": "KNOCKOUT_BATTLE_NOT_SETTLED"} var operation_id := String(battle.get("operation_id", "")) if operation_id.is_empty(): return {"ok": false, "code": "KNOCKOUT_OPERATION_REQUIRED"} var members := _members_by_id(raw_recruits) if members.is_empty(): return {"ok": false, "code": "KNOCKOUT_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": "KNOCKOUT_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_hash := String(Dictionary(persisted_result).get("result_hash", "")) var supplied_hash := String(copied_result.get("result_hash", "")) if not persisted_hash.is_empty() and not supplied_hash.is_empty() and persisted_hash != supplied_hash: return {"ok": false, "code": "KNOCKOUT_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": "KNOCKOUT_RESULT_OPERATION_MISMATCH"} var receipt_state := _settlement_receipt(contract, battle, operation_id) if not bool(receipt_state.get("ok", false)): return receipt_state var raw_knockouts = copied_result.get("knocked_out_ids", battle.get("knocked_out_ids", [])) if not raw_knockouts is Array: return {"ok": false, "code": "KNOCKOUT_IDS_INVALID"} var ids: Array = [] var id_set := {} var identities: Array = [] var front_to_rear: Array = _copy_array(battle.get("front_to_rear_ids", [])) for raw_id in raw_knockouts: var recruit_id := String(raw_id) if recruit_id.is_empty() or not members.has(recruit_id): return {"ok": false, "code": "KNOCKOUT_RECRUIT_UNKNOWN"} if id_set.has(recruit_id): return {"ok": false, "code": "KNOCKOUT_RECRUIT_DUPLICATE"} id_set[recruit_id] = true ids.append(recruit_id) var recruit: Dictionary = Dictionary(members[recruit_id]).duplicate(true) identities.append({ "recruit_id": recruit_id, "position": front_to_rear.find(recruit_id), "recruit": recruit, "battle_local": true, "deleted": false, "returns_next_stage": true, }) var battle_knockouts = battle.get("knocked_out_ids", []) if battle_knockouts is Array and not battle_knockouts.is_empty() and ids != battle_knockouts: return {"ok": false, "code": "KNOCKOUT_IDENTITY_MISMATCH"} var reward_state := _reward_state(battle.get("reward_draft", {})) if not bool(reward_state.get("ok", false)): return reward_state var reward_pending := bool(reward_state.pending) var acknowledgement_target := "battle_reward_choice" if reward_pending else "battle_recovery" var copied_battle: Dictionary = battle.duplicate(true) var copied_events: Array = _copy_array(battle.get("phase_events", [])) 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", front_to_rear.size())), "contract_hash": String(contract.get("contract_hash", "")), } var copied_reward: Dictionary = Dictionary(reward_state.draft).duplicate(true) var receipt: Dictionary = Dictionary(receipt_state.receipt).duplicate(true) return { "ok": true, "page_id": "knockout_review", "locale": String(locale), "company": { "company_id": String(company.get("company_id", "")), "owned_count": members.size(), "line_capacity": int(contract.get("line_capacity", front_to_rear.size())), "recruits": _copy_array(raw_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", copied_events.size())), "total_phases": int(battle.get("total_phases", copied_events.size())), "front_to_rear_ids": front_to_rear, "enemy_ids": _copy_array(battle.get("enemy_ids", [])), "phase_events": copied_events, "knocked_out_ids": ids.duplicate(true), "copy": copied_battle, }, "result": copied_result, "knockouts": { "ids": ids.duplicate(true), "count": ids.size(), "identities": identities, "battle_local": true, "deleted": false, "returns_next_stage": true, }, "gap": { "state": "battle_local_absence" if not ids.is_empty() else "no_knockout", "battle_local": true, "company_identity_preserved": true, "return_rule": "returns_next_stage_same_identity", }, "reward": { "draft": copied_reward, "present": bool(reward_state.present), "status": String(reward_state.status), "pending": reward_pending, "options": _copy_array(copied_reward.get("options", [])), "options_count": Array(copied_reward.get("options", [])).size(), }, "receipt": { "settlement": receipt, "operation_id": operation_id, "receipt_hash": String(receipt.get("receipt_hash", "")), "duplicate_safe": bool(receipt_state.duplicate_safe), }, "acknowledgement": { "eligible": true, "target": acknowledgement_target, "reward_pending": reward_pending, "reason": "reward_pending" if reward_pending else "recovery_ready", "notice": String(notice), }, "commands": { "back_enabled": true, "acknowledge_enabled": true, "settings_enabled": true, "reward_review_enabled": reward_pending, "recovery_enabled": not reward_pending, }, } 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() and 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": "KNOCKOUT_SETTLEMENT_RECEIPT_REQUIRED"} if matches.size() > 1: return {"ok": false, "code": "KNOCKOUT_SETTLEMENT_RECEIPT_DUPLICATE"} var receipt: Dictionary = matches[0] var battle_id := String(receipt.get("battle_operation_id", operation_id)) if not battle_id.is_empty() and battle_id != operation_id: return {"ok": false, "code": "KNOCKOUT_RECEIPT_OPERATION_MISMATCH"} return {"ok": true, "receipt": receipt, "duplicate_safe": true} func _reward_state(raw_draft) -> Dictionary: if raw_draft == null: raw_draft = {} if not raw_draft is Dictionary: return {"ok": false, "code": "KNOCKOUT_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 options = draft.get("options", []) if not options is Array: return {"ok": false, "code": "KNOCKOUT_REWARD_OPTIONS_INVALID"} var option_ids := {} for option in options: if not option is Dictionary: return {"ok": false, "code": "KNOCKOUT_REWARD_OPTION_INVALID"} var reward_id := String(option.get("reward_id", "")) if reward_id.is_empty(): return {"ok": false, "code": "KNOCKOUT_REWARD_ID_INVALID"} if option_ids.has(reward_id): return {"ok": false, "code": "KNOCKOUT_REWARD_ID_DUPLICATE"} option_ids[reward_id] = true var status := String(draft.get("status", "open")) return {"ok": true, "present": true, "pending": status == "open", "status": status, "draft": draft} 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