346 lines
14 KiB
GDScript
346 lines
14 KiB
GDScript
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",
|
|
]
|
|
|
|
const PRESENTATION_TRACE_KINDS := [
|
|
"signature", "status", "status_resisted", "status_expire", "movement",
|
|
"cast_start", "cast_resolve", "cast_cancelled", "cast_delayed",
|
|
]
|
|
const PRESENTATION_TRACE_PRIORITY := {
|
|
"signature": 0,
|
|
"status": 1,
|
|
"status_resisted": 2,
|
|
"status_expire": 3,
|
|
"movement": 4,
|
|
"cast_start": 5,
|
|
"cast_resolve": 6,
|
|
"cast_cancelled": 6,
|
|
"cast_delayed": 6,
|
|
}
|
|
|
|
|
|
func presentation_feedback(event: Dictionary, journal: Array, locale := "zh_CN") -> Dictionary:
|
|
## Project one same-tick bookkeeping fact into a compact BAT-002 text slot.
|
|
## The journal is a read-only replay result; this method never mutates the
|
|
## BattleService event or observation records.
|
|
if event.is_empty() or journal.is_empty():
|
|
return {}
|
|
var event_tick := int(event.get("tick", event.get("event_tick", -1)))
|
|
if event_tick < 0:
|
|
return {}
|
|
var event_actor := String(event.get("actor_id", event.get("source_actor_id", "")))
|
|
var event_target := String(event.get("target_id", event.get("reserved_target_id", "")))
|
|
var candidates: Array = []
|
|
for index in range(journal.size()):
|
|
var row: Dictionary = Dictionary(journal[index])
|
|
var kind := String(row.get("kind", ""))
|
|
if kind not in PRESENTATION_TRACE_KINDS or int(row.get("tick", -1)) != event_tick:
|
|
continue
|
|
var row_actor := String(row.get("actor", ""))
|
|
var row_target := String(row.get("target", ""))
|
|
if not event_actor.is_empty() and not event_target.is_empty():
|
|
if not _actor_matches(event_actor, row_actor) and not _actor_matches(event_target, row_target):
|
|
continue
|
|
elif not event_actor.is_empty() and not _actor_matches(event_actor, row_actor):
|
|
continue
|
|
candidates.append({
|
|
"row": row,
|
|
"priority": int(PRESENTATION_TRACE_PRIORITY.get(kind, 99)),
|
|
"order": index,
|
|
})
|
|
if candidates.is_empty():
|
|
return {}
|
|
candidates.sort_custom(func(left, right):
|
|
if int(left.priority) == int(right.priority):
|
|
return int(left.order) < int(right.order)
|
|
return int(left.priority) < int(right.priority)
|
|
)
|
|
var selected: Dictionary = Dictionary(candidates[0].row)
|
|
var trace_kind := _presentation_trace_kind(String(selected.get("kind", "")))
|
|
var copy := _presentation_trace_copy(selected, trace_kind)
|
|
return {
|
|
"kind": trace_kind,
|
|
"source_kind": String(selected.get("kind", "")),
|
|
"tick": event_tick,
|
|
"count": candidates.size(),
|
|
"text": String(copy.get("en", "")) if String(locale) == "en" else String(copy.get("zh", "")),
|
|
"zh": String(copy.get("zh", "")),
|
|
"en": String(copy.get("en", "")),
|
|
}
|
|
|
|
|
|
func _presentation_trace_kind(source_kind: String) -> String:
|
|
if source_kind == "signature":
|
|
return "signature"
|
|
if source_kind in ["status", "status_resisted", "status_expire"]:
|
|
return "status"
|
|
if source_kind == "movement":
|
|
return "movement"
|
|
return "cast"
|
|
|
|
|
|
func _presentation_trace_copy(row: Dictionary, trace_kind: String) -> Dictionary:
|
|
match trace_kind:
|
|
"signature":
|
|
var signature := String(row.get("signature", ""))
|
|
var phase := String(row.get("phase", ""))
|
|
return {"zh": "特征 %s · %s" % [signature, phase], "en": "SIGNATURE %s · %s" % [signature, phase]}
|
|
"status":
|
|
var status_id := String(row.get("status_id", ""))
|
|
var sign := String(row.get("sign", ""))
|
|
var ticks := int(row.get("ticks", 0))
|
|
var source_kind := String(row.get("kind", "status"))
|
|
if source_kind == "status_resisted":
|
|
return {"zh": "状态抵抗 · %s" % status_id, "en": "STATUS RESISTED · %s" % status_id}
|
|
if source_kind == "status_expire":
|
|
return {"zh": "状态结束 · %s" % status_id, "en": "STATUS EXPIRED · %s" % status_id}
|
|
return {"zh": "状态 %s · %s · %d tick" % [status_id, sign, ticks], "en": "STATUS %s · %s · %d ticks" % [status_id, sign, ticks]}
|
|
"movement":
|
|
var from_position := int(row.get("from", 0))
|
|
var to_position := int(row.get("to", from_position))
|
|
var steps := int(row.get("steps", absi(to_position - from_position)))
|
|
return {"zh": "移动 %d → %d · %d 格" % [from_position, to_position, steps], "en": "MOVE %d -> %d · %d step%s" % [from_position, to_position, steps, "" if steps == 1 else "s"]}
|
|
_:
|
|
var action := String(row.get("action", ""))
|
|
var source_kind := String(row.get("kind", ""))
|
|
var zh_state: String = {
|
|
"cast_start": "开始",
|
|
"cast_resolve": "完成",
|
|
"cast_cancelled": "取消",
|
|
"cast_delayed": "延迟",
|
|
}.get(source_kind, "变化")
|
|
var en_state: String = {
|
|
"cast_start": "START",
|
|
"cast_resolve": "RESOLVED",
|
|
"cast_cancelled": "CANCELLED",
|
|
"cast_delayed": "DELAYED",
|
|
}.get(source_kind, "CHANGED")
|
|
var reason := String(row.get("reason", ""))
|
|
var zh := "施法 %s · %s" % [action, zh_state]
|
|
var en := "CAST %s · %s" % [action, en_state]
|
|
if not reason.is_empty():
|
|
zh += " · " + reason
|
|
en += " · " + reason
|
|
return {"zh": zh, "en": en}
|
|
|
|
|
|
func _actor_matches(left: String, right: String) -> bool:
|
|
if left.is_empty() or right.is_empty():
|
|
return false
|
|
if left == right:
|
|
return true
|
|
return _strip_runtime_prefix(left) == _strip_runtime_prefix(right)
|
|
|
|
|
|
func _strip_runtime_prefix(value: String) -> String:
|
|
if value.begins_with("ally-") or value.begins_with("enemy-"):
|
|
return value.substr(value.find("-") + 1)
|
|
return value
|
|
|
|
|
|
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",
|
|
presentation_journal: Array = []
|
|
) -> 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 feedback := presentation_feedback(copied_event, presentation_journal, String(locale))
|
|
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,
|
|
"feedback": feedback,
|
|
"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
|