796 lines
34 KiB
GDScript
796 lines
34 KiB
GDScript
class_name P100ResolutionService
|
|
extends RefCounted
|
|
|
|
const Content = preload("res://p10_0/content_registry.gd")
|
|
const MarketService = preload("res://p10_0/market_service.gd")
|
|
const OfferGenerator = preload("res://p10_0/offer_generator.gd")
|
|
const State = preload("res://p10_0/run_state.gd")
|
|
|
|
const MAX_CREDIT := 54
|
|
const IMPLEMENTED_ARTIFACT_IDS := ["AR-003", "AR-005", "AR-006"]
|
|
const ARTIFACT_CONFLICTS := {
|
|
"AR-003": "AR-004",
|
|
"AR-004": "AR-003",
|
|
"AR-005": "AR-006",
|
|
"AR-006": "AR-005",
|
|
"AR-007": "AR-008",
|
|
"AR-008": "AR-007",
|
|
}
|
|
const ROLE_ORDER := {
|
|
"PF-B01": 0, "PF-B02": 1, "PF-B03": 2, "PF-B04": 3,
|
|
"PF-B08": 4, "PF-B09": 5, "PF-B11": 6, "PF-B07": 7,
|
|
"PF-B05": 8, "PF-B06": 9, "PF-B10": 10, "PF-B12": 11,
|
|
}
|
|
|
|
var state := State.new()
|
|
var catalog: Dictionary = Content.snapshot()
|
|
var generator = OfferGenerator.new(catalog)
|
|
var valid := false
|
|
var error_code := ""
|
|
|
|
|
|
func _init(run_snapshot: Dictionary = {}) -> void:
|
|
if run_snapshot.is_empty():
|
|
error_code = "run_snapshot_required"
|
|
return
|
|
valid = state.restore_snapshot(run_snapshot)
|
|
if not valid:
|
|
error_code = "invalid_run_snapshot"
|
|
|
|
|
|
func restore_snapshot(raw: Dictionary) -> bool:
|
|
valid = state.restore_snapshot(raw)
|
|
error_code = "" if valid else "invalid_run_snapshot"
|
|
return valid
|
|
|
|
|
|
func settle_battle(operation_id: String, expected_revision: int, expected_battle_hash: String, battle_snapshot: Dictionary) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_battle_hash": expected_battle_hash,
|
|
}
|
|
var prior := _prior_operation(operation_id, "settle_battle", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.get("phase", "")) != "encounter":
|
|
return _reject("encounter_required")
|
|
var validation := _validate_battle_snapshot(battle_snapshot, expected_battle_hash)
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
|
|
var before := state.snapshot()
|
|
var current: Dictionary = state.current_run
|
|
var encounter: Dictionary = current.encounter
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var round_index := int(current.market_index)
|
|
var risk := int(encounter.risk)
|
|
var fallen_ids: Array = Array(battle_snapshot.fallen_recruit_ids).duplicate()
|
|
var deployed_before: Array = Array(encounter.deployed_recruit_ids).duplicate()
|
|
var survivor_ids: Array[String] = []
|
|
for recruit_id in deployed_before:
|
|
if recruit_id not in fallen_ids:
|
|
survivor_ids.append(String(recruit_id))
|
|
|
|
var casualties: Array[Dictionary] = []
|
|
var returned_equipment_ids: Array[String] = []
|
|
var credit_before := int(current.replacement_credit)
|
|
for recruit_id in fallen_ids:
|
|
var casualty := _apply_casualty(String(recruit_id))
|
|
if casualty.is_empty():
|
|
state.restore_snapshot(before)
|
|
return _reject("casualty_snapshot_mismatch")
|
|
casualties.append(casualty)
|
|
if not String(casualty.returned_equipment_instance_id).is_empty():
|
|
returned_equipment_ids.append(String(casualty.returned_equipment_instance_id))
|
|
|
|
var xp_award := _battle_xp(round_index, risk, outcome, not survivor_ids.is_empty())
|
|
var growth: Array[Dictionary] = []
|
|
for recruit_id in survivor_ids:
|
|
var actor: Dictionary = battle_snapshot.actors.get(recruit_id, {})
|
|
if actor.is_empty():
|
|
state.restore_snapshot(before)
|
|
return _reject("survivor_actor_missing")
|
|
growth.append(_apply_survivor_growth(recruit_id, int(actor.hp), xp_award))
|
|
|
|
var coin_before := int(current.coin)
|
|
var battle_coin := 0
|
|
var failure_fee := 0
|
|
if outcome == "victory":
|
|
battle_coin = _battle_coin(round_index, risk, String(current.difficulty))
|
|
current.coin = coin_before + battle_coin
|
|
current.cleared_rounds = int(current.cleared_rounds) + 1
|
|
current.market_index = mini(12, int(current.market_index) + 1)
|
|
current.line_capacity = State.capacity_for_clears(int(current.cleared_rounds))
|
|
if not _open_generated_market():
|
|
state.restore_snapshot(before)
|
|
return _reject("market_generation_failed")
|
|
current.pending_reward = _reward_draft(expected_battle_hash, round_index, risk)
|
|
else:
|
|
failure_fee = _failure_fee(String(current.difficulty), outcome, coin_before)
|
|
current.coin = coin_before - failure_fee
|
|
_reopen_stable_market(String(encounter.offer_id))
|
|
|
|
if not casualties.is_empty():
|
|
_install_protected_recovery_offer(round_index)
|
|
else:
|
|
current.recovery_required = false
|
|
current.protected_recovery_offer_id = ""
|
|
|
|
current.phase = "market"
|
|
current.encounter = {}
|
|
current.deployed_recruit_ids = _surviving_order(deployed_before)
|
|
current.reserve_recruit_ids = _remaining_reserve()
|
|
current.formation_proposal = _automatic_order(current.company, current.recruit_records, int(current.line_capacity))
|
|
var report := {
|
|
"battle_hash": expected_battle_hash,
|
|
"outcome": outcome,
|
|
"round": round_index,
|
|
"risk": risk,
|
|
"ticks": int(battle_snapshot.tick),
|
|
"first_decisive_event": Dictionary(battle_snapshot.first_decisive_event).duplicate(true),
|
|
"fallen_recruit_ids": fallen_ids,
|
|
"survivor_ids": survivor_ids,
|
|
"casualties": casualties,
|
|
"returned_equipment_instance_ids": returned_equipment_ids,
|
|
"credit_before": credit_before,
|
|
"credit_after": int(current.replacement_credit),
|
|
"xp_award": xp_award,
|
|
"growth": growth,
|
|
"battle_coin": battle_coin,
|
|
"failure_fee": failure_fee,
|
|
"coin_before": coin_before,
|
|
"coin_after": int(current.coin),
|
|
"reward_draft_id": String(current.pending_reward.get("reward_id", "")),
|
|
}
|
|
current.last_battle_report = report.duplicate(true)
|
|
var receipt := report.duplicate(true)
|
|
receipt["operation_id"] = operation_id
|
|
return _commit(operation_id, "settle_battle", intent, "battle_settled", receipt, before)
|
|
|
|
|
|
func claim_reward(operation_id: String, expected_revision: int, expected_draft_hash: String, card_id: String, artifact_id: String = "", replace_artifact_id: String = "") -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"expected_draft_hash": expected_draft_hash,
|
|
"card_id": card_id,
|
|
"artifact_id": artifact_id,
|
|
"replace_artifact_id": replace_artifact_id,
|
|
}
|
|
var prior := _prior_operation(operation_id, "claim_reward", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _reward_guard(expected_revision, expected_draft_hash)
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
var card := _reward_card(card_id)
|
|
if card.is_empty():
|
|
return _reject("reward_card_unavailable")
|
|
var before := state.snapshot()
|
|
var result := {"kind": String(card.kind)}
|
|
match String(card.kind):
|
|
"coin":
|
|
state.current_run.coin = int(state.current_run.coin) + int(card.amount)
|
|
result["coin_delta"] = int(card.amount)
|
|
"credit":
|
|
var credit_before := int(state.current_run.replacement_credit)
|
|
state.current_run.replacement_credit = mini(MAX_CREDIT, credit_before + int(card.amount))
|
|
result["credit_delta"] = int(state.current_run.replacement_credit) - credit_before
|
|
"equipment":
|
|
var instance_id := String(card.instance_id)
|
|
if state.current_run.inventory.has(instance_id):
|
|
return _reject("reward_equipment_exists")
|
|
state.current_run.inventory[instance_id] = {
|
|
"instance_id": instance_id,
|
|
"catalog_id": String(card.catalog_id),
|
|
"coin_price_paid": 0,
|
|
"wearer_id": "",
|
|
"source": String(state.current_run.pending_reward.reward_id),
|
|
}
|
|
result["instance_id"] = instance_id
|
|
result["catalog_id"] = String(card.catalog_id)
|
|
"artifact":
|
|
var artifact_result := _activate_artifact(card, artifact_id, replace_artifact_id)
|
|
if not artifact_result.ok:
|
|
return _reject(String(artifact_result.code))
|
|
result.merge(artifact_result.receipt, true)
|
|
_:
|
|
return _reject("unknown_reward_kind")
|
|
state.current_run.pending_reward = {}
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"card_id": card_id,
|
|
"result": result,
|
|
"coin_after": int(state.current_run.coin),
|
|
"credit_after": int(state.current_run.replacement_credit),
|
|
}
|
|
return _commit(operation_id, "claim_reward", intent, "reward_claimed", receipt, before)
|
|
|
|
|
|
func decline_reward(operation_id: String, expected_revision: int, expected_draft_hash: String) -> Dictionary:
|
|
var intent := {"expected_revision": expected_revision, "expected_draft_hash": expected_draft_hash}
|
|
var prior := _prior_operation(operation_id, "decline_reward", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var guard := _reward_guard(expected_revision, expected_draft_hash)
|
|
if not guard.is_empty():
|
|
return _reject(guard)
|
|
var before := state.snapshot()
|
|
var reward_id := String(state.current_run.pending_reward.reward_id)
|
|
state.current_run.pending_reward = {}
|
|
return _commit(operation_id, "decline_reward", intent, "reward_declined", {"operation_id": operation_id, "reward_id": reward_id}, before)
|
|
|
|
|
|
func promotion_options(recruit_id: String) -> Dictionary:
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if state.current_run.is_empty() or String(state.current_run.get("phase", "")) != "market":
|
|
return _reject("market_required")
|
|
if recruit_id not in state.current_run.company:
|
|
return _reject("recruit_unavailable")
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
var options := _regular_descendants(String(record.profession_id))
|
|
if int(record.level) < 4 or int(record.survived_battles) < 3 or options.size() != 2:
|
|
return _reject("promotion_not_eligible")
|
|
return _query("promotion_options", {
|
|
"recruit_id": recruit_id,
|
|
"current_profession_id": String(record.profession_id),
|
|
"options": options,
|
|
"level": int(record.level),
|
|
"survived_battles": int(record.survived_battles),
|
|
})
|
|
|
|
|
|
func commit_promotion(operation_id: String, expected_revision: int, recruit_id: String, expected_profession_id: String, chosen_profession_id: String) -> Dictionary:
|
|
var intent := {
|
|
"expected_revision": expected_revision,
|
|
"recruit_id": recruit_id,
|
|
"expected_profession_id": expected_profession_id,
|
|
"chosen_profession_id": chosen_profession_id,
|
|
}
|
|
var prior := _prior_operation(operation_id, "promote", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.get("phase", "")) != "market":
|
|
return _reject("market_required")
|
|
if not state.current_run.pending_reward.is_empty():
|
|
return _reject("reward_pending")
|
|
if recruit_id not in state.current_run.company:
|
|
return _reject("recruit_unavailable")
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
if String(record.profession_id) != expected_profession_id:
|
|
return _reject("profession_mismatch")
|
|
var options := _regular_descendants(expected_profession_id)
|
|
if int(record.level) < 4 or int(record.survived_battles) < 3 or chosen_profession_id not in options:
|
|
return _reject("promotion_not_eligible")
|
|
var before := state.snapshot()
|
|
var hp_ratio := float(record.current_hp) / float(maxi(1, int(record.max_hp)))
|
|
record.profession_id = chosen_profession_id
|
|
record.profession_overlay = chosen_profession_id
|
|
record.promotion_state = "regular_advanced"
|
|
record.promotion_history = Array(record.get("promotion_history", [])).duplicate()
|
|
record.promotion_history.append({"from": expected_profession_id, "to": chosen_profession_id, "market_index": int(state.current_run.market_index)})
|
|
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
|
|
state.current_run.formation_proposal = _automatic_order(state.current_run.company, state.current_run.recruit_records, int(state.current_run.line_capacity))
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"recruit_id": recruit_id,
|
|
"from_profession_id": expected_profession_id,
|
|
"to_profession_id": chosen_profession_id,
|
|
"hp_ratio": hp_ratio,
|
|
"trait_id": String(record.trait_id),
|
|
"equipment_instance_id": String(state.current_run.equipped_by_recruit[recruit_id]),
|
|
"survived_battles": int(record.survived_battles),
|
|
}
|
|
return _commit(operation_id, "promote", intent, "promotion_committed", receipt, before)
|
|
|
|
|
|
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 + int(floor(float(coin_price_paid) / 2.0)))
|
|
|
|
|
|
static func reward_budget(round_index: int, risk: int, region_band: int = 0) -> int:
|
|
return 12 + 2 * round_index + 3 * region_band + 6 * risk
|
|
|
|
|
|
func _validate_battle_snapshot(snapshot_value: Dictionary, expected_hash: String) -> Dictionary:
|
|
var required := [
|
|
"run_id", "contract_id", "market_index", "offer_id", "risk", "wave_count",
|
|
"tick", "tick_ms", "max_ticks", "terminal", "outcome", "ally_order",
|
|
"reserve_ids", "actors", "event_log", "fallen_recruit_ids",
|
|
"first_decisive_event", "event_log_hash", "snapshot_hash",
|
|
]
|
|
for key in required:
|
|
if not snapshot_value.has(key):
|
|
return {"ok": false, "code": "battle_snapshot_missing:%s" % key}
|
|
if not bool(snapshot_value.terminal) or String(snapshot_value.outcome) not in ["victory", "defeat", "timeout", "retreat"]:
|
|
return {"ok": false, "code": "battle_not_terminal"}
|
|
if String(snapshot_value.snapshot_hash) != expected_hash:
|
|
return {"ok": false, "code": "battle_hash_mismatch"}
|
|
var hashed := snapshot_value.duplicate(true)
|
|
hashed.erase("snapshot_hash")
|
|
if String(hashed.event_log_hash) != _hash(hashed.event_log) or _hash(hashed) != expected_hash:
|
|
return {"ok": false, "code": "battle_snapshot_tampered"}
|
|
var current: Dictionary = state.current_run
|
|
var encounter: Dictionary = current.encounter
|
|
if String(snapshot_value.run_id) != String(current.run_id) or String(snapshot_value.contract_id) != String(current.contract_id):
|
|
return {"ok": false, "code": "battle_run_mismatch"}
|
|
if int(snapshot_value.market_index) != int(current.market_index) or String(snapshot_value.offer_id) != String(encounter.offer_id) or int(snapshot_value.risk) != int(encounter.risk):
|
|
return {"ok": false, "code": "battle_encounter_mismatch"}
|
|
if int(snapshot_value.tick_ms) != 100 or int(snapshot_value.max_ticks) != 1500 or int(snapshot_value.tick) > 1500:
|
|
return {"ok": false, "code": "battle_timing_invalid"}
|
|
if snapshot_value.first_decisive_event.is_empty() or not snapshot_value.event_log.any(func(event): return String(event.get("event_id", "")) == String(snapshot_value.first_decisive_event.get("event_id", ""))):
|
|
return {"ok": false, "code": "decisive_event_missing"}
|
|
var fallen: Array = snapshot_value.fallen_recruit_ids
|
|
if not _unique(fallen) or not _subset(fallen, encounter.deployed_recruit_ids):
|
|
return {"ok": false, "code": "battle_casualty_invalid"}
|
|
for reserve_id in snapshot_value.reserve_ids:
|
|
if snapshot_value.actors.has(reserve_id):
|
|
return {"ok": false, "code": "reserve_entered_battle"}
|
|
for recruit_id in encounter.deployed_recruit_ids:
|
|
if not snapshot_value.actors.has(recruit_id):
|
|
return {"ok": false, "code": "deployed_actor_missing"}
|
|
var hp := int(snapshot_value.actors[recruit_id].hp)
|
|
if (recruit_id in fallen) != (hp == 0):
|
|
return {"ok": false, "code": "casualty_hp_mismatch"}
|
|
if String(snapshot_value.outcome) == "victory" and (int(snapshot_value.next_wave) != int(snapshot_value.wave_count) or not snapshot_value.enemy_order.is_empty()):
|
|
return {"ok": false, "code": "victory_predicate_invalid"}
|
|
if String(snapshot_value.outcome) == "defeat" and not snapshot_value.ally_order.is_empty():
|
|
return {"ok": false, "code": "defeat_predicate_invalid"}
|
|
return {"ok": true, "code": "ok"}
|
|
|
|
|
|
func _apply_casualty(recruit_id: String) -> Dictionary:
|
|
if recruit_id not in state.current_run.company or not state.current_run.recruit_records.has(recruit_id):
|
|
return {}
|
|
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():
|
|
if not state.current_run.inventory.has(instance_id) or String(state.current_run.inventory[instance_id].wearer_id) != recruit_id:
|
|
return {}
|
|
state.current_run.inventory[instance_id].wearer_id = ""
|
|
var grant := death_credit(int(record.get("original_list_price", 14)), int(record.get("coin_price_paid", 0)))
|
|
var credit_before := int(state.current_run.replacement_credit)
|
|
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),
|
|
"original_list_price": int(record.get("original_list_price", 14)),
|
|
"coin_price_paid": int(record.get("coin_price_paid", 0)),
|
|
"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 _battle_xp(round_index: int, risk: int, outcome: String, has_survivor: bool) -> int:
|
|
var base := battle_xp(round_index, risk)
|
|
if outcome == "victory":
|
|
return base
|
|
if outcome in ["timeout", "retreat"] and has_survivor:
|
|
return int(floor(float(base) * 0.5))
|
|
return 0
|
|
|
|
|
|
func _battle_coin(round_index: int, risk: int, difficulty: String) -> int:
|
|
var multiplier := 1.15 if difficulty == "iron_oath" else 1.0
|
|
return int(floor(float(18 + 2 * round_index + 7 * risk) * multiplier))
|
|
|
|
|
|
func _failure_fee(difficulty: String, outcome: String, coin: int) -> int:
|
|
var base: int = int({
|
|
"wayfinder": {"defeat": 0, "timeout": 2, "retreat": 4},
|
|
"standard": {"defeat": 2, "timeout": 4, "retreat": 6},
|
|
"stormbound": {"defeat": 3, "timeout": 5, "retreat": 7},
|
|
"iron_oath": {"defeat": 4, "timeout": 6, "retreat": 8},
|
|
}.get(difficulty, {}).get(outcome, 0))
|
|
return mini(coin, int(base))
|
|
|
|
|
|
func _open_generated_market() -> bool:
|
|
var generated: Dictionary = generator.market_board(String(state.current_run.run_seed), String(state.current_run.contract_id), int(state.current_run.market_index), 0)
|
|
if not generated.ok:
|
|
return false
|
|
var board := _priced_board(generated.board)
|
|
state.current_run.market_board = board
|
|
state.current_run.refresh_count = 0
|
|
state.current_run.locks = {}
|
|
state.current_run.available_recruit_offer_ids = _offer_ids(board.recruit_offers, "source_offer_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")
|
|
return true
|
|
|
|
|
|
func _reopen_stable_market(consumed_offer_id: String) -> void:
|
|
state.current_run.available_battle_offer_ids.erase(consumed_offer_id)
|
|
|
|
|
|
func _install_protected_recovery_offer(round_index: int) -> void:
|
|
var board: Dictionary = state.current_run.market_board
|
|
var protected := _recovery_recruit(round_index)
|
|
var slot := 0
|
|
for index in range(board.recruit_offers.size()):
|
|
var candidate_id := String(board.recruit_offers[index].source_offer_id)
|
|
if candidate_id not in state.current_run.locks:
|
|
slot = index
|
|
break
|
|
var replaced_id := String(board.recruit_offers[slot].source_offer_id)
|
|
state.current_run.available_recruit_offer_ids.erase(replaced_id)
|
|
state.current_run.locks.erase(replaced_id)
|
|
board.recruit_offers[slot] = protected
|
|
board.erase("board_hash")
|
|
board.board_hash = _hash(board)
|
|
state.current_run.market_board = board
|
|
state.current_run.available_recruit_offer_ids.append(String(protected.source_offer_id))
|
|
state.current_run.recovery_required = true
|
|
state.current_run.protected_recovery_offer_id = String(protected.source_offer_id)
|
|
|
|
|
|
func _recovery_recruit(round_index: int) -> Dictionary:
|
|
var selected: Dictionary = {}
|
|
for refresh_index in range(64):
|
|
var generated: Dictionary = generator.market_board(String(state.current_run.run_seed), String(state.current_run.contract_id), int(state.current_run.market_index), refresh_index)
|
|
for offer in generated.board.recruit_offers:
|
|
if _fills_missing_responsibility(String(offer.profession_id)):
|
|
selected = Dictionary(offer).duplicate(true)
|
|
break
|
|
if not selected.is_empty():
|
|
break
|
|
if selected.is_empty():
|
|
selected = Dictionary(generator.market_board(String(state.current_run.run_seed), String(state.current_run.contract_id), int(state.current_run.market_index), 0).board.recruit_offers[0]).duplicate(true)
|
|
var offer_level := maxi(1, mini(10, 1 + int(floor(float(int(state.current_run.market_index) - 1) / 3.0))) - 1)
|
|
var generated_level := int(selected.level)
|
|
var old_max_hp := int(selected.max_hp)
|
|
selected.level = offer_level
|
|
selected.xp = 0
|
|
selected.max_hp = maxi(1, old_max_hp - 4 * maxi(0, generated_level - offer_level))
|
|
selected.current_hp = int(selected.max_hp)
|
|
selected.source_offer_id = _low128(_digest(String(state.current_run.run_seed), "protected-recovery", [state.current_run.contract_id, round_index, state.current_run.recruits_fallen_total]))
|
|
selected.source_draft_id = ""
|
|
selected.market_id = "RECOVERY-%s-%02d" % [state.current_run.contract_id, int(state.current_run.market_index)]
|
|
selected.offer_level = offer_level
|
|
selected.profession_tier = 0
|
|
selected.list_price = MarketService.hire_list_price(offer_level, 0)
|
|
selected.recovery_offer = true
|
|
selected.erase("canonical_output_hash")
|
|
selected.canonical_output_hash = _hash(selected)
|
|
return selected
|
|
|
|
|
|
func _fills_missing_responsibility(profession_id: String) -> bool:
|
|
var coverage := {"protection": false, "damage": false, "reach": false, "sustain": false}
|
|
for recruit_id in state.current_run.company:
|
|
var tags: Array = Content.rows_by_id(catalog, "professions").get(String(state.current_run.recruit_records[recruit_id].profession_id), {}).get("semantic_tags", [])
|
|
_apply_coverage(tags, coverage)
|
|
var missing := ""
|
|
for key in ["protection", "damage", "reach", "sustain"]:
|
|
if not bool(coverage[key]):
|
|
missing = key
|
|
break
|
|
if missing.is_empty():
|
|
return true
|
|
var candidate_tags: Array = Content.rows_by_id(catalog, "professions").get(profession_id, {}).get("semantic_tags", [])
|
|
var candidate := {"protection": false, "damage": false, "reach": false, "sustain": false}
|
|
_apply_coverage(candidate_tags, candidate)
|
|
return bool(candidate[missing])
|
|
|
|
|
|
func _apply_coverage(tags: Array, coverage: Dictionary) -> void:
|
|
coverage.protection = bool(coverage.protection) or "front_hold" in tags or "enable" in tags
|
|
coverage.damage = bool(coverage.damage) or "single_pressure" in tags or "projectile" in tags or "area" in tags or "construct" in tags
|
|
coverage.reach = bool(coverage.reach) or "reach_control" in tags or "projectile" in tags or "area" in tags or "impair" in tags
|
|
coverage.sustain = bool(coverage.sustain) or "restore" in tags or "supply" in tags
|
|
|
|
|
|
func _reward_draft(battle_hash: String, round_index: int, risk: int) -> Dictionary:
|
|
var reward_id := _low128(_digest(String(state.current_run.run_seed), "reward-draft", [state.current_run.contract_id, round_index, risk, battle_hash]))
|
|
var budget := reward_budget(round_index, risk)
|
|
var equipment_ids: Array[String] = []
|
|
equipment_ids.assign(Content.rows_by_id(catalog, "equipment").keys())
|
|
equipment_ids.sort_custom(func(left: String, right: String): return _digest(reward_id, "equipment", [left]) < _digest(reward_id, "equipment", [right]))
|
|
var cards: Array[Dictionary] = []
|
|
var equipment_count := 2 if risk == 1 else 1
|
|
for index in range(equipment_count):
|
|
var catalog_id := equipment_ids[index]
|
|
cards.append({
|
|
"card_id": _low128(_digest(reward_id, "card", [index, catalog_id])),
|
|
"kind": "equipment",
|
|
"catalog_id": catalog_id,
|
|
"instance_id": _low128(_digest(reward_id, "equipment-instance", [catalog_id])),
|
|
})
|
|
if risk == 2:
|
|
var artifact_options: Array[String] = []
|
|
artifact_options.assign(IMPLEMENTED_ARTIFACT_IDS)
|
|
artifact_options.sort_custom(func(left: String, right: String): return _digest(reward_id, "artifact", [left]) < _digest(reward_id, "artifact", [right]))
|
|
cards.insert(0, {
|
|
"card_id": _low128(_digest(reward_id, "card", ["artifact"])),
|
|
"kind": "artifact",
|
|
"artifact_ids": artifact_options,
|
|
})
|
|
cards.append({
|
|
"card_id": _low128(_digest(reward_id, "card", ["credit"])),
|
|
"kind": "credit",
|
|
"amount": int(floor(float(budget) * 0.5)),
|
|
})
|
|
else:
|
|
cards.append({
|
|
"card_id": _low128(_digest(reward_id, "card", ["coin"])),
|
|
"kind": "coin",
|
|
"amount": int(floor(float(budget) * 0.5)),
|
|
})
|
|
var draft := {
|
|
"reward_id": reward_id,
|
|
"battle_hash": battle_hash,
|
|
"round": round_index,
|
|
"risk": risk,
|
|
"budget": budget,
|
|
"cards": cards,
|
|
}
|
|
var normalized_draft: Dictionary = JSON.parse_string(JSON.stringify(draft))
|
|
normalized_draft["draft_hash"] = _hash(normalized_draft)
|
|
return normalized_draft
|
|
|
|
|
|
func _activate_artifact(card: Dictionary, artifact_id: String, replace_artifact_id: String) -> Dictionary:
|
|
if artifact_id not in card.artifact_ids or artifact_id not in IMPLEMENTED_ARTIFACT_IDS:
|
|
return {"ok": false, "code": "artifact_unavailable"}
|
|
if artifact_id in state.current_run.active_artifact_ids:
|
|
return {"ok": false, "code": "artifact_duplicate"}
|
|
var active: Array = state.current_run.active_artifact_ids
|
|
var conflict := String(ARTIFACT_CONFLICTS.get(artifact_id, ""))
|
|
var required_replacement := conflict if conflict in active else ""
|
|
if active.size() >= 3 and required_replacement.is_empty():
|
|
required_replacement = replace_artifact_id
|
|
if not required_replacement.is_empty():
|
|
if replace_artifact_id != required_replacement or replace_artifact_id not in active:
|
|
return {"ok": false, "code": "artifact_replacement_required"}
|
|
active.erase(replace_artifact_id)
|
|
state.current_run.artifact_rule_state.erase(replace_artifact_id)
|
|
elif not replace_artifact_id.is_empty():
|
|
return {"ok": false, "code": "artifact_replacement_unexpected"}
|
|
active.append(artifact_id)
|
|
state.current_run.artifact_rule_state[artifact_id] = {
|
|
"activated_market_index": int(state.current_run.market_index),
|
|
"uses_by_market": {},
|
|
"rule": _artifact_rule(artifact_id),
|
|
}
|
|
if artifact_id == "AR-003":
|
|
state.current_run.market_board = _priced_board(state.current_run.market_board)
|
|
return {"ok": true, "code": "ok", "receipt": {"artifact_id": artifact_id, "replaced_artifact_id": replace_artifact_id, "rule": _artifact_rule(artifact_id)}}
|
|
|
|
|
|
func _artifact_rule(artifact_id: String) -> Dictionary:
|
|
return {
|
|
"AR-003": {"kind": "recruit_price_exchange", "common_discount": 4, "premium_surcharge": 4},
|
|
"AR-005": {"kind": "presentation_redraw", "uses_per_market": 1, "combat_record_locked": true},
|
|
"AR-006": {"kind": "trait_redraw", "uses_per_market": 1, "profession_grades_level_gear_locked": true},
|
|
}.get(artifact_id, {})
|
|
|
|
|
|
func _priced_board(raw: Dictionary) -> Dictionary:
|
|
var board := raw.duplicate(true)
|
|
var offer_level := mini(10, 1 + int(floor(float(int(board.market_index) - 1) / 3.0)))
|
|
var first_common_discounted := false
|
|
for offer in board.recruit_offers:
|
|
var profession_tier := 1 if String(offer.profession_id).begins_with("PF-A") else 0
|
|
var priced_level := int(offer.get("offer_level", offer_level)) if bool(offer.get("recovery_offer", false)) else offer_level
|
|
offer.profession_tier = profession_tier
|
|
offer.offer_level = priced_level
|
|
offer.list_price = MarketService.hire_list_price(priced_level, profession_tier)
|
|
if "AR-003" in state.current_run.get("active_artifact_ids", []):
|
|
if String(offer.quality_band) == "Common" and not first_common_discounted:
|
|
offer.list_price = maxi(0, int(offer.list_price) - 4)
|
|
first_common_discounted = true
|
|
elif String(offer.quality_band) in ["Uncommon", "Rare"]:
|
|
offer.list_price = int(offer.list_price) + 4
|
|
for offer in board.equipment_offers:
|
|
var tier := clampi(int((int(String(offer.catalog_id).trim_prefix("EQ-")) - 1) / 10), 0, 3)
|
|
offer.item_tier = tier
|
|
offer.region_band = 0
|
|
offer.list_price = MarketService.equipment_list_price(0, tier)
|
|
board.erase("board_hash")
|
|
board.board_hash = _hash(board)
|
|
return board
|
|
|
|
|
|
func _reward_guard(expected_revision: int, expected_draft_hash: String) -> String:
|
|
if not valid:
|
|
return error_code
|
|
if expected_revision != state.revision:
|
|
return "stale_revision"
|
|
if state.current_run.is_empty() or String(state.current_run.get("phase", "")) != "market":
|
|
return "market_required"
|
|
if state.current_run.pending_reward.is_empty():
|
|
return "reward_required"
|
|
if String(state.current_run.pending_reward.get("draft_hash", "")) != expected_draft_hash:
|
|
return "reward_hash_mismatch"
|
|
return ""
|
|
|
|
|
|
func _reward_card(card_id: String) -> Dictionary:
|
|
for card in state.current_run.pending_reward.get("cards", []):
|
|
if String(card.get("card_id", "")) == card_id:
|
|
return Dictionary(card).duplicate(true)
|
|
return {}
|
|
|
|
|
|
func _regular_descendants(profession_id: String) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for row in catalog.professions:
|
|
if String(row.get("family", "")) == "advanced" and String(row.get("parent_profession_id", "")) == profession_id:
|
|
result.append(String(row.id))
|
|
result.sort()
|
|
return result
|
|
|
|
|
|
func _surviving_order(previous_order: Array) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for recruit_id in previous_order:
|
|
if recruit_id in state.current_run.company:
|
|
result.append(String(recruit_id))
|
|
return result
|
|
|
|
|
|
func _remaining_reserve() -> Array[String]:
|
|
var result: Array[String] = []
|
|
for recruit_id in state.current_run.company:
|
|
if recruit_id not in state.current_run.deployed_recruit_ids:
|
|
result.append(String(recruit_id))
|
|
return result
|
|
|
|
|
|
func _automatic_order(recruit_ids: Array, records: Dictionary, capacity: int) -> Array[String]:
|
|
var result: Array[String] = []
|
|
result.assign(recruit_ids)
|
|
result.sort_custom(func(left: String, right: String):
|
|
var left_base := String(records[left].get("base_profession_id", records[left].profession_id))
|
|
var right_base := String(records[right].get("base_profession_id", records[right].profession_id))
|
|
var left_rank := int(ROLE_ORDER.get(left_base, 99))
|
|
var right_rank := int(ROLE_ORDER.get(right_base, 99))
|
|
return left_rank < right_rank if left_rank != right_rank else left < right
|
|
)
|
|
result.resize(mini(capacity, result.size()))
|
|
return result
|
|
|
|
|
|
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.intent_hash) != _hash({"kind": kind, "intent": intent}):
|
|
return {"known": true, "result": _reject("operation_id_conflict")}
|
|
return {"known": true, "result": {"ok": true, "code": "already_applied", "duplicate": true, "applied": false, "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.operation_receipts[operation_id] = {
|
|
"kind": kind,
|
|
"intent_hash": _hash({"kind": kind, "intent": intent}),
|
|
"status": "applied",
|
|
"receipt": receipt.duplicate(true),
|
|
}
|
|
state.revision += 1
|
|
if not state.invariants_hold():
|
|
state.restore_snapshot(before)
|
|
return _reject("invariant_failure")
|
|
return {"ok": true, "code": code, "duplicate": false, "applied": true, "receipt": receipt.duplicate(true), "snapshot": state.snapshot()}
|
|
|
|
|
|
func _query(code: String, receipt: Dictionary) -> Dictionary:
|
|
return {"ok": true, "code": code, "duplicate": false, "applied": false, "receipt": receipt.duplicate(true), "snapshot": state.snapshot()}
|
|
|
|
|
|
func _reject(code: String) -> Dictionary:
|
|
return {"ok": false, "code": code, "duplicate": false, "applied": false, "receipt": {}, "snapshot": state.snapshot()}
|
|
|
|
|
|
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 _subset(values: Array, authority: Array) -> bool:
|
|
for value in values:
|
|
if value not in authority:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _unique(values: Array) -> bool:
|
|
var seen := {}
|
|
for value in values:
|
|
if seen.has(value):
|
|
return false
|
|
seen[value] = true
|
|
return true
|
|
|
|
|
|
func _digest(seed: String, label: String, parts: Array = []) -> String:
|
|
var framed: Array[String] = ["%d:%s" % [seed.length(), seed], "%d:%s" % [label.length(), label]]
|
|
for part in parts:
|
|
var value := str(part)
|
|
framed.append("%d:%s" % [value.length(), value])
|
|
return "|".join(framed).sha256_text()
|
|
|
|
|
|
func _low128(value: String) -> String:
|
|
return value.substr(0, 32)
|
|
|
|
|
|
func _hash(value: Variant) -> String:
|
|
return JSON.stringify(_canonical(value)).sha256_text()
|
|
|
|
|
|
func _canonical(value: Variant) -> Variant:
|
|
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
|