1181 lines
49 KiB
GDScript
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
|