344 lines
14 KiB
GDScript
344 lines
14 KiB
GDScript
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
|