772 lines
29 KiB
GDScript
772 lines
29 KiB
GDScript
class_name FG3Contract2ExecutionService
|
|
extends RefCounted
|
|
|
|
const BattleService = preload("res://fg3/contract2_battle_service.gd")
|
|
const MarketGenerator = preload("res://fg3/contract2_market_generator.gd")
|
|
const State = preload("res://fg3/run_state.gd")
|
|
|
|
const MAX_CREDIT := 54
|
|
const FIRST_CLEAR_RENOWN := 20
|
|
const CLEAR_ID := "CLEAR-RG02-C02"
|
|
const BOSS_ID := "BO-R2-02"
|
|
const BOSS_RECOVERY_COST := 26
|
|
const BOSS_PLAN_IDS := ["interrupt_pulse_family", "durable_links_stagger"]
|
|
|
|
var state := State.new()
|
|
var valid := false
|
|
var error_code := "invalid_state"
|
|
|
|
|
|
func _init(snapshot_value: Dictionary) -> void:
|
|
valid = state.restore_snapshot(snapshot_value)
|
|
if valid:
|
|
error_code = ""
|
|
|
|
|
|
static func xp_to_next(level: int) -> int:
|
|
return 20 + 10 * level
|
|
|
|
|
|
static func death_credit(original_list_price: int, coin_price_paid: int) -> int:
|
|
return mini(original_list_price, 10 + floori(float(coin_price_paid) / 2.0))
|
|
|
|
|
|
func commit_route(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
offer_id: String,
|
|
expected_board_hash: String
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"offer_id": offer_id,
|
|
"expected_board_hash": expected_board_hash,
|
|
}
|
|
var prior := _prior_operation(operation_id, "commit_contract2_route", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_market")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
if expected_board_hash != String(state.current_run.market_board.get("board_hash", "")):
|
|
return _reject("board_hash_mismatch")
|
|
if state.current_run.deployed_recruit_ids.is_empty():
|
|
return _reject("deployment_required")
|
|
if state.current_run.deployed_recruit_ids.size() > 10:
|
|
return _reject("deployment_over_capacity")
|
|
var offer := _battle_offer(offer_id)
|
|
if offer.is_empty() or offer_id not in state.current_run.available_battle_offer_ids:
|
|
return _reject("battle_offer_unavailable")
|
|
var before := state.snapshot()
|
|
var checkpoint_id := _hash({
|
|
"run_id": String(state.current_run.run_id),
|
|
"contract_id": "CTR-R2-02",
|
|
"operation_id": operation_id,
|
|
"revision": expected_revision,
|
|
"board_hash": expected_board_hash,
|
|
"offer_id": offer_id,
|
|
})
|
|
state.battle_checkpoints[checkpoint_id] = {
|
|
"checkpoint_id": checkpoint_id,
|
|
"kind": "contract2_route",
|
|
"source_revision": expected_revision,
|
|
"source_snapshot": before.duplicate(true),
|
|
"board_hash": expected_board_hash,
|
|
"offer_id": offer_id,
|
|
}
|
|
state.current_run.phase = "contract2_prepared"
|
|
state.current_run.selected_route_id = offer_id
|
|
state.current_run.pending_encounter = offer.duplicate(true)
|
|
state.current_run.pending_encounter["deployed_recruit_ids"] = Array(state.current_run.deployed_recruit_ids).duplicate()
|
|
state.current_run.pending_encounter["active_artifact_ids"] = Array(state.current_run.active_artifact_ids).duplicate()
|
|
state.current_run.pre_battle_checkpoint_id = checkpoint_id
|
|
return _commit(operation_id, "commit_contract2_route", intent, "contract2_route_prepared", {
|
|
"operation_id": operation_id,
|
|
"offer_id": offer_id,
|
|
"route_id": String(offer.route_id),
|
|
"risk": int(offer.risk),
|
|
"checkpoint_id": checkpoint_id,
|
|
"board_hash": expected_board_hash,
|
|
}, before)
|
|
|
|
|
|
func settle_route(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
battle_snapshot: Dictionary
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"snapshot_hash": String(battle_snapshot.get("snapshot_hash", "")),
|
|
}
|
|
var prior := _prior_operation(operation_id, "settle_contract2_route", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_prepared")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
var validation := _validate_battle_snapshot(battle_snapshot, "fg3_contract2_route")
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
var before := state.snapshot()
|
|
var checkpoint_id := String(battle_snapshot.checkpoint_id)
|
|
var checkpoint: Dictionary = state.battle_checkpoints[checkpoint_id]
|
|
state.current_run = Dictionary(checkpoint.source_snapshot.current_run).duplicate(true)
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var casualties := _apply_actor_results(battle_snapshot)
|
|
var xp_award := int(battle_snapshot.xp_grant)
|
|
if outcome != "victory":
|
|
xp_award = floori(float(xp_award) * 0.5)
|
|
var growth := _apply_growth(battle_snapshot, xp_award)
|
|
_update_formation()
|
|
state.current_run.last_battle_report = _route_report(battle_snapshot, xp_award)
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"round_index": int(battle_snapshot.round_index),
|
|
"route_id": String(battle_snapshot.route_id),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": outcome,
|
|
"cause": String(battle_snapshot.cause),
|
|
"casualties": casualties,
|
|
"growth": growth,
|
|
"xp_award": xp_award,
|
|
}
|
|
if outcome == "victory":
|
|
var advanced := _advance_route_victory(battle_snapshot, checkpoint_id)
|
|
if not advanced.ok:
|
|
state.restore_snapshot(before)
|
|
return _reject(String(advanced.code))
|
|
receipt.merge(advanced.receipt, true)
|
|
else:
|
|
state.current_run.phase = "contract2_recovery"
|
|
state.current_run.selected_route_id = ""
|
|
state.current_run.pending_encounter = {}
|
|
state.current_run.pre_battle_checkpoint_id = checkpoint_id
|
|
state.current_run.recovery_required = true
|
|
state.current_run.recovery_cost = int(battle_snapshot.recovery_coin)
|
|
receipt["reward_coin"] = 0
|
|
receipt["recovery_cost"] = int(state.current_run.recovery_cost)
|
|
receipt["replacement_credit"] = int(state.current_run.replacement_credit)
|
|
return _commit(operation_id, "settle_contract2_route", intent, "contract2_route_settled", receipt, before)
|
|
|
|
|
|
func recover_route(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
expected_battle_hash: String,
|
|
expected_cost: int
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_battle_hash": expected_battle_hash,
|
|
"expected_cost": expected_cost,
|
|
}
|
|
var prior := _prior_operation(operation_id, "recover_contract2_route", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_recovery")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
if expected_battle_hash != String(state.current_run.last_battle_report.get("battle_hash", "")):
|
|
return _reject("battle_hash_mismatch")
|
|
if expected_cost != int(state.current_run.recovery_cost):
|
|
return _reject("recovery_cost_mismatch")
|
|
if int(state.current_run.coin) < expected_cost:
|
|
return _reject("insufficient_coin")
|
|
var before := state.snapshot()
|
|
state.current_run.coin = int(state.current_run.coin) - expected_cost
|
|
_heal_survivors_to_floor(0.4)
|
|
state.current_run.phase = "contract2_market"
|
|
state.current_run.recovery_required = false
|
|
state.current_run.recovery_cost = 0
|
|
state.current_run.selected_route_id = ""
|
|
state.current_run.pending_encounter = {}
|
|
var checkpoint_id := String(state.current_run.pre_battle_checkpoint_id)
|
|
state.current_run.pre_battle_checkpoint_id = ""
|
|
state.battle_checkpoints.erase(checkpoint_id)
|
|
return _commit(operation_id, "recover_contract2_route", intent, "contract2_route_recovered", {
|
|
"operation_id": operation_id,
|
|
"battle_hash": expected_battle_hash,
|
|
"coin_cost": expected_cost,
|
|
"coin": int(state.current_run.coin),
|
|
"surviving_recruit_ids": Array(state.current_run.company).duplicate(),
|
|
}, before)
|
|
|
|
|
|
func commit_boss_plan(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
expected_muster_hash: String,
|
|
plan_id: String
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_muster_hash": expected_muster_hash,
|
|
"plan_id": plan_id,
|
|
}
|
|
var prior := _prior_operation(operation_id, "commit_contract2_boss_plan", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_boss_muster")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
if expected_muster_hash != String(state.current_run.boss_muster.get("muster_hash", "")):
|
|
return _reject("muster_hash_mismatch")
|
|
if plan_id not in state.current_run.boss_muster.plan_ids:
|
|
return _reject("boss_plan_invalid")
|
|
if state.current_run.deployed_recruit_ids.is_empty():
|
|
return _reject("deployment_required")
|
|
var before := state.snapshot()
|
|
var checkpoint_id := _hash({
|
|
"run_id": String(state.current_run.run_id),
|
|
"operation_id": operation_id,
|
|
"revision": expected_revision,
|
|
"muster_hash": expected_muster_hash,
|
|
"plan_id": plan_id,
|
|
})
|
|
state.battle_checkpoints[checkpoint_id] = {
|
|
"checkpoint_id": checkpoint_id,
|
|
"kind": "contract2_boss",
|
|
"source_revision": expected_revision,
|
|
"source_snapshot": before.duplicate(true),
|
|
"muster_hash": expected_muster_hash,
|
|
"plan_id": plan_id,
|
|
}
|
|
state.current_run.phase = "contract2_boss_prepared"
|
|
state.current_run.selected_boss_plan = plan_id
|
|
state.current_run.pre_battle_checkpoint_id = checkpoint_id
|
|
state.current_run.pending_boss_result = {}
|
|
return _commit(operation_id, "commit_contract2_boss_plan", intent, "contract2_boss_plan_prepared", {
|
|
"operation_id": operation_id,
|
|
"boss_id": BOSS_ID,
|
|
"plan_id": plan_id,
|
|
"muster_hash": expected_muster_hash,
|
|
"checkpoint_id": checkpoint_id,
|
|
}, before)
|
|
|
|
|
|
func settle_boss(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
battle_snapshot: Dictionary
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"snapshot_hash": String(battle_snapshot.get("snapshot_hash", "")),
|
|
}
|
|
var prior := _prior_operation(operation_id, "settle_contract2_boss", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_boss_prepared")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
var validation := _validate_battle_snapshot(battle_snapshot, "fg3_contract2_boss")
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
var before := state.snapshot()
|
|
var checkpoint_id := String(battle_snapshot.checkpoint_id)
|
|
var checkpoint: Dictionary = state.battle_checkpoints[checkpoint_id]
|
|
state.current_run = Dictionary(checkpoint.source_snapshot.current_run).duplicate(true)
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var casualties := _apply_actor_results(battle_snapshot)
|
|
var growth := _apply_growth(battle_snapshot, 0)
|
|
_update_formation()
|
|
var result := {
|
|
"boss_id": BOSS_ID,
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"muster_hash": String(battle_snapshot.muster_hash),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": outcome,
|
|
"cause": String(battle_snapshot.cause),
|
|
"fallen_recruit_ids": Array(battle_snapshot.fallen_recruit_ids).duplicate(),
|
|
"casualties": casualties,
|
|
"growth": growth,
|
|
}
|
|
var receipt := result.duplicate(true)
|
|
receipt["operation_id"] = operation_id
|
|
receipt["reward_coin"] = 0
|
|
state.current_run.selected_boss_plan = ""
|
|
if outcome == "victory":
|
|
result["close_id"] = _hash({
|
|
"run_id": String(state.current_run.run_id),
|
|
"clear_id": CLEAR_ID,
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
}).substr(0, 32)
|
|
result["clear_id"] = CLEAR_ID
|
|
result["first_clear_renown"] = FIRST_CLEAR_RENOWN
|
|
state.current_run.phase = "region_result"
|
|
state.current_run.pending_boss_result = result
|
|
state.current_run.pre_battle_checkpoint_id = ""
|
|
state.current_run.recovery_required = false
|
|
state.current_run.recovery_cost = 0
|
|
state.battle_checkpoints.erase(checkpoint_id)
|
|
receipt["close_id"] = String(result.close_id)
|
|
else:
|
|
state.current_run.phase = "contract2_boss_recovery"
|
|
state.current_run.pending_boss_result = result
|
|
state.current_run.pre_battle_checkpoint_id = checkpoint_id
|
|
state.current_run.recovery_required = true
|
|
state.current_run.recovery_cost = BOSS_RECOVERY_COST
|
|
receipt["recovery_cost"] = BOSS_RECOVERY_COST
|
|
receipt["replacement_credit"] = int(state.current_run.replacement_credit)
|
|
return _commit(operation_id, "settle_contract2_boss", intent, "contract2_boss_settled", receipt, before)
|
|
|
|
|
|
func recover_boss(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
expected_battle_hash: String,
|
|
expected_cost: int
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_battle_hash": expected_battle_hash,
|
|
"expected_cost": expected_cost,
|
|
}
|
|
var prior := _prior_operation(operation_id, "recover_contract2_boss", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "contract2_boss_recovery")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
if expected_battle_hash != String(state.current_run.pending_boss_result.get("battle_hash", "")):
|
|
return _reject("battle_hash_mismatch")
|
|
if expected_cost != int(state.current_run.recovery_cost):
|
|
return _reject("recovery_cost_mismatch")
|
|
if int(state.current_run.coin) < expected_cost:
|
|
return _reject("insufficient_coin")
|
|
var before := state.snapshot()
|
|
state.current_run.coin = int(state.current_run.coin) - expected_cost
|
|
_heal_survivors_to_floor(0.4)
|
|
state.current_run.phase = "contract2_boss_muster"
|
|
state.current_run.selected_boss_plan = ""
|
|
state.current_run.pending_boss_result = {}
|
|
state.current_run.recovery_required = false
|
|
state.current_run.recovery_cost = 0
|
|
var checkpoint_id := String(state.current_run.pre_battle_checkpoint_id)
|
|
state.current_run.pre_battle_checkpoint_id = ""
|
|
state.battle_checkpoints.erase(checkpoint_id)
|
|
return _commit(operation_id, "recover_contract2_boss", intent, "contract2_boss_recovered", {
|
|
"operation_id": operation_id,
|
|
"battle_hash": expected_battle_hash,
|
|
"coin_cost": expected_cost,
|
|
"coin": int(state.current_run.coin),
|
|
"muster_hash": String(state.current_run.boss_muster.muster_hash),
|
|
"surviving_recruit_ids": Array(state.current_run.company).duplicate(),
|
|
}, before)
|
|
|
|
|
|
func close_region(
|
|
operation_id: String,
|
|
expected_revision: int,
|
|
expected_close_id: String
|
|
) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_close_id": expected_close_id,
|
|
}
|
|
var prior := _prior_operation(operation_id, "close_rg02", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _guard(expected_revision, "region_result")
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
var pending: Dictionary = state.current_run.pending_boss_result
|
|
if expected_close_id != String(pending.get("close_id", "")):
|
|
return _reject("close_id_mismatch")
|
|
var before := state.snapshot()
|
|
var first_clear: bool = CLEAR_ID not in state.guild_profile.contract_clear_ids
|
|
var renown_grant := FIRST_CLEAR_RENOWN if first_clear else 0
|
|
if first_clear:
|
|
state.guild_profile.contract_clear_ids.append(CLEAR_ID)
|
|
state.guild_profile.persistent_renown = mini(9999, int(state.guild_profile.persistent_renown) + renown_grant)
|
|
var discovery_id := "BOSS-%s" % BOSS_ID
|
|
if discovery_id not in state.guild_profile.discovery_ids:
|
|
state.guild_profile.discovery_ids.append(discovery_id)
|
|
var record := _company_record(pending, renown_grant)
|
|
state.guild_profile.company_records.append(record)
|
|
state.guild_profile.clear_receipts[CLEAR_ID] = {
|
|
"clear_id": CLEAR_ID,
|
|
"close_id": expected_close_id,
|
|
"record_id": String(record.record_id),
|
|
"renown_grant": renown_grant,
|
|
}
|
|
if "RG-03" not in state.guild_profile.unlocked_region_ids:
|
|
state.guild_profile.unlocked_region_ids.append("RG-03")
|
|
var result := {
|
|
"result_kind": "fg3.rg02_closed",
|
|
"run_id": String(state.current_run.run_id),
|
|
"region_id": "RG-02",
|
|
"clear_ids": ["CLEAR-RG02-C01", CLEAR_ID],
|
|
"record_ids": [
|
|
String(state.guild_profile.clear_receipts["CLEAR-RG02-C01"].record_id),
|
|
String(record.record_id),
|
|
],
|
|
"persistent_renown": int(state.guild_profile.persistent_renown),
|
|
"unlocked_region_id": "RG-03",
|
|
"surviving_company_ids": Array(state.current_run.company).duplicate(),
|
|
"boss_battle_hash": String(pending.battle_hash),
|
|
}
|
|
result["result_hash"] = _hash(result)
|
|
state.current_run.status = "closed"
|
|
state.current_run.phase = "region_closed"
|
|
state.current_run.market_board = result
|
|
state.current_run.available_recruit_offer_ids = []
|
|
state.current_run.available_equipment_offer_ids = []
|
|
state.current_run.available_battle_offer_ids = []
|
|
state.current_run.locked_offer_ids = []
|
|
state.current_run.next_contract_id = ""
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"close_id": expected_close_id,
|
|
"clear_id": CLEAR_ID,
|
|
"first_clear": first_clear,
|
|
"renown_grant": renown_grant,
|
|
"persistent_renown": int(state.guild_profile.persistent_renown),
|
|
"record": record,
|
|
"result": result,
|
|
"unlocked_region_ids": Array(state.guild_profile.unlocked_region_ids).duplicate(),
|
|
}
|
|
return _commit(operation_id, "close_rg02", intent, "rg02_closed", receipt, before)
|
|
|
|
|
|
func _advance_route_victory(battle_snapshot: Dictionary, checkpoint_id: String) -> Dictionary:
|
|
var reward_coin := int(battle_snapshot.reward_coin)
|
|
state.current_run.coin = int(state.current_run.coin) + reward_coin
|
|
state.current_run.cleared_rounds = int(state.current_run.cleared_rounds) + 1
|
|
state.current_run.route_history.append({
|
|
"round_index": int(battle_snapshot.round_index),
|
|
"contract_id": "CTR-R2-02",
|
|
"route_id": String(battle_snapshot.route_id),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": "victory",
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"reward_coin": reward_coin,
|
|
"artifact_id": String(battle_snapshot.artifact_id),
|
|
})
|
|
state.current_run.selected_route_id = ""
|
|
state.current_run.pending_encounter = {}
|
|
state.current_run.pre_battle_checkpoint_id = ""
|
|
state.current_run.recovery_required = false
|
|
state.current_run.recovery_cost = 0
|
|
state.current_run.refresh_count = 0
|
|
state.battle_checkpoints.erase(checkpoint_id)
|
|
var next_market := int(battle_snapshot.round_index) + 1
|
|
if next_market <= 12:
|
|
var generated := MarketGenerator.new().generate(String(state.current_run.run_seed), next_market, 0)
|
|
if not generated.ok:
|
|
return {"ok": false, "code": "next_contract2_market_generation_failed"}
|
|
var board: Dictionary = generated.board
|
|
state.current_run.phase = "contract2_market"
|
|
state.current_run.market_index = next_market
|
|
state.current_run.round_index = next_market
|
|
state.current_run.line_capacity = 10
|
|
state.current_run.market_board = board.duplicate(true)
|
|
state.current_run.available_recruit_offer_ids = _offer_ids(board.recruit_slots, "slot_id")
|
|
state.current_run.available_equipment_offer_ids = _offer_ids(board.equipment_offers, "offer_id")
|
|
state.current_run.available_battle_offer_ids = _offer_ids(board.battle_offers, "offer_id")
|
|
state.current_run.locked_offer_ids = []
|
|
else:
|
|
state.current_run.phase = "contract2_boss_muster"
|
|
state.current_run.market_index = 13
|
|
state.current_run.round_index = 13
|
|
state.current_run.line_capacity = 10
|
|
state.current_run.available_recruit_offer_ids = []
|
|
state.current_run.available_equipment_offer_ids = []
|
|
state.current_run.available_battle_offer_ids = []
|
|
state.current_run.locked_offer_ids = []
|
|
state.current_run.boss_muster = _boss_muster()
|
|
state.current_run.selected_boss_plan = ""
|
|
state.current_run.pending_boss_result = {}
|
|
return {"ok": true, "code": "contract2_victory_advanced", "receipt": {
|
|
"reward_coin": reward_coin,
|
|
"coin": int(state.current_run.coin),
|
|
"market_index": int(state.current_run.market_index),
|
|
"cleared_rounds": int(state.current_run.cleared_rounds),
|
|
"line_capacity": 10,
|
|
"boss_muster_open": next_market == 13,
|
|
}}
|
|
|
|
|
|
func _boss_muster() -> Dictionary:
|
|
var muster := {
|
|
"muster_id": _hash({
|
|
"run_seed": String(state.current_run.run_seed),
|
|
"boss_id": BOSS_ID,
|
|
"contract_id": "CTR-R2-02",
|
|
}).substr(0, 32),
|
|
"boss_id": BOSS_ID,
|
|
"contract_id": "CTR-R2-02",
|
|
"market_index": 13,
|
|
"line_capacity": 10,
|
|
"plan_ids": BOSS_PLAN_IDS.duplicate(),
|
|
"pulse_family": "healing_movement_cleanse",
|
|
"linked_indices": [3, 4],
|
|
"third_pulse_indices": [3, 4, 5],
|
|
"failure_cause": "linked_cost_chained_through_recovery",
|
|
"source_route_hash": String(state.current_run.last_battle_report.battle_hash),
|
|
}
|
|
muster["muster_hash"] = _hash(muster)
|
|
return muster
|
|
|
|
|
|
func _validate_battle_snapshot(snapshot_value: Dictionary, expected_kind: String) -> Dictionary:
|
|
if String(snapshot_value.get("kind", "")) != expected_kind:
|
|
return {"ok": false, "code": "battle_kind_mismatch"}
|
|
for key in [
|
|
"run_id", "contract_id", "region_id", "plan_id", "terminal", "outcome",
|
|
"cause", "actors", "final_order", "fallen_recruit_ids", "event_log",
|
|
"checkpoint_id", "event_log_hash", "snapshot_hash",
|
|
]:
|
|
if not snapshot_value.has(key):
|
|
return {"ok": false, "code": "battle_field_missing:%s" % key}
|
|
if not bool(snapshot_value.terminal) or String(snapshot_value.outcome) not in ["victory", "defeat"]:
|
|
return {"ok": false, "code": "battle_not_terminal"}
|
|
if String(snapshot_value.checkpoint_id) not in state.battle_checkpoints:
|
|
return {"ok": false, "code": "checkpoint_missing"}
|
|
var expected := BattleService.new(state.snapshot()).run_to_terminal(String(snapshot_value.plan_id))
|
|
if not expected.ok:
|
|
return {"ok": false, "code": "battle_replay_failed"}
|
|
if expected.snapshot != snapshot_value:
|
|
return {"ok": false, "code": "battle_snapshot_mismatch"}
|
|
return {"ok": true, "code": "ok"}
|
|
|
|
|
|
func _apply_actor_results(battle_snapshot: Dictionary) -> Array:
|
|
var casualties: Array = []
|
|
for recruit_id_value in battle_snapshot.final_order:
|
|
var recruit_id := String(recruit_id_value)
|
|
if recruit_id in battle_snapshot.fallen_recruit_ids:
|
|
casualties.append(_apply_casualty(recruit_id))
|
|
elif recruit_id in state.current_run.company:
|
|
state.current_run.recruit_records[recruit_id].current_hp = maxi(1, int(battle_snapshot.actors[recruit_id].hp))
|
|
return casualties
|
|
|
|
|
|
func _apply_casualty(recruit_id: String) -> Dictionary:
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
var instance_id := String(state.current_run.equipped_by_recruit.get(recruit_id, ""))
|
|
if not instance_id.is_empty():
|
|
state.current_run.inventory[instance_id].wearer_id = ""
|
|
var credit_before := int(state.current_run.replacement_credit)
|
|
var grant := death_credit(int(record.get("original_list_price", 14)), int(record.get("coin_price_paid", 0)))
|
|
state.current_run.replacement_credit = mini(MAX_CREDIT, credit_before + grant)
|
|
state.current_run.company.erase(recruit_id)
|
|
state.current_run.deployed_recruit_ids.erase(recruit_id)
|
|
state.current_run.reserve_recruit_ids.erase(recruit_id)
|
|
state.current_run.recruit_records.erase(recruit_id)
|
|
state.current_run.equipped_by_recruit.erase(recruit_id)
|
|
state.current_run.recruits_fallen_total = int(state.current_run.recruits_fallen_total) + 1
|
|
return {
|
|
"recruit_id": recruit_id,
|
|
"profession_id": String(record.profession_id),
|
|
"trait_id": String(record.trait_id),
|
|
"level": int(record.level),
|
|
"credit_grant": int(state.current_run.replacement_credit) - credit_before,
|
|
"returned_equipment_instance_id": instance_id,
|
|
}
|
|
|
|
|
|
func _apply_growth(battle_snapshot: Dictionary, xp_award: int) -> Array:
|
|
var rows: Array = []
|
|
for recruit_id_value in battle_snapshot.final_order:
|
|
var recruit_id := String(recruit_id_value)
|
|
if recruit_id not in state.current_run.company:
|
|
continue
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
var level_before := int(record.level)
|
|
var xp_before := int(record.xp)
|
|
var max_hp_before := int(record.max_hp)
|
|
var hp_ratio := float(record.current_hp) / float(maxi(1, max_hp_before))
|
|
record.survived_battles = int(record.survived_battles) + 1
|
|
record.xp = int(record.xp) + xp_award
|
|
while int(record.level) < 10 and int(record.xp) >= xp_to_next(int(record.level)):
|
|
record.xp = int(record.xp) - xp_to_next(int(record.level))
|
|
record.level = int(record.level) + 1
|
|
record.max_hp = int(record.max_hp) + 4
|
|
if int(record.level) >= 10:
|
|
record.xp = mini(int(record.xp), xp_to_next(10) - 1)
|
|
record.current_hp = clampi(roundi(hp_ratio * float(record.max_hp)), 1, int(record.max_hp))
|
|
state.current_run.recruit_records[recruit_id] = record
|
|
rows.append({
|
|
"recruit_id": recruit_id,
|
|
"level_before": level_before,
|
|
"level_after": int(record.level),
|
|
"xp_before": xp_before,
|
|
"xp_after": int(record.xp),
|
|
})
|
|
return rows
|
|
|
|
|
|
func _route_report(battle_snapshot: Dictionary, xp_award: int) -> Dictionary:
|
|
return {
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"round_index": int(battle_snapshot.round_index),
|
|
"route_id": String(battle_snapshot.route_id),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": String(battle_snapshot.outcome),
|
|
"cause": String(battle_snapshot.cause),
|
|
"fallen_recruit_ids": Array(battle_snapshot.fallen_recruit_ids).duplicate(),
|
|
"artifact_id": String(battle_snapshot.artifact_id),
|
|
"tick": int(battle_snapshot.tick),
|
|
"xp_award": xp_award,
|
|
}
|
|
|
|
|
|
func _company_record(pending: Dictionary, renown_grant: int) -> Dictionary:
|
|
var survivors: Array = []
|
|
for recruit_id in state.current_run.company:
|
|
var recruit: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
survivors.append({
|
|
"recruit_id": String(recruit_id),
|
|
"profession_id": String(recruit.profession_id),
|
|
"trait_id": String(recruit.trait_id),
|
|
"level": int(recruit.level),
|
|
"survived_battles": int(recruit.survived_battles),
|
|
})
|
|
var contract2_routes: Array = Array(state.current_run.route_history).slice(12)
|
|
return {
|
|
"record_id": _hash({
|
|
"run_id": String(state.current_run.run_id),
|
|
"clear_id": CLEAR_ID,
|
|
"battle_hash": String(pending.battle_hash),
|
|
}).substr(0, 32),
|
|
"run_id": String(state.current_run.run_id),
|
|
"contract_id": "CTR-R2-02",
|
|
"clear_id": CLEAR_ID,
|
|
"boss_id": BOSS_ID,
|
|
"plan_id": String(pending.plan_id),
|
|
"renown_grant": renown_grant,
|
|
"survivors": survivors,
|
|
"fallen_total": int(state.current_run.recruits_fallen_total),
|
|
"dismissed_total": int(state.current_run.recruits_dismissed_total),
|
|
"active_artifact_ids": Array(state.current_run.active_artifact_ids).duplicate(),
|
|
"route_ids": contract2_routes.map(func(row): return String(row.get("route_id", ""))),
|
|
}
|
|
|
|
|
|
func _update_formation() -> void:
|
|
var deployed: Array = []
|
|
for recruit_id in state.current_run.deployed_recruit_ids:
|
|
if recruit_id in state.current_run.company:
|
|
deployed.append(recruit_id)
|
|
state.current_run.deployed_recruit_ids = deployed
|
|
var reserve: Array = []
|
|
for recruit_id in state.current_run.company:
|
|
if recruit_id not in deployed:
|
|
reserve.append(recruit_id)
|
|
state.current_run.reserve_recruit_ids = reserve
|
|
state.current_run.formation_proposal = Array(state.current_run.company).slice(0, mini(10, state.current_run.company.size()))
|
|
|
|
|
|
func _heal_survivors_to_floor(ratio: float) -> void:
|
|
for recruit_id in state.current_run.company:
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
record.current_hp = maxi(int(record.current_hp), ceili(ratio * float(record.max_hp)))
|
|
state.current_run.recruit_records[recruit_id] = record
|
|
|
|
|
|
func _battle_offer(offer_id: String) -> Dictionary:
|
|
for offer in state.current_run.market_board.battle_offers:
|
|
if String(offer.get("offer_id", "")) == offer_id:
|
|
return Dictionary(offer).duplicate(true)
|
|
return {}
|
|
|
|
|
|
func _guard(expected_revision: int, phase: String) -> String:
|
|
if not valid:
|
|
return error_code
|
|
if expected_revision != state.revision:
|
|
return "stale_revision"
|
|
if String(state.current_run.phase) != phase:
|
|
return "%s_required" % phase
|
|
return ""
|
|
|
|
|
|
func _prior_operation(operation_id: String, kind: String, intent: Dictionary) -> Dictionary:
|
|
if operation_id.is_empty():
|
|
return {"known": true, "result": _reject("operation_id_required")}
|
|
if not valid or not state.operation_receipts.has(operation_id):
|
|
return {"known": false}
|
|
var stored: Dictionary = state.operation_receipts[operation_id]
|
|
if String(stored.kind) != kind or String(stored.intent_hash) != _hash(intent):
|
|
return {"known": true, "result": _reject("operation_conflict")}
|
|
return {"known": true, "result": {
|
|
"ok": true,
|
|
"code": String(stored.code),
|
|
"duplicate": true,
|
|
"revision": int(stored.revision),
|
|
"receipt": Dictionary(stored.receipt).duplicate(true),
|
|
"snapshot": state.snapshot(),
|
|
}}
|
|
|
|
|
|
func _commit(
|
|
operation_id: String,
|
|
kind: String,
|
|
intent: Dictionary,
|
|
code: String,
|
|
receipt: Dictionary,
|
|
before: Dictionary
|
|
) -> Dictionary:
|
|
state.revision += 1
|
|
state.operation_receipts[operation_id] = {
|
|
"operation_id": operation_id,
|
|
"kind": kind,
|
|
"intent_hash": _hash(intent),
|
|
"status": "applied",
|
|
"code": code,
|
|
"revision": state.revision,
|
|
"receipt": receipt.duplicate(true),
|
|
}
|
|
if not state.invariants_hold():
|
|
state.restore_snapshot(before)
|
|
return _reject("postcondition_failed")
|
|
return {
|
|
"ok": true,
|
|
"code": code,
|
|
"duplicate": false,
|
|
"revision": state.revision,
|
|
"receipt": receipt.duplicate(true),
|
|
"snapshot": state.snapshot(),
|
|
}
|
|
|
|
|
|
func _reject(code: String) -> Dictionary:
|
|
return {
|
|
"ok": false,
|
|
"code": code,
|
|
"duplicate": false,
|
|
"revision": state.revision if valid else -1,
|
|
}
|
|
|
|
|
|
func _offer_ids(offers: Array, field: String) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for offer in offers:
|
|
result.append(String(offer.get(field, "")))
|
|
return result
|
|
|
|
|
|
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
|