454 lines
17 KiB
GDScript
454 lines
17 KiB
GDScript
class_name P102MidContractResolutionService
|
|
extends RefCounted
|
|
|
|
const BoardService = preload("res://p10_2/market_board_service.gd")
|
|
const State = preload("res://p10_2/run_state.gd")
|
|
|
|
const MAX_CREDIT := 54
|
|
|
|
var state := State.new()
|
|
var boards = BoardService.new()
|
|
var valid := false
|
|
var error_code := "invalid_state"
|
|
|
|
|
|
func _init(snapshot_value: Dictionary) -> void:
|
|
valid = state.restore_snapshot(snapshot_value)
|
|
if valid and String(state.current_run.phase) in ["prepared", "recovery"]:
|
|
error_code = ""
|
|
else:
|
|
valid = false
|
|
error_code = "prepared_or_recovery_required"
|
|
|
|
|
|
static func xp_to_next(level: int) -> int:
|
|
return 20 + 10 * level
|
|
|
|
|
|
static func battle_xp(round_index: int, risk: int) -> int:
|
|
return 10 + 2 * round_index + 4 * risk
|
|
|
|
|
|
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 settle(
|
|
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_mid_round", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if String(state.current_run.phase) != "prepared":
|
|
return _reject("prepared_route_required")
|
|
var validation := _validate_battle_snapshot(battle_snapshot)
|
|
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.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" else floori(0.5 * float(battle_snapshot.xp_grant))
|
|
var growth_rows: Array = []
|
|
for recruit_id in battle_snapshot.final_order:
|
|
if recruit_id in state.current_run.company:
|
|
growth_rows.append(_apply_survivor_growth(
|
|
String(recruit_id),
|
|
int(battle_snapshot.actors[recruit_id].hp),
|
|
xp_award
|
|
))
|
|
state.current_run.last_battle_report = {
|
|
"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,
|
|
"first_decisive_event": Dictionary(battle_snapshot.first_decisive_event).duplicate(true),
|
|
"fallen_recruit_ids": Array(battle_snapshot.fallen_recruit_ids).duplicate(),
|
|
"tick": int(battle_snapshot.tick),
|
|
"xp_award": 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),
|
|
"outcome": outcome,
|
|
"casualties": casualties,
|
|
"growth": growth_rows,
|
|
"xp_award": xp_award,
|
|
}
|
|
if outcome == "victory":
|
|
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),
|
|
"route_id": String(battle_snapshot.route_id),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": outcome,
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"reward_coin": reward_coin,
|
|
})
|
|
var next_market := int(state.current_run.market_index) + 1
|
|
state.current_run.market_index = next_market
|
|
state.current_run.round_index = next_market
|
|
state.current_run.line_capacity = mini(
|
|
10,
|
|
4 + floori(float(state.current_run.cleared_rounds) / 2.0)
|
|
)
|
|
state.current_run.refresh_count = 0
|
|
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
|
|
if next_market <= 4:
|
|
var generated := boards.generate(String(state.current_run.run_seed), next_market, 0)
|
|
if not generated.ok:
|
|
state.restore_snapshot(before)
|
|
return _reject("next_market_generation_failed")
|
|
var next_board := BoardService.merge_locked_board(
|
|
Dictionary(checkpoint.run.market_board),
|
|
Dictionary(generated.board),
|
|
Array(state.current_run.locked_offer_ids)
|
|
)
|
|
if next_board.is_empty():
|
|
state.restore_snapshot(before)
|
|
return _reject("next_market_lock_merge_failed")
|
|
state.current_run.phase = "market"
|
|
state.current_run.market_board = next_board
|
|
state.current_run.available_recruit_offer_ids = _offer_ids(next_board.recruit_offers, "source_offer_id")
|
|
state.current_run.available_equipment_offer_ids = _offer_ids(next_board.equipment_offers, "offer_id")
|
|
state.current_run.available_battle_offer_ids = _offer_ids(next_board.battle_offers, "offer_id")
|
|
else:
|
|
var settlement_hash := BoardService.hash_value({
|
|
"battle_hash": String(battle_snapshot.snapshot_hash),
|
|
"cleared_rounds": int(state.current_run.cleared_rounds),
|
|
"route_history": Array(state.current_run.route_history).duplicate(true),
|
|
})
|
|
var handoff := {
|
|
"handoff_kind": "p10.2.market_5_continuation",
|
|
"run_seed": String(state.current_run.run_seed),
|
|
"contract_id": "CTR-R2-01",
|
|
"region_id": "RG-02",
|
|
"market_index": 5,
|
|
"cleared_rounds": 4,
|
|
"line_capacity": 6,
|
|
"source_settlement_hash": settlement_hash,
|
|
"carried_lock_ids": Array(state.current_run.locked_offer_ids).duplicate(),
|
|
}
|
|
handoff["board_hash"] = BoardService.hash_value(handoff)
|
|
state.current_run.phase = "market_5"
|
|
state.current_run.market_board = handoff
|
|
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.reed_ferry_restored = true
|
|
state.battle_checkpoints.erase(checkpoint_id)
|
|
receipt["reward_coin"] = reward_coin
|
|
receipt["coin"] = int(state.current_run.coin)
|
|
receipt["market_index"] = next_market
|
|
receipt["cleared_rounds"] = int(state.current_run.cleared_rounds)
|
|
receipt["line_capacity"] = int(state.current_run.line_capacity)
|
|
receipt["reed_ferry_restored"] = bool(state.current_run.reed_ferry_restored)
|
|
else:
|
|
state.current_run.phase = "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["recovery_cost"] = int(state.current_run.recovery_cost)
|
|
receipt["replacement_credit"] = int(state.current_run.replacement_credit)
|
|
return _commit(
|
|
operation_id,
|
|
"settle_mid_round",
|
|
intent,
|
|
"mid_round_settled",
|
|
receipt,
|
|
before
|
|
)
|
|
|
|
|
|
func recover(
|
|
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_mid_round", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if String(state.current_run.phase) != "recovery" or not bool(state.current_run.recovery_required):
|
|
return _reject("recovery_not_required")
|
|
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
|
|
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(0.4 * float(record.max_hp))
|
|
)
|
|
state.current_run.phase = "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_mid_round", intent, "mid_round_recovered", {
|
|
"operation_id": operation_id,
|
|
"battle_hash": expected_battle_hash,
|
|
"coin_cost": expected_cost,
|
|
"coin": int(state.current_run.coin),
|
|
"replacement_credit": int(state.current_run.replacement_credit),
|
|
"surviving_recruit_ids": Array(state.current_run.company).duplicate(),
|
|
}, before)
|
|
|
|
|
|
func _validate_battle_snapshot(snapshot_value: Dictionary) -> Dictionary:
|
|
for key in [
|
|
"run_id", "contract_id", "region_id", "market_index", "round_index",
|
|
"offer_id", "route_id", "risk", "plan_id", "fixed_tick_ms", "tick",
|
|
"terminal", "outcome", "actors", "final_order", "fallen_recruit_ids",
|
|
"event_log", "first_decisive_event", "reward_coin", "recovery_coin",
|
|
"xp_grant", "board_hash", "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"}
|
|
var hashed := snapshot_value.duplicate(true)
|
|
var snapshot_hash := String(hashed.snapshot_hash)
|
|
hashed.erase("snapshot_hash")
|
|
if String(hashed.event_log_hash) != BoardService.hash_value(hashed.event_log):
|
|
return {"ok": false, "code": "event_log_hash_mismatch"}
|
|
if snapshot_hash != BoardService.hash_value(hashed):
|
|
return {"ok": false, "code": "battle_hash_mismatch"}
|
|
var run: Dictionary = state.current_run
|
|
var encounter: Dictionary = run.pending_encounter
|
|
if (
|
|
String(snapshot_value.run_id) != String(run.run_id)
|
|
or String(snapshot_value.contract_id) != "CTR-R2-01"
|
|
or String(snapshot_value.region_id) != "RG-02"
|
|
or int(snapshot_value.market_index) != int(run.market_index)
|
|
or int(snapshot_value.round_index) != int(run.round_index)
|
|
):
|
|
return {"ok": false, "code": "battle_run_mismatch"}
|
|
if (
|
|
String(snapshot_value.offer_id) != String(encounter.offer_id)
|
|
or String(snapshot_value.route_id) != String(encounter.route_id)
|
|
or int(snapshot_value.risk) != int(encounter.risk)
|
|
or String(snapshot_value.board_hash) != String(encounter.board_hash)
|
|
):
|
|
return {"ok": false, "code": "battle_route_mismatch"}
|
|
if (
|
|
int(snapshot_value.fixed_tick_ms) != 100
|
|
or int(snapshot_value.tick) <= 0
|
|
or int(snapshot_value.tick) > 200
|
|
or snapshot_value.first_decisive_event.is_empty()
|
|
):
|
|
return {"ok": false, "code": "battle_clock_invalid"}
|
|
if not snapshot_value.actors is Dictionary or not snapshot_value.fallen_recruit_ids is Array:
|
|
return {"ok": false, "code": "battle_actor_type"}
|
|
if Array(snapshot_value.final_order) != Array(encounter.deployed_recruit_ids):
|
|
return {"ok": false, "code": "battle_order_mismatch"}
|
|
if not _same_members(snapshot_value.actors.keys(), encounter.deployed_recruit_ids):
|
|
return {"ok": false, "code": "battle_actor_membership"}
|
|
for recruit_id in encounter.deployed_recruit_ids:
|
|
var actor: Dictionary = snapshot_value.actors[recruit_id]
|
|
if int(actor.hp) < 0 or int(actor.hp) > int(actor.max_hp):
|
|
return {"ok": false, "code": "battle_actor_hp"}
|
|
if (recruit_id in snapshot_value.fallen_recruit_ids) != (int(actor.hp) == 0):
|
|
return {"ok": false, "code": "battle_fallen_mismatch"}
|
|
if int(snapshot_value.reward_coin) != int(encounter.reward_coin):
|
|
return {"ok": false, "code": "battle_reward_mismatch"}
|
|
if int(snapshot_value.recovery_coin) != int(encounter.recovery_coin):
|
|
return {"ok": false, "code": "battle_recovery_mismatch"}
|
|
if int(snapshot_value.xp_grant) != battle_xp(int(encounter.round_index), int(encounter.risk)):
|
|
return {"ok": false, "code": "battle_xp_mismatch"}
|
|
var checkpoint_id := String(snapshot_value.checkpoint_id)
|
|
if checkpoint_id != String(run.pre_battle_checkpoint_id) or not state.battle_checkpoints.has(checkpoint_id):
|
|
return {"ok": false, "code": "battle_checkpoint_mismatch"}
|
|
return {"ok": true, "code": "ok"}
|
|
|
|
|
|
func _apply_actor_results(battle_snapshot: Dictionary) -> Array:
|
|
var casualties: Array = []
|
|
for recruit_id in battle_snapshot.final_order:
|
|
if recruit_id in battle_snapshot.fallen_recruit_ids:
|
|
casualties.append(_apply_casualty(String(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() and state.current_run.inventory.has(instance_id):
|
|
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_survivor_growth(recruit_id: String, remaining_hp: int, xp_award: int) -> Dictionary:
|
|
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(clampi(remaining_hp, 1, max_hp_before)) / 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.level = 10
|
|
record.xp = 0
|
|
record.current_hp = clampi(
|
|
int(round(hp_ratio * float(record.max_hp))),
|
|
1,
|
|
int(record.max_hp)
|
|
)
|
|
state.current_run.recruit_records[recruit_id] = record
|
|
return {
|
|
"recruit_id": recruit_id,
|
|
"level_before": level_before,
|
|
"level_after": int(record.level),
|
|
"xp_before": xp_before,
|
|
"xp_award": xp_award,
|
|
"xp_after": int(record.xp),
|
|
"max_hp_before": max_hp_before,
|
|
"max_hp_after": int(record.max_hp),
|
|
"current_hp_after": int(record.current_hp),
|
|
"survived_battles": int(record.survived_battles),
|
|
}
|
|
|
|
|
|
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) != BoardService.hash_value(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": BoardService.hash_value(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 _same_members(left: Array, right: Array) -> bool:
|
|
if left.size() != right.size():
|
|
return false
|
|
for value in left:
|
|
if value not in right:
|
|
return false
|
|
return true
|