Files
aetherbound-guild/runtime/entry/battle_service.gd
T

1181 lines
49 KiB
GDScript

class_name ABGBattleService
extends RefCounted
const DraftService = preload("res://entry/initial_company_draft_service.gd")
const DEFAULT_COMPANY_PATH := "user://aetherbound_expedition_company.json"
const TOTAL_PHASES := 9
const OBSERVATION_VERSION := 1
const REWARD_DRAFT_VERSION := 1
var company_path := DEFAULT_COMPANY_PATH
func load_company() -> Dictionary:
return _store().load_company()
func begin_battle(operation_id: String, risk_option_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
if not _is_hex(risk_option_id, 32):
return {"ok": false, "code": "BATTLE_RISK_ID_INVALID"}
var loaded: Dictionary = load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.active_contract
var formation: Dictionary = contract.get("formation", {})
if formation.is_empty() or String(formation.get("status", "")) != "ready":
return {"ok": false, "code": "BATTLE_READINESS_REQUIRED"}
var option := _risk_option_by_id(formation, risk_option_id)
if option.is_empty():
return {"ok": false, "code": "BATTLE_RISK_UNAVAILABLE"}
if contract.has("battle_state") and not Dictionary(contract.battle_state).is_empty():
var existing: Dictionary = contract.battle_state
if String(existing.get("operation_id", "")) == operation_id and String(existing.get("risk_option_id", "")) == risk_option_id:
return {"ok": true, "code": "battle_already_started", "company": company, "battle": existing}
return {"ok": false, "code": "BATTLE_ALREADY_ACTIVE"}
var party_ids: Array[String] = []
party_ids.assign(formation.accepted_party_snapshot.front_to_rear_ids)
var snapshot_hash := String(formation.accepted_party_snapshot.snapshot_hash)
var intent := {
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"risk_option_id": risk_option_id,
"snapshot_hash": snapshot_hash,
}
var intent_hash := _hash(intent)
for prior in contract.get("battle_receipts", []):
if String(prior.get("operation_id", "")) == operation_id:
if String(prior.get("intent_hash", "")) != intent_hash:
return {"ok": false, "code": "BATTLE_OPERATION_CONFLICT"}
return {"ok": true, "code": "battle_already_started", "company": company, "battle": contract.battle_state}
var battle := {
"battle_version": 1,
"status": "active",
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"risk_option_id": risk_option_id,
"risk": int(option.risk),
"snapshot_id": String(formation.accepted_party_snapshot.snapshot_id),
"snapshot_hash": snapshot_hash,
"front_to_rear_ids": party_ids,
"enemy_ids": Array(option.enemy_ids).duplicate(),
"phase_index": 0,
"total_phases": TOTAL_PHASES,
"phase_events": [],
"knocked_out_ids": [],
"result": {},
}
_refresh_hash(battle, "battle_hash")
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"risk_option_id": risk_option_id,
"snapshot_id": String(battle.snapshot_id),
"snapshot_hash": snapshot_hash,
"event_count": 0,
}
_refresh_hash(receipt, "receipt_hash")
contract.status = "battle"
contract.battle_state = battle
contract.battle_receipts = [receipt]
contract.battle_settlement_receipts = []
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "battle_started", "company": saved.company, "battle": saved.company.active_contract.battle_state, "receipt": receipt}
func advance_battle(operation_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var loaded: Dictionary = load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.active_contract
var battle: Dictionary = contract.get("battle_state", {})
if battle.is_empty() or String(battle.get("operation_id", "")) != operation_id:
return {"ok": false, "code": "BATTLE_NOT_FOUND"}
if String(battle.status) == "outcome_locked":
return {"ok": false, "code": "BATTLE_OUTCOME_HANDOFF_REQUIRED"}
if String(battle.status) == "result" or String(battle.status) == "recovered":
return {"ok": true, "code": "battle_already_settled", "company": company, "battle": battle}
var phase: int = int(battle.phase_index)
if phase >= TOTAL_PHASES:
return _settle_battle(company, battle)
var event := _event_for_phase(company, battle, phase)
battle.phase_events.append(event)
battle.phase_index = phase + 1
if String(event.kind) == "knockout" and not String(event.target_id).is_empty():
battle.knocked_out_ids = [String(event.target_id)]
if String(event.kind) == "result":
return _settle_battle(company, battle)
_refresh_hash(battle, "battle_hash")
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "battle_advanced", "company": saved.company, "battle": saved.company.active_contract.battle_state, "event": event}
func begin_observation(operation_id: String, expected_snapshot_hash := "") -> Dictionary:
var context := _load_observation_context(operation_id, false)
if not context.ok:
return context
var company: Dictionary = context.company
var contract: Dictionary = context.contract
var battle: Dictionary = context.battle
var receipt: Dictionary = context.receipt
if not expected_snapshot_hash.is_empty():
if not _is_hex(expected_snapshot_hash, 64) or expected_snapshot_hash != String(battle.snapshot_hash):
return {"ok": false, "code": "OBSERVATION_SNAPSHOT_MISMATCH"}
if battle.has("observation") and not Dictionary(battle.observation).is_empty():
return {
"ok": true,
"code": "observation_outcome_locked" if String(battle.observation.status) == "outcome_locked" else "observation_already_started",
"company": company,
"battle": battle,
"observation": Dictionary(battle.observation).duplicate(true),
"receipt": receipt,
}
if String(battle.status) != "active" or int(battle.phase_index) != 0 or not Array(battle.phase_events).is_empty():
return {"ok": false, "code": "OBSERVATION_OPENING_REQUIRED"}
var observation := {
"observation_version": OBSERVATION_VERSION,
"status": "waiting_for_next_event",
"operation_id": operation_id,
"snapshot_id": String(battle.snapshot_id),
"snapshot_hash": String(battle.snapshot_hash),
"receipt_hash": String(receipt.receipt_hash),
"next_phase_index": 0,
"last_event_id": "",
"last_event_hash": "",
"event_journal_hash": _hash([]),
"outcome_lock": {},
}
_refresh_hash(observation, "observation_hash")
battle.observation = observation
var saved := _save_battle_state(company, contract, battle)
if not saved.ok:
return saved
var saved_battle: Dictionary = saved.company.active_contract.battle_state
return {
"ok": true,
"code": "observation_started",
"company": saved.company,
"battle": saved_battle,
"observation": Dictionary(saved_battle.observation).duplicate(true),
"receipt": receipt,
}
func preview_observation_event(operation_id: String) -> Dictionary:
var context := _load_observation_context(operation_id, true)
if not context.ok:
return context
var battle: Dictionary = context.battle
var observation: Dictionary = context.observation
if String(observation.status) == "outcome_locked":
return {
"ok": true,
"code": "observation_outcome_locked",
"company": context.company,
"battle": battle,
"observation": observation,
"outcome_lock": Dictionary(observation.outcome_lock).duplicate(true),
}
var phase := int(observation.next_phase_index)
if phase < 0 or phase >= TOTAL_PHASES:
return {"ok": false, "code": "OBSERVATION_EVENT_ORDER_INVALID"}
var event := _observation_event_for_phase(context.company, battle, phase)
return {
"ok": true,
"code": "observation_event_ready",
"company": context.company,
"battle": battle,
"observation": observation,
"event": event,
}
func commit_observation_event(operation_id: String, event_id: String, event_hash: String) -> Dictionary:
if not _is_hex(event_id, 32) or not _is_hex(event_hash, 64):
return {"ok": false, "code": "OBSERVATION_EVENT_ID_INVALID"}
var context := _load_observation_context(operation_id, true)
if not context.ok:
return context
var company: Dictionary = context.company
var contract: Dictionary = context.contract
var battle: Dictionary = context.battle
var observation: Dictionary = context.observation
for committed_event in battle.phase_events:
if String(committed_event.get("event_id", "")) != event_id:
continue
if String(committed_event.get("event_hash", "")) != event_hash:
return {"ok": false, "code": "OBSERVATION_EVENT_HASH_MISMATCH"}
return {
"ok": true,
"code": "observation_event_already_committed",
"company": company,
"battle": battle,
"observation": observation,
"event": Dictionary(committed_event).duplicate(true),
}
if String(observation.status) == "outcome_locked":
return {"ok": false, "code": "OBSERVATION_OUTCOME_LOCKED"}
var phase := int(observation.next_phase_index)
if phase < 0 or phase >= TOTAL_PHASES:
return {"ok": false, "code": "OBSERVATION_EVENT_ORDER_INVALID"}
var expected_event := _observation_event_for_phase(company, battle, phase)
if String(expected_event.event_id) != event_id:
return {"ok": false, "code": "OBSERVATION_EVENT_ORDER_MISMATCH"}
if String(expected_event.event_hash) != event_hash:
return {"ok": false, "code": "OBSERVATION_EVENT_HASH_MISMATCH"}
battle.phase_events.append(expected_event.duplicate(true))
battle.phase_index = phase + 1
if String(expected_event.kind) == "knockout" and not String(expected_event.target_id).is_empty():
battle.knocked_out_ids = [String(expected_event.target_id)]
observation.next_phase_index = int(battle.phase_index)
observation.last_event_id = event_id
observation.last_event_hash = event_hash
observation.event_journal_hash = _hash(battle.phase_events)
var code := "observation_event_committed"
if String(expected_event.kind) == "result":
var decisive := _observation_decisive_event(battle.phase_events)
var outcome_lock := {
"outcome_id": ("%s|outcome|%s" % [operation_id, event_hash]).sha256_text().substr(0, 32),
"operation_id": operation_id,
"snapshot_hash": String(battle.snapshot_hash),
"outcome": String(expected_event.outcome),
"outcome_cause": "enemy_line_broken",
"outcome_tick": int(expected_event.event_tick),
"final_snapshot_hash": _observation_final_snapshot_hash(battle, String(observation.event_journal_hash)),
"event_journal_hash": String(observation.event_journal_hash),
"decisive_event_id": String(decisive.get("event_id", "")),
"decisive_event_hash": String(decisive.get("event_hash", "")),
"out001_is_next": true,
}
_refresh_hash(outcome_lock, "outcome_hash")
observation.status = "outcome_locked"
observation.outcome_lock = outcome_lock
battle.status = "outcome_locked"
code = "observation_outcome_locked"
else:
observation.status = "waiting_for_next_event"
_refresh_hash(observation, "observation_hash")
battle.observation = observation
var saved := _save_battle_state(company, contract, battle)
if not saved.ok:
return saved
var saved_battle: Dictionary = saved.company.active_contract.battle_state
return {
"ok": true,
"code": code,
"company": saved.company,
"battle": saved_battle,
"observation": Dictionary(saved_battle.observation).duplicate(true),
"event": expected_event,
"outcome_lock": Dictionary(saved_battle.observation.outcome_lock).duplicate(true),
}
func retry_observation_snapshot(operation_id: String, expected_snapshot_hash: String, expected_journal_hash := "") -> Dictionary:
if not _is_hex(expected_snapshot_hash, 64):
return {"ok": false, "code": "OBSERVATION_SNAPSHOT_ID_INVALID"}
if not expected_journal_hash.is_empty() and not _is_hex(expected_journal_hash, 64):
return {"ok": false, "code": "OBSERVATION_JOURNAL_ID_INVALID"}
var context := _load_observation_context(operation_id, true)
if not context.ok:
return context
var observation: Dictionary = context.observation
if expected_snapshot_hash != String(observation.snapshot_hash):
return {"ok": false, "code": "OBSERVATION_SNAPSHOT_MISMATCH"}
if not expected_journal_hash.is_empty() and expected_journal_hash != String(observation.event_journal_hash):
return {"ok": false, "code": "OBSERVATION_JOURNAL_MISMATCH"}
return {
"ok": true,
"code": "observation_snapshot_valid",
"company": context.company,
"battle": context.battle,
"observation": observation,
"receipt": context.receipt,
}
func settle_observation_outcome(operation_id: String) -> Dictionary:
# BAT-002 intentionally stops before settlement. This separate operation is
# the only observation-path boundary that may apply battle Coin/XP, write
# Recruit history and open the deterministic reward draft. The observation
# journal and its outcome lock are never rewritten.
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var loaded := load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.get("active_contract", {})
var battle: Dictionary = contract.get("battle_state", {})
if battle.is_empty() or String(battle.get("operation_id", "")) != operation_id:
return {"ok": false, "code": "BATTLE_NOT_FOUND"}
if String(battle.get("status", "")) in ["result", "recovered"]:
return {
"ok": true,
"code": "outcome_already_settled",
"company": company,
"battle": battle,
"result": Dictionary(battle.get("result", {})).duplicate(true),
"reward_draft": Dictionary(battle.get("reward_draft", {})).duplicate(true),
}
if String(battle.get("status", "")) != "outcome_locked":
return {"ok": false, "code": "BATTLE_OUTCOME_REQUIRED"}
var observation: Dictionary = battle.get("observation", {})
var receipts: Array = contract.get("battle_receipts", [])
if observation.is_empty() or receipts.size() != 1:
return {"ok": false, "code": "OBSERVATION_NOT_READY_FOR_RESULT"}
var receipt: Dictionary = Dictionary(receipts[0])
var validation := _validate_observation_state(battle, receipt, observation)
if not validation.ok:
return {"ok": false, "code": "OBSERVATION_RESULT_INVALID", "detail": validation}
var outcome_lock: Dictionary = Dictionary(observation.get("outcome_lock", {})).duplicate(true)
if outcome_lock.is_empty() or not bool(outcome_lock.get("out001_is_next", false)):
return {"ok": false, "code": "OBSERVATION_OUTCOME_LOCK_MISSING"}
var option := _risk_option_by_id(contract.get("formation", {}), String(battle.get("risk_option_id", "")))
if option.is_empty():
return {"ok": false, "code": "BATTLE_RISK_UNAVAILABLE"}
var knocked: Array[String] = []
knocked.assign(battle.get("knocked_out_ids", []))
var reward := int(option.get("reward_coin", 0))
var xp_per_survivor := 12 + int(option.get("risk", 0)) * 4
var coin_before := int(contract.get("coin", 0))
var coin_after := coin_before + reward
var xp_by_recruit := {}
var total_xp := 0
for recruit in company.get("owned_recruits", []):
var recruit_id := String(recruit.get("recruit_id", ""))
var history: Dictionary = Dictionary(recruit.get("battle_history", {})).duplicate(true)
history.battles_entered = int(history.get("battles_entered", 0)) + 1
history.victories = int(history.get("victories", 0)) + 1
if recruit_id in knocked:
history.knockouts = int(history.get("knockouts", 0)) + 1
xp_by_recruit[recruit_id] = {
"award": 0,
"level_before": int(recruit.get("level", 1)),
"level_after": int(recruit.get("level", 1)),
"xp_before": int(recruit.get("xp", 0)),
"xp_after": int(recruit.get("xp", 0)),
}
else:
var level_before := int(recruit.get("level", 1))
var xp_before := int(recruit.get("xp", 0))
_apply_xp(recruit, xp_per_survivor)
total_xp += xp_per_survivor
xp_by_recruit[recruit_id] = {
"award": xp_per_survivor,
"level_before": level_before,
"level_after": int(recruit.get("level", level_before)),
"xp_before": xp_before,
"xp_after": int(recruit.get("xp", xp_before)),
}
recruit.battle_history = history
recruit.recruit_hash = _hash_without(recruit, "recruit_hash")
var reward_draft := _build_reward_draft(contract, battle, option)
var decisive := _observation_decisive_event(Array(battle.get("phase_events", [])))
var result := {
"settlement_version": 1,
"outcome": String(outcome_lock.get("outcome", "victory")),
"outcome_cause": String(outcome_lock.get("outcome_cause", "enemy_line_broken")),
"reward_coin": reward,
"xp_award": total_xp,
"xp_per_recruit": xp_per_survivor,
"coin_before": coin_before,
"coin_after": coin_after,
"knocked_out_ids": knocked,
"decisive_event_hash": String(decisive.get("event_hash", "")),
"event_journal_hash": String(observation.get("event_journal_hash", "")),
"outcome_hash": String(outcome_lock.get("outcome_hash", "")),
"reward_draft_id": String(reward_draft.get("draft_id", "")),
"reward_options_hash": String(reward_draft.get("options_hash", "")),
}
result.xp_by_recruit = xp_by_recruit
_refresh_hash(result, "result_hash")
var settlement := {
"operation_id": operation_id,
"company_id": String(company.company_id),
"run_id": String(contract.get("run_id", "")),
"battle_operation_id": operation_id,
"pre_settlement_battle_hash": String(battle.get("battle_hash", "")),
"snapshot_hash": String(battle.get("snapshot_hash", "")),
"event_journal_hash": String(observation.get("event_journal_hash", "")),
"outcome_hash": String(outcome_lock.get("outcome_hash", "")),
"outcome": String(result.outcome),
"reward_coin": reward,
"xp_award": total_xp,
"xp_per_recruit": xp_per_survivor,
"coin_before": coin_before,
"coin_after": coin_after,
"knocked_out_ids": knocked,
"reward_draft_id": String(reward_draft.get("draft_id", "")),
}
_refresh_hash(settlement, "receipt_hash")
battle.result = result
battle.reward_draft = reward_draft
battle.settlement_receipt = settlement
battle.status = "result"
contract.coin = coin_after
contract.battle_settlement_receipts = [settlement]
contract.reward_receipts = []
contract.status = "result"
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {
"ok": true,
"code": "outcome_settled",
"company": saved.company,
"battle": saved.company.active_contract.battle_state,
"result": saved.company.active_contract.battle_state.result,
"reward_draft": saved.company.active_contract.battle_state.reward_draft,
"receipt": settlement,
}
func claim_reward(operation_id: String, reward_id: String, battle_operation_id := "") -> Dictionary:
if not _is_hex(operation_id, 32) or not _is_hex(reward_id, 32):
return {"ok": false, "code": "REWARD_ID_INVALID"}
var battle_id := operation_id if String(battle_operation_id).is_empty() else String(battle_operation_id)
if not _is_hex(battle_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var context := _load_settled_battle_context(battle_id)
if not context.ok:
return context
var company: Dictionary = context.company
var contract: Dictionary = context.contract
var battle: Dictionary = context.battle
var draft: Dictionary = Dictionary(battle.get("reward_draft", {})).duplicate(true)
if draft.is_empty():
return {"ok": false, "code": "REWARD_DRAFT_MISSING"}
var reward_receipts: Array = contract.get("reward_receipts", [])
for prior_value in reward_receipts:
var prior: Dictionary = prior_value
if String(prior.get("operation_id", "")) == operation_id:
if String(prior.get("reward_id", "")) != reward_id:
return {"ok": false, "code": "REWARD_OPERATION_CONFLICT"}
return {"ok": true, "code": "reward_already_applied", "company": company, "battle": battle, "receipt": prior}
var intent_hash := _hash({
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"battle_operation_id": battle_id,
"draft_hash": String(draft.get("draft_hash", "")),
"reward_id": reward_id,
})
if String(draft.get("status", "")) != "open":
return {"ok": false, "code": "REWARD_ALREADY_APPLIED"}
var selected := _reward_option_by_id(draft, reward_id)
if selected.is_empty():
return {"ok": false, "code": "REWARD_OPTION_UNAVAILABLE"}
var kind := String(selected.get("kind", ""))
var coin_before := int(contract.get("coin", 0))
var coin_after := coin_before
var inventory: Array = company.get("equipment_inventory", []).duplicate(true)
var item_record := {}
if kind == "coin":
coin_after += int(selected.get("amount", 0))
elif kind == "equipment":
var catalog_id := String(selected.get("catalog_id", ""))
if catalog_id.is_empty():
return {"ok": false, "code": "REWARD_TARGET_INVALID"}
item_record = {
"catalog_id": catalog_id,
"instance_id": ("%s|reward|%s" % [operation_id, reward_id]).sha256_text().substr(0, 32),
"source": "battle_reward",
"reward_id": reward_id,
"acquired_operation_id": operation_id,
}
inventory.append(item_record)
else:
return {"ok": false, "code": "REWARD_KIND_INVALID"}
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"battle_operation_id": String(battle.operation_id),
"draft_id": String(draft.get("draft_id", "")),
"draft_hash": String(draft.get("draft_hash", "")),
"reward_id": reward_id,
"kind": kind,
"amount": int(selected.get("amount", 0)),
"catalog_id": String(selected.get("catalog_id", "")),
"coin_before": coin_before,
"coin_after": coin_after,
}
_refresh_hash(receipt, "receipt_hash")
contract.coin = coin_after
company.equipment_inventory = inventory
draft.status = "claimed"
draft.selected_reward_id = reward_id
draft.receipt_hash = String(receipt.receipt_hash)
_refresh_hash(draft, "draft_hash")
battle.reward_draft = draft
battle.result.reward_receipt_hash = String(receipt.receipt_hash)
_refresh_hash(battle.result, "result_hash")
reward_receipts.append(receipt)
contract.reward_receipts = reward_receipts
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "reward_claimed", "company": saved.company, "battle": saved.company.active_contract.battle_state, "receipt": receipt, "item": item_record}
func decline_reward(operation_id: String, battle_operation_id := "") -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "REWARD_OPERATION_ID_INVALID"}
var battle_id := operation_id if String(battle_operation_id).is_empty() else String(battle_operation_id)
if not _is_hex(battle_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var context := _load_settled_battle_context(battle_id)
if not context.ok:
return context
var company: Dictionary = context.company
var contract: Dictionary = context.contract
var battle: Dictionary = context.battle
var draft: Dictionary = Dictionary(battle.get("reward_draft", {})).duplicate(true)
if draft.is_empty():
return {"ok": false, "code": "REWARD_DRAFT_MISSING"}
var receipts: Array = contract.get("reward_receipts", [])
for prior_value in receipts:
var prior: Dictionary = prior_value
if String(prior.get("operation_id", "")) == operation_id:
if String(prior.get("kind", "")) != "declined":
return {"ok": false, "code": "REWARD_OPERATION_CONFLICT"}
return {"ok": true, "code": "reward_already_applied", "company": company, "battle": battle, "receipt": prior}
var intent_hash := _hash({"company_id": String(company.company_id), "run_id": String(contract.run_id), "battle_operation_id": battle_id, "draft_hash": String(draft.get("draft_hash", "")), "decline": true})
if String(draft.get("status", "")) != "open":
return {"ok": false, "code": "REWARD_ALREADY_APPLIED"}
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"battle_operation_id": String(battle.operation_id),
"draft_id": String(draft.get("draft_id", "")),
"draft_hash": String(draft.get("draft_hash", "")),
"reward_id": "",
"kind": "declined",
"amount": 0,
"coin_before": int(contract.get("coin", 0)),
"coin_after": int(contract.get("coin", 0)),
}
_refresh_hash(receipt, "receipt_hash")
draft.status = "declined"
draft.selected_reward_id = ""
draft.receipt_hash = String(receipt.receipt_hash)
_refresh_hash(draft, "draft_hash")
battle.reward_draft = draft
battle.result.reward_receipt_hash = String(receipt.receipt_hash)
_refresh_hash(battle.result, "result_hash")
receipts.append(receipt)
contract.reward_receipts = receipts
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "reward_declined", "company": saved.company, "battle": saved.company.active_contract.battle_state, "receipt": receipt}
func _load_settled_battle_context(operation_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var loaded := load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.get("active_contract", {})
var battle: Dictionary = contract.get("battle_state", {})
if battle.is_empty() or String(battle.get("operation_id", "")) != operation_id:
return {"ok": false, "code": "BATTLE_NOT_FOUND"}
if String(battle.get("status", "")) not in ["result", "recovered"]:
return {"ok": false, "code": "REWARD_RESULT_REQUIRED"}
return {"ok": true, "code": "settled_battle_context_valid", "company": company, "contract": contract, "battle": battle}
func _build_reward_draft(contract: Dictionary, battle: Dictionary, option: Dictionary) -> Dictionary:
var risk := int(option.get("risk", 0))
var budget := 12 + 2 * int(contract.get("market_index", 1)) + 6 * risk
var desired_count := 2 if risk == 0 else 3
var equipment_count := 1 if risk == 0 else 2
var options: Array = []
var available_ids: Array = contract.get("available_equipment_offer_ids", [])
var equipment_offers: Array = contract.get("market_board", {}).get("equipment_offers", [])
for raw_offer in equipment_offers:
if options.size() >= equipment_count:
break
var offer: Dictionary = raw_offer
var offer_id := String(offer.get("offer_id", ""))
if offer_id.is_empty() or offer_id not in available_ids:
continue
var catalog_id := String(offer.get("catalog_id", ""))
var reward_id := ("%s|reward|equipment|%s" % [String(battle.get("operation_id", "")), catalog_id]).sha256_text().substr(0, 32)
options.append({
"reward_id": reward_id,
"kind": "equipment",
"catalog_id": catalog_id,
"source_offer_id": offer_id,
"amount": 0,
"eligible_target": "Company inventory",
"next_decision": "Adds one free equipment object to the next Market preparation.",
"conflict": "Does not equip a Recruit automatically; choose the target later.",
"skip_outcome": "Keep the battle Coin already paid; no object is added.",
})
var coin_reward_id := ("%s|reward|coin|%d" % [String(battle.get("operation_id", "")), budget]).sha256_text().substr(0, 32)
options.append({
"reward_id": coin_reward_id,
"kind": "coin",
"catalog_id": "",
"source_offer_id": "",
"amount": maxi(1, int(floor(float(budget) * 0.5))),
"eligible_target": "Current Contract Coin",
"next_decision": "Adds spendable Coin before the next-stage Market.",
"conflict": "No equipment object is added by this card.",
"skip_outcome": "Keep only the battle Coin already paid.",
})
while options.size() < desired_count:
var fallback_amount := maxi(1, int(floor(float(budget) * 0.25)) + options.size())
var fallback_id := ("%s|reward|coin-fallback|%d" % [String(battle.get("operation_id", "")), options.size()]).sha256_text().substr(0, 32)
options.append({
"reward_id": fallback_id,
"kind": "coin",
"catalog_id": "",
"source_offer_id": "",
"amount": fallback_amount,
"eligible_target": "Current Contract Coin",
"next_decision": "Adds a smaller flexible Coin reserve.",
"conflict": "Trades away a free equipment object.",
"skip_outcome": "Keep only the battle Coin already paid.",
})
var draft := {
"draft_version": REWARD_DRAFT_VERSION,
"draft_id": ("%s|reward-draft|%s" % [String(battle.get("operation_id", "")), String(battle.get("snapshot_hash", ""))]).sha256_text().substr(0, 32),
"operation_id": String(battle.get("operation_id", "")),
"risk": risk,
"tier_id": String(option.get("tier_id", "steady")),
"budget": budget,
"status": "open",
"selected_reward_id": "",
"receipt_hash": "",
"options": options,
}
draft.options_hash = _hash(options)
_refresh_hash(draft, "draft_hash")
return draft
func _reward_option_by_id(draft: Dictionary, reward_id: String) -> Dictionary:
for raw_option in draft.get("options", []):
var option: Dictionary = raw_option
if String(option.get("reward_id", "")) == reward_id:
return option.duplicate(true)
return {}
func recover_next_stage(operation_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "RECOVERY_OPERATION_ID_INVALID"}
var loaded: Dictionary = load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.active_contract
var battle: Dictionary = contract.get("battle_state", {})
for prior in contract.get("recovery_receipts", []):
if String(prior.get("operation_id", "")) == operation_id:
return {"ok": true, "code": "recovery_already_applied", "company": company, "receipt": prior}
if battle.is_empty() or String(battle.status) != "result":
return {"ok": false, "code": "RECOVERY_RESULT_REQUIRED"}
var reward_draft: Dictionary = battle.get("reward_draft", {})
if not reward_draft.is_empty() and String(reward_draft.get("status", "open")) == "open":
return {"ok": false, "code": "RECOVERY_REWARD_REQUIRED"}
var knocked: Array[String] = []
knocked.assign(battle.get("knocked_out_ids", []))
var receipt := {
"operation_id": operation_id,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"battle_operation_id": String(battle.operation_id),
"battle_hash": String(battle.battle_hash),
"recruited_ids": knocked,
"return_rule": "returns_next_stage_same_identity",
"next_stage_ready": true,
}
_refresh_hash(receipt, "receipt_hash")
var recovery_receipts: Array = contract.get("recovery_receipts", []).duplicate(true)
recovery_receipts.append(receipt)
contract.recovery_receipts = recovery_receipts
contract.status = "market"
battle.status = "recovered"
battle.recovery_receipt = receipt
_refresh_hash(battle, "battle_hash")
contract.battle_state = battle
contract.erase("formation")
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "recovered", "company": saved.company, "receipt": receipt, "recruited_ids": knocked}
func _settle_battle(company: Dictionary, battle: Dictionary) -> Dictionary:
var contract: Dictionary = company.active_contract
if String(battle.status) == "result":
return {"ok": true, "code": "battle_already_settled", "company": company, "battle": battle}
var option := _risk_option_by_id(contract.formation, String(battle.risk_option_id))
if option.is_empty():
return {"ok": false, "code": "BATTLE_RISK_UNAVAILABLE"}
var knocked: Array[String] = []
knocked.assign(battle.knocked_out_ids)
var reward := int(option.reward_coin)
var xp := 12 + int(option.risk) * 4
var coin_before := int(contract.coin)
var coin_after := coin_before + reward
if battle.phase_events.is_empty() or String(battle.phase_events.back().get("kind", "")) != "result":
return {"ok": false, "code": "BATTLE_RESULT_EVENT_MISSING"}
var result_event: Dictionary = Dictionary(battle.phase_events.back()).duplicate(true)
result_event.reward_coin = reward
result_event.xp_award = 0
result_event.xp_per_recruit = xp
result_event.knocked_out_ids = knocked
_refresh_hash(result_event, "event_hash")
battle.phase_events[battle.phase_events.size() - 1] = result_event
battle.phase_index = TOTAL_PHASES
battle.status = "result"
var xp_by_recruit := {}
var total_xp := 0
battle.result = {
"outcome": "victory",
"reward_coin": reward,
"xp_award": 0,
"xp_per_recruit": xp,
"coin_before": coin_before,
"coin_after": coin_after,
"knocked_out_ids": knocked,
"decisive_event_hash": String(battle.phase_events[maxi(0, battle.phase_events.size() - 2)].event_hash),
}
_refresh_hash(battle.result, "result_hash")
contract.coin = coin_after
for recruit in company.owned_recruits:
var recruit_id := String(recruit.recruit_id)
var history: Dictionary = recruit.battle_history.duplicate(true)
history.battles_entered = int(history.battles_entered) + 1
history.victories = int(history.victories) + 1
if recruit_id in knocked:
history.knockouts = int(history.knockouts) + 1
xp_by_recruit[recruit_id] = {"award": 0, "level_before": int(recruit.level), "level_after": int(recruit.level), "xp_after": int(recruit.xp)}
else:
var level_before := int(recruit.level)
var xp_before := int(recruit.xp)
_apply_xp(recruit, xp)
total_xp += xp
xp_by_recruit[recruit_id] = {"award": xp, "level_before": level_before, "level_after": int(recruit.level), "xp_before": xp_before, "xp_after": int(recruit.xp)}
recruit.battle_history = history
recruit.recruit_hash = _hash_without(recruit, "recruit_hash")
battle.result.xp_award = total_xp
battle.result.xp_by_recruit = xp_by_recruit
result_event.xp_award = total_xp
_refresh_hash(result_event, "event_hash")
battle.phase_events[battle.phase_events.size() - 1] = result_event
var decisive_event_hash := ""
for event in battle.phase_events:
if String(event.get("kind", "")) == "damage":
decisive_event_hash = String(event.get("event_hash", ""))
break
if decisive_event_hash.is_empty():
decisive_event_hash = String(battle.phase_events.back().get("event_hash", ""))
battle.result.decisive_event_hash = decisive_event_hash
_refresh_hash(battle.result, "result_hash")
var settlement := {
"operation_id": String(battle.operation_id),
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"battle_hash": String(battle.battle_hash),
"outcome": "victory",
"reward_coin": reward,
"xp_award": total_xp,
"xp_per_recruit": xp,
"xp_by_recruit": xp_by_recruit,
"coin_before": coin_before,
"coin_after": coin_after,
"knocked_out_ids": knocked,
}
_refresh_hash(settlement, "receipt_hash")
contract.battle_settlement_receipts = [settlement]
if not contract.battle_receipts.is_empty():
contract.battle_receipts[0].event_count = battle.phase_events.size()
_refresh_hash(contract.battle_receipts[0], "receipt_hash")
contract.status = "result"
battle.settlement_receipt = settlement
_refresh_hash(battle, "battle_hash")
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
var saved: Dictionary = _store().save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "battle_settled", "company": saved.company, "battle": saved.company.active_contract.battle_state, "event": result_event}
func _load_observation_context(operation_id: String, require_observation: bool) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "BATTLE_OPERATION_ID_INVALID"}
var loaded := load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.get("active_contract", {})
var battle: Dictionary = contract.get("battle_state", {})
if battle.is_empty() or String(battle.get("operation_id", "")) != operation_id:
return {"ok": false, "code": "BATTLE_NOT_FOUND"}
if String(battle.get("status", "")) not in ["active", "outcome_locked"]:
return {"ok": false, "code": "OBSERVATION_NOT_ACTIVE"}
if not _is_hex(String(battle.get("battle_hash", "")), 64) or _hash_without(battle, "battle_hash") != String(battle.battle_hash):
return {"ok": false, "code": "OBSERVATION_BATTLE_HASH_INVALID"}
var receipts: Array = contract.get("battle_receipts", [])
if receipts.size() != 1:
return {"ok": false, "code": "OBSERVATION_RECEIPT_COUNT_INVALID"}
var receipt: Dictionary = Dictionary(receipts[0])
if (
String(receipt.get("operation_id", "")) != operation_id
or String(receipt.get("snapshot_id", "")) != String(battle.get("snapshot_id", ""))
or String(receipt.get("snapshot_hash", "")) != String(battle.get("snapshot_hash", ""))
or String(receipt.get("company_id", "")) != String(battle.get("company_id", ""))
or String(receipt.get("run_id", "")) != String(battle.get("run_id", ""))
):
return {"ok": false, "code": "OBSERVATION_RECEIPT_SOURCE_INVALID"}
if not _is_hex(String(receipt.get("receipt_hash", "")), 64) or _hash_without(receipt, "receipt_hash") != String(receipt.receipt_hash):
return {"ok": false, "code": "OBSERVATION_RECEIPT_HASH_INVALID"}
var observation: Dictionary = battle.get("observation", {})
if require_observation and observation.is_empty():
return {"ok": false, "code": "OBSERVATION_NOT_STARTED"}
if not observation.is_empty():
var validation := _validate_observation_state(battle, receipt, observation)
if not validation.ok:
return validation
return {
"ok": true,
"code": "observation_context_valid",
"company": company,
"contract": contract,
"battle": battle,
"receipt": receipt.duplicate(true),
"observation": observation.duplicate(true),
}
func _validate_observation_state(battle: Dictionary, receipt: Dictionary, observation: Dictionary) -> Dictionary:
var required := [
"observation_version", "status", "operation_id", "snapshot_id",
"snapshot_hash", "receipt_hash", "next_phase_index", "last_event_id",
"last_event_hash", "event_journal_hash", "outcome_lock",
"observation_hash",
]
for field in required:
if not observation.has(field):
return {"ok": false, "code": "OBSERVATION_FIELD_MISSING", "field": field}
if (
int(observation.observation_version) != OBSERVATION_VERSION
or String(observation.status) not in ["waiting_for_next_event", "outcome_locked"]
or String(observation.operation_id) != String(battle.operation_id)
or String(observation.snapshot_id) != String(battle.snapshot_id)
or String(observation.snapshot_hash) != String(battle.snapshot_hash)
or String(observation.receipt_hash) != String(receipt.receipt_hash)
):
return {"ok": false, "code": "OBSERVATION_IDENTITY_INVALID"}
var events: Array = battle.get("phase_events", [])
if int(observation.next_phase_index) != int(battle.phase_index) or events.size() != int(battle.phase_index) or events.size() > TOTAL_PHASES:
return {"ok": false, "code": "OBSERVATION_EVENT_ORDER_INVALID"}
var prior_events: Array = []
for index in range(events.size()):
var event: Dictionary = events[index]
if (
int(event.get("phase", -1)) != index
or String(event.get("event_id", "")) != _observation_event_id(battle, index)
or String(event.get("previous_journal_hash", "")) != _hash(prior_events)
or not _is_hex(String(event.get("event_hash", "")), 64)
or _hash_without(event, "event_hash") != String(event.event_hash)
):
return {"ok": false, "code": "OBSERVATION_EVENT_HASH_INVALID"}
prior_events.append(event.duplicate(true))
if String(observation.event_journal_hash) != _hash(events):
return {"ok": false, "code": "OBSERVATION_JOURNAL_HASH_INVALID"}
if events.is_empty():
if not String(observation.last_event_id).is_empty() or not String(observation.last_event_hash).is_empty():
return {"ok": false, "code": "OBSERVATION_LAST_EVENT_INVALID"}
else:
var last: Dictionary = events.back()
if String(observation.last_event_id) != String(last.event_id) or String(observation.last_event_hash) != String(last.event_hash):
return {"ok": false, "code": "OBSERVATION_LAST_EVENT_INVALID"}
if String(observation.status) == "outcome_locked":
var outcome_lock: Dictionary = observation.outcome_lock
if (
String(battle.status) != "outcome_locked"
or events.size() != TOTAL_PHASES
or outcome_lock.is_empty()
or String(outcome_lock.get("event_journal_hash", "")) != String(observation.event_journal_hash)
or not _is_hex(String(outcome_lock.get("outcome_hash", "")), 64)
or _hash_without(outcome_lock, "outcome_hash") != String(outcome_lock.outcome_hash)
):
return {"ok": false, "code": "OBSERVATION_OUTCOME_INVALID"}
elif String(battle.status) != "active" or not Dictionary(observation.outcome_lock).is_empty():
return {"ok": false, "code": "OBSERVATION_STATUS_INVALID"}
if not _is_hex(String(observation.observation_hash), 64) or _hash_without(observation, "observation_hash") != String(observation.observation_hash):
return {"ok": false, "code": "OBSERVATION_HASH_INVALID"}
return {"ok": true, "code": "observation_valid"}
func _save_battle_state(company: Dictionary, contract: Dictionary, battle: Dictionary) -> Dictionary:
_refresh_hash(battle, "battle_hash")
contract.battle_state = battle
_refresh_hash(contract, "contract_hash")
company.active_contract = contract
_refresh_hash(company, "company_hash")
return _store().save_company(company)
func _observation_event_for_phase(company: Dictionary, battle: Dictionary, phase: int) -> Dictionary:
var event := _event_for_phase(company, battle, phase)
event.erase("event_hash")
if String(event.kind) in ["telegraph", "knockout"]:
var allied_ids: Array = battle.get("front_to_rear_ids", [])
var enemy_ids: Array = battle.get("enemy_ids", [])
event.actor_id = String(enemy_ids[0]) if not enemy_ids.is_empty() else "enemy-front"
event.target_id = String(allied_ids[0]) if not allied_ids.is_empty() else ""
var source_id := String(event.get("actor_id", ""))
var target_id := String(event.get("target_id", ""))
var previous_event_id := _observation_event_id(battle, phase - 1) if phase > 0 else ""
var next_event_id := _observation_event_id(battle, phase + 1) if phase + 1 < TOTAL_PHASES else ""
event.event_id = _observation_event_id(battle, phase)
event.event_tick = phase
event.event_priority = phase
event.event_kind = "outcome_lock" if String(event.kind) == "result" else String(event.kind)
event.event_families = _observation_event_families(String(event.kind))
event.action_id = String(event.get("caption_id", ""))
event.source_actor_id = source_id
event.forecast_target_ids = [target_id] if not target_id.is_empty() else []
event.reserved_target_id = target_id
event.actual_recipient_ids = [target_id] if not target_id.is_empty() else []
event.outcome_disposition = String(event.get("outcome", "observed"))
event.state_delta = {
"kind": String(event.kind),
"amount": int(event.get("amount", 0)),
"direction": _observation_delta_direction(String(event.kind)),
}
event.cause_event_ids = [previous_event_id] if not previous_event_id.is_empty() else []
event.previous_major_event_id = previous_event_id
event.next_major_event_id = next_event_id
event.previous_journal_hash = _hash(Array(battle.get("phase_events", [])))
_refresh_hash(event, "event_hash")
return event
func _observation_event_families(kind: String) -> Array[String]:
match kind:
"opening": return ["battle_opening"]
"telegraph": return ["enemy_action_and_target_relation"]
"guard": return ["guard_or_protection_intercept"]
"ranged": return ["ranged_release_and_contact", "damage_source_target_and_value"]
"magic": return ["magic_cast_and_resolution", "damage_source_target_and_value"]
"healing": return ["healing_source_and_recipient_change"]
"damage": return ["melee_contact", "damage_source_target_and_value"]
"knockout": return ["enemy_action_and_target_relation", "damage_source_target_and_value", "knockout_and_line_collapse"]
"guarded_recovery": return ["guard_or_protection_intercept"]
"result": return ["outcome_lock"]
return []
func _observation_delta_direction(kind: String) -> String:
if kind in ["ranged", "magic", "damage", "knockout"]:
return "loss"
if kind in ["guard", "healing", "guarded_recovery"]:
return "gain"
return "none"
func _observation_event_id(battle: Dictionary, phase: int) -> String:
return ("%s|%s|observation|%d" % [String(battle.get("operation_id", "")), String(battle.get("snapshot_hash", "")), phase]).sha256_text().substr(0, 32)
func _observation_decisive_event(events: Array) -> Dictionary:
for event in events:
if String(event.get("kind", "")) == "damage":
return Dictionary(event).duplicate(true)
return Dictionary(events.back()).duplicate(true) if not events.is_empty() else {}
func _observation_final_snapshot_hash(battle: Dictionary, journal_hash: String) -> String:
return _hash({
"operation_id": String(battle.operation_id),
"snapshot_id": String(battle.snapshot_id),
"snapshot_hash": String(battle.snapshot_hash),
"front_to_rear_ids": Array(battle.front_to_rear_ids).duplicate(),
"enemy_ids": Array(battle.enemy_ids).duplicate(),
"phase_index": int(battle.phase_index),
"knocked_out_ids": Array(battle.knocked_out_ids).duplicate(),
"event_journal_hash": journal_hash,
})
func _apply_xp(recruit: Dictionary, amount: int) -> void:
var level := int(recruit.level)
var xp := int(recruit.xp) + amount
while level < 10:
var threshold := 20 + 10 * level
if xp < threshold:
break
xp -= threshold
level += 1
if level >= 10:
xp = 0
recruit.level = level
recruit.xp = xp
func _event_for_phase(company: Dictionary, battle: Dictionary, phase: int) -> Dictionary:
var ids: Array = battle.front_to_rear_ids
var actor_id := String(ids[0]) if not ids.is_empty() else ""
var rear_id := String(ids[ids.size() - 1]) if not ids.is_empty() else ""
var target_id := String(battle.enemy_ids[0]) if not battle.enemy_ids.is_empty() else "enemy-front"
var event := {
"phase": phase,
"actor_id": actor_id,
"target_id": target_id,
"amount": 0,
"outcome": "observed",
}
match phase:
0:
event.kind = "opening"
event.caption_id = "battle_opening"
event.actor_id = ""
event.target_id = ""
1:
event.kind = "telegraph"
event.caption_id = "incoming_threat"
2:
event.kind = "guard"
event.caption_id = "protection_intercepts"
event.actor_id = rear_id if not rear_id.is_empty() else actor_id
event.target_id = actor_id
event.amount = 8
3:
event.kind = "ranged"
event.caption_id = "ranged_contact"
event.actor_id = rear_id if not rear_id.is_empty() else actor_id
event.amount = 10
4:
event.kind = "magic"
event.caption_id = "magic_break"
event.actor_id = actor_id
event.amount = 12
5:
event.kind = "healing"
event.caption_id = "healing_link"
event.actor_id = String(ids[mini(1, ids.size() - 1)]) if not ids.is_empty() else actor_id
event.target_id = actor_id
event.amount = 6
6:
event.kind = "damage"
event.caption_id = "decisive_damage"
event.actor_id = actor_id
event.amount = 22
7:
event.kind = "knockout" if int(battle.risk) > 0 else "guarded_recovery"
event.caption_id = "knockout" if int(battle.risk) > 0 else "line_holds"
event.target_id = actor_id if int(battle.risk) > 0 else ""
8:
event.kind = "result"
event.caption_id = "victory"
event.outcome = "victory"
_refresh_hash(event, "event_hash")
return event
func _risk_option_by_id(formation: Dictionary, option_id: String) -> Dictionary:
for option in formation.get("risk_options", []):
if String(option.get("risk_option_id", "")) == option_id:
return Dictionary(option)
return {}
func _store() -> RefCounted:
var store := DraftService.new()
store.company_path = company_path
return store
func _refresh_hash(value: Dictionary, field: String) -> void:
value.erase(field)
value[field] = _hash(value)
func _hash_without(value: Dictionary, field: String) -> String:
var copy := value.duplicate(true)
copy.erase(field)
return _hash(copy)
func _is_hex(value: String, length: int) -> bool:
if value.length() != length:
return false
for character in value:
if character not in "0123456789abcdef":
return false
return true
func _hash(value) -> String:
return JSON.stringify(_canonical(value)).sha256_text()
func _canonical(value):
if value is float and is_equal_approx(value, round(value)):
return int(round(value))
if value is Dictionary:
var result := {}
var keys: Array = value.keys()
keys.sort_custom(func(left, right): return String(left) < String(right))
for key in keys:
result[key] = _canonical(value[key])
return result
if value is Array:
var result: Array = []
for item in value:
result.append(_canonical(item))
return result
return value