435 lines
17 KiB
GDScript
435 lines
17 KiB
GDScript
class_name P99RunService
|
|
extends RefCounted
|
|
|
|
const Content = preload("res://content/p9_9_content.gd")
|
|
const State = preload("res://p9_9/run_state.gd")
|
|
|
|
const STARTING_COIN := 52
|
|
const DEFEAT_RECOVERY_COST := 6
|
|
const ROLE_ORDER := {
|
|
"contact_guard": 0, "front_control": 0, "edge_contact": 1, "flank": 1,
|
|
"reach_control": 2, "mixed_enable": 3, "construct_support": 4,
|
|
"healer": 5, "ranged": 6, "area_caster": 7,
|
|
}
|
|
|
|
var state := State.new()
|
|
var content: Dictionary = {}
|
|
|
|
|
|
func _init(profile_snapshot: Dictionary = {}) -> void:
|
|
if not profile_snapshot.is_empty():
|
|
assert(state.initialize_from_profile(profile_snapshot))
|
|
content = Content.run_snapshot(state.selected_charter_claim_id)
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if not state.restore_snapshot(snapshot):
|
|
return false
|
|
content = Content.run_snapshot(state.selected_charter_claim_id)
|
|
return true
|
|
|
|
|
|
func draft_offers() -> Array[Dictionary]:
|
|
var rows: Array[Dictionary] = []
|
|
for recruit_id in state.draft_offer_ids:
|
|
rows.append(Dictionary(content.draft_recruits[recruit_id]).duplicate(true))
|
|
return rows
|
|
|
|
|
|
func set_provisional(operation_id: String, recruit_id: String, selected: bool, expected_revision := -1) -> Dictionary:
|
|
var intent := {"recruit_id": recruit_id, "selected": selected}
|
|
var prior := _prior_operation(operation_id, "set_provisional", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if state.run_created:
|
|
return _reject("run_already_created")
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if recruit_id not in state.draft_offer_ids:
|
|
return _reject("draft_recruit_unknown")
|
|
if selected and recruit_id not in state.selected_provisional_ids and state.selected_provisional_ids.size() >= 2:
|
|
return _reject("draft_capacity_reached")
|
|
if selected and recruit_id not in state.selected_provisional_ids:
|
|
state.selected_provisional_ids.append(recruit_id)
|
|
elif not selected:
|
|
state.selected_provisional_ids.erase(recruit_id)
|
|
_sort_by_authority(state.selected_provisional_ids, state.draft_offer_ids)
|
|
var receipt := {
|
|
"index": state.operation_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"recruit_id": recruit_id,
|
|
"selected": selected,
|
|
"selected_ids": state.selected_provisional_ids.duplicate(),
|
|
"selected_count": state.selected_provisional_ids.size(),
|
|
}
|
|
return _commit(operation_id, "set_provisional", intent, "provisional_selection_set", receipt)
|
|
|
|
|
|
func confirm_run(operation_id: String, expected_revision := -1) -> Dictionary:
|
|
var intent := {"selected_ids": state.selected_provisional_ids.duplicate(), "run_seed": state.run_seed}
|
|
var prior := _prior_operation(operation_id, "confirm_run", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if state.run_created:
|
|
return _reject("run_already_created")
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.selected_provisional_ids.size() != 2:
|
|
return _reject("exact_two_required")
|
|
state.run_created = true
|
|
state.initial_selected_ids.assign(state.selected_provisional_ids)
|
|
state.coin = STARTING_COIN
|
|
state.cleared_rounds = 0
|
|
state.line_capacity = State.capacity_for_clears(0)
|
|
state.current_market = 1
|
|
for recruit_id in state.initial_selected_ids:
|
|
_add_recruit(Dictionary(content.draft_recruits[recruit_id]), 0, "provisional")
|
|
_open_current_market()
|
|
var receipt := {
|
|
"index": state.run_creation_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"run_seed": state.run_seed,
|
|
"selected_ids": state.initial_selected_ids.duplicate(),
|
|
"discarded_ids": state.draft_offer_ids.filter(func(value): return value not in state.initial_selected_ids),
|
|
"starting_coin": STARTING_COIN,
|
|
"line_capacity": state.line_capacity,
|
|
"market": 1,
|
|
}
|
|
state.run_creation_receipts.append(receipt)
|
|
return _commit(operation_id, "confirm_run", intent, "run_created", receipt)
|
|
|
|
|
|
func hire(operation_id: String, offer_id: String) -> Dictionary:
|
|
var intent := {"offer_id": offer_id}
|
|
var prior := _prior_operation(operation_id, "hire", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not state.run_created or state.phase != State.Phase.MARKET or state.current_market != 1:
|
|
return _reject("market_one_required")
|
|
if state.recovery_required:
|
|
return _reject("recovery_required")
|
|
if offer_id not in state.available_recruit_offer_ids or not content.market_hires.has(offer_id):
|
|
return _reject("recruit_offer_unavailable")
|
|
if state.company.size() >= 4:
|
|
return _reject("opening_company_full")
|
|
var row: Dictionary = content.market_hires[offer_id]
|
|
var price := int(row.list_price)
|
|
if state.coin < price:
|
|
return _reject("insufficient_coin")
|
|
var coin_before := state.coin
|
|
state.coin -= price
|
|
_add_recruit(row, price, "market_one")
|
|
state.available_recruit_offer_ids.erase(offer_id)
|
|
var receipt := {
|
|
"index": state.hire_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"offer_id": offer_id,
|
|
"price": price,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"company_count": state.company.size(),
|
|
}
|
|
state.hire_receipts.append(receipt)
|
|
if state.company.size() == 4:
|
|
state.deployed_recruit_ids.assign(auto_order_candidate())
|
|
state.formation_receipts.append({
|
|
"index": state.formation_receipts.size(),
|
|
"trigger_operation_id": operation_id,
|
|
"ordered_ids": state.deployed_recruit_ids.duplicate(),
|
|
"automatic": true,
|
|
})
|
|
receipt["automatic_order"] = state.deployed_recruit_ids.duplicate()
|
|
return _commit(operation_id, "hire", intent, "hire_committed", receipt)
|
|
|
|
|
|
func auto_order_candidate() -> Array[String]:
|
|
var ordered: Array[String] = []
|
|
ordered.assign(state.company)
|
|
ordered.sort_custom(func(left: String, right: String):
|
|
var left_role := _profession_role(String(state.recruit_records[left].profession_id))
|
|
var right_role := _profession_role(String(state.recruit_records[right].profession_id))
|
|
var left_key := int(ROLE_ORDER.get(left_role, 99))
|
|
var right_key := int(ROLE_ORDER.get(right_role, 99))
|
|
return left_key < right_key if left_key != right_key else state.company.find(left) < state.company.find(right)
|
|
)
|
|
return ordered
|
|
|
|
|
|
func route_preview(route_id: String) -> Dictionary:
|
|
if not content.routes.has(route_id) or route_id not in state.market_board.get("battle_offer_ids", []):
|
|
return _reject("route_unknown")
|
|
var route: Dictionary = content.routes[route_id]
|
|
var result := {
|
|
"ok": true,
|
|
"code": "route_preview",
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"reward": int(route.reward),
|
|
"enemy_ids": Array(route.enemy_ids).duplicate(),
|
|
"pressure_zh": String(route.pressure_zh),
|
|
"pressure_en": String(route.pressure_en),
|
|
"battle_seed": int(route.seed),
|
|
"threat_intel_revealed": false,
|
|
"intel": {},
|
|
}
|
|
if state.selected_charter_claim_id == "CH-RG01-THREAT-01":
|
|
result.threat_intel_revealed = true
|
|
result.intel = {
|
|
"arrival_tick": int(route.arrival_tick),
|
|
"selected_target": String(route.selected_target),
|
|
"gust_cadence": int(route.gust_cadence),
|
|
}
|
|
return result
|
|
|
|
|
|
func commit_route(operation_id: String, route_id: String) -> Dictionary:
|
|
var intent := {"route_id": route_id}
|
|
var prior := _prior_operation(operation_id, "commit_route", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if state.phase != State.Phase.MARKET or state.current_market > 3 or state.continuation_locked:
|
|
return _reject("active_market_required")
|
|
if state.company.size() != 4 or state.deployed_recruit_ids.size() != 4:
|
|
return _reject("four_recruit_company_required")
|
|
if state.recovery_required:
|
|
return _reject("recovery_required")
|
|
if route_id not in state.available_battle_offer_ids or not content.routes.has(route_id):
|
|
return _reject("route_unavailable")
|
|
var route: Dictionary = content.routes[route_id]
|
|
if int(route.round) != state.current_market:
|
|
return _reject("route_round_mismatch")
|
|
state.battle_checkpoint = state.snapshot()
|
|
state.phase = State.Phase.ENCOUNTER
|
|
state.selected_route = route_id
|
|
state.route_history.append(route_id)
|
|
var receipt := {
|
|
"index": state.route_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"reward": int(route.reward),
|
|
"enemy_ids": Array(route.enemy_ids).duplicate(),
|
|
"battle_seed": int(route.seed),
|
|
"threat_intel_revealed": state.selected_charter_claim_id == "CH-RG01-THREAT-01",
|
|
}
|
|
state.route_receipts.append(receipt)
|
|
return _commit(operation_id, "commit_route", intent, "route_committed", receipt)
|
|
|
|
|
|
func settle_battle(operation_id: String, battle_snapshot: Dictionary) -> Dictionary:
|
|
var intent := {
|
|
"route_id": String(battle_snapshot.get("route_id", "")),
|
|
"revision": int(battle_snapshot.get("revision", -1)),
|
|
"tick": int(battle_snapshot.get("tick", -1)),
|
|
"outcome": String(battle_snapshot.get("outcome", "")),
|
|
"member_hp_hash": JSON.stringify(battle_snapshot.get("member_hp", {})).sha256_text(),
|
|
"enemy_hp_hash": JSON.stringify(battle_snapshot.get("enemy_hp", {})).sha256_text(),
|
|
}
|
|
var prior := _prior_operation(operation_id, "settle_battle", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var validation := _validate_terminal_battle(battle_snapshot)
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
var route_id := state.selected_route
|
|
var route: Dictionary = content.routes[route_id]
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var coin_before := state.coin
|
|
var injured: Array[String] = []
|
|
for recruit_id in state.deployed_recruit_ids:
|
|
var hp := int(battle_snapshot.member_hp[recruit_id])
|
|
state.member_condition[recruit_id] = hp
|
|
if hp <= 0:
|
|
injured.append(recruit_id)
|
|
if outcome == "victory":
|
|
state.coin += int(route.reward)
|
|
state.cleared_rounds += 1
|
|
state.line_capacity = State.capacity_for_clears(state.cleared_rounds)
|
|
state.current_market = state.cleared_rounds + 1
|
|
_open_current_market()
|
|
_clear_recovery()
|
|
else:
|
|
state.recovery_required = true
|
|
state.recovery_kind = "defeat"
|
|
state.recovery_cost = DEFEAT_RECOVERY_COST
|
|
state.injured_recruit_ids.assign(injured)
|
|
state.retry_route = route_id
|
|
state.phase = State.Phase.MARKET
|
|
state.selected_route = ""
|
|
state.battle_checkpoint.clear()
|
|
var receipt := {
|
|
"index": state.battle_settlement_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"outcome": outcome,
|
|
"tick": int(battle_snapshot.tick),
|
|
"coin_reward": int(route.reward) if outcome == "victory" else 0,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"member_hp": Dictionary(battle_snapshot.member_hp).duplicate(true),
|
|
"cleared_rounds": state.cleared_rounds,
|
|
"line_capacity": state.line_capacity,
|
|
"next_market": state.current_market,
|
|
}
|
|
state.battle_settlement_receipts.append(receipt)
|
|
return _commit(operation_id, "settle_battle", intent, "battle_settled", receipt)
|
|
|
|
|
|
func recover_defeat(operation_id: String) -> Dictionary:
|
|
var intent := {"action": "recover_defeat"}
|
|
var prior := _prior_operation(operation_id, "recover_defeat", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if state.phase != State.Phase.MARKET or not state.recovery_required or state.recovery_kind != "defeat":
|
|
return _reject("defeat_recovery_unavailable")
|
|
if state.coin < state.recovery_cost:
|
|
return _reject("insufficient_coin")
|
|
var coin_before := state.coin
|
|
var recovered_ids := state.injured_recruit_ids.duplicate()
|
|
var retry := state.retry_route
|
|
state.coin -= state.recovery_cost
|
|
for recruit_id in recovered_ids:
|
|
state.member_condition[recruit_id] = _member_max_hp(recruit_id)
|
|
var receipt := {
|
|
"index": state.recovery_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"cost": DEFEAT_RECOVERY_COST,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"recruit_ids": recovered_ids,
|
|
"retry_route": retry,
|
|
}
|
|
state.recovery_receipts.append(receipt)
|
|
_clear_recovery()
|
|
return _commit(operation_id, "recover_defeat", intent, "defeat_recovered", receipt)
|
|
|
|
|
|
func _add_recruit(row: Dictionary, coin_price_paid: int, source: String) -> void:
|
|
var recruit_id := String(row.id)
|
|
var profession_id := String(row.profession_id)
|
|
var maximum := int(content.professions[profession_id].max_hp)
|
|
state.company.append(recruit_id)
|
|
state.recruit_records[recruit_id] = {
|
|
"id": recruit_id,
|
|
"name_zh": String(row.name_zh),
|
|
"name_en": String(row.name_en),
|
|
"profession_id": profession_id,
|
|
"trait_id": String(row.trait_id),
|
|
"generation_seed": String(row.generation_seed),
|
|
"appearance_seed": int(row.appearance_seed),
|
|
"level": 1,
|
|
"xp": 0,
|
|
"coin_price_paid": coin_price_paid,
|
|
"source": source,
|
|
}
|
|
state.member_condition[recruit_id] = maximum
|
|
state.equipped_by_recruit[recruit_id] = ""
|
|
|
|
|
|
func _open_current_market() -> void:
|
|
state.market_board = Dictionary(content.boards[state.current_market]).duplicate(true)
|
|
state.available_recruit_offer_ids.assign(state.market_board.recruit_offer_ids)
|
|
state.available_equipment_offer_ids.assign(state.market_board.equipment_offer_ids)
|
|
state.available_battle_offer_ids.assign(state.market_board.battle_offer_ids)
|
|
state.continuation_locked = state.current_market == 4
|
|
|
|
|
|
func _validate_terminal_battle(battle_snapshot: Dictionary) -> Dictionary:
|
|
if state.phase != State.Phase.ENCOUNTER or state.selected_route.is_empty():
|
|
return {"ok": false, "code": "encounter_required"}
|
|
for key in ["route_id", "tick", "terminal", "outcome", "member_ids", "member_hp", "enemy_ids", "enemy_hp"]:
|
|
if not battle_snapshot.has(key):
|
|
return {"ok": false, "code": "battle_snapshot_malformed"}
|
|
if String(battle_snapshot.route_id) != state.selected_route or not bool(battle_snapshot.terminal):
|
|
return {"ok": false, "code": "battle_snapshot_mismatch"}
|
|
if String(battle_snapshot.outcome) not in ["victory", "defeat"]:
|
|
return {"ok": false, "code": "battle_not_settleable"}
|
|
if not _same_members(Array(battle_snapshot.member_ids), state.deployed_recruit_ids):
|
|
return {"ok": false, "code": "battle_company_mismatch"}
|
|
var route: Dictionary = content.routes[state.selected_route]
|
|
if Array(battle_snapshot.enemy_ids) != Array(route.enemy_ids):
|
|
return {"ok": false, "code": "battle_enemy_mismatch"}
|
|
var living_members := 0
|
|
for recruit_id in state.deployed_recruit_ids:
|
|
if not battle_snapshot.member_hp.has(recruit_id):
|
|
return {"ok": false, "code": "battle_hp_missing"}
|
|
var hp := int(battle_snapshot.member_hp[recruit_id])
|
|
if hp < 0 or hp > _member_max_hp(recruit_id):
|
|
return {"ok": false, "code": "battle_hp_invalid"}
|
|
living_members += 1 if hp > 0 else 0
|
|
var living_enemies := 0
|
|
for enemy_id in route.enemy_ids:
|
|
if not battle_snapshot.enemy_hp.has(enemy_id):
|
|
return {"ok": false, "code": "battle_hp_missing"}
|
|
var hp := int(battle_snapshot.enemy_hp[enemy_id])
|
|
if hp < 0 or hp > int(content.enemies[enemy_id].max_hp):
|
|
return {"ok": false, "code": "battle_hp_invalid"}
|
|
living_enemies += 1 if hp > 0 else 0
|
|
if String(battle_snapshot.outcome) == "victory" and living_enemies != 0:
|
|
return {"ok": false, "code": "victory_not_proven"}
|
|
if String(battle_snapshot.outcome) == "defeat" and living_members != 0:
|
|
return {"ok": false, "code": "defeat_not_proven"}
|
|
return {"ok": true}
|
|
|
|
|
|
func _member_max_hp(recruit_id: String) -> int:
|
|
var profession_id := String(state.recruit_records.get(recruit_id, {}).get("profession_id", ""))
|
|
return int(content.professions.get(profession_id, {}).get("max_hp", 0))
|
|
|
|
|
|
func _profession_role(profession_id: String) -> String:
|
|
return String(content.professions.get(profession_id, {}).get("role", ""))
|
|
|
|
|
|
func _clear_recovery() -> void:
|
|
state.recovery_required = false
|
|
state.recovery_kind = ""
|
|
state.recovery_cost = 0
|
|
state.injured_recruit_ids.clear()
|
|
state.retry_route = ""
|
|
|
|
|
|
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 state.operation_receipts.has(operation_id):
|
|
return {"known": false}
|
|
var stored: Dictionary = state.operation_receipts[operation_id]
|
|
if String(stored.get("kind", "")) != kind or String(stored.get("intent_hash", "")) != _intent_hash(kind, intent):
|
|
return {"known": true, "result": _reject("operation_id_conflict")}
|
|
return {"known": true, "result": {"ok": true, "code": "already_applied", "duplicate": true, "receipt": Dictionary(stored.receipt).duplicate(true), "snapshot": state.snapshot()}}
|
|
|
|
|
|
func _commit(operation_id: String, kind: String, intent: Dictionary, code: String, receipt: Dictionary) -> Dictionary:
|
|
state.operation_receipts[operation_id] = {"kind": kind, "intent_hash": _intent_hash(kind, intent), "receipt": receipt.duplicate(true)}
|
|
state.revision += 1
|
|
assert(state.invariants_hold())
|
|
return {"ok": true, "code": code, "duplicate": false, "receipt": receipt.duplicate(true), "snapshot": state.snapshot()}
|
|
|
|
|
|
func _intent_hash(kind: String, intent: Dictionary) -> String:
|
|
return JSON.stringify({"kind": kind, "intent": intent}).sha256_text()
|
|
|
|
|
|
func _sort_by_authority(values: Array[String], authority: Array[String]) -> void:
|
|
values.sort_custom(func(left: String, right: String): return authority.find(left) < authority.find(right))
|
|
|
|
|
|
func _same_members(left: Array, right: Array) -> bool:
|
|
if left.size() != right.size():
|
|
return false
|
|
var seen := {}
|
|
for value in left:
|
|
if seen.has(value) or value not in right:
|
|
return false
|
|
seen[value] = true
|
|
return true
|
|
|
|
|
|
func _reject(code: String) -> Dictionary:
|
|
return {"ok": false, "code": code, "duplicate": false, "receipt": {}, "snapshot": state.snapshot()}
|