533 lines
19 KiB
GDScript
533 lines
19 KiB
GDScript
class_name P102RunState
|
|
extends RefCounted
|
|
|
|
const P101State = preload("res://p10_1/region_run_state.gd")
|
|
|
|
const STATE_KIND := "p102_siltwake_mid_contract"
|
|
const RULESET_VERSION := "p10.2-siltwake-mid-contract-r1"
|
|
|
|
var state_kind := STATE_KIND
|
|
var ruleset := RULESET_VERSION
|
|
var revision := 0
|
|
var lineage_id := ""
|
|
var generation_root_seed := ""
|
|
var next_region_run_ordinal := 0
|
|
var migration_source_hash := ""
|
|
var migration_source_revision := -1
|
|
var guild_profile: Dictionary = {}
|
|
var current_run: Dictionary = {}
|
|
var operation_receipts: Dictionary = {}
|
|
var legacy_operation_receipts: Dictionary = {}
|
|
var legacy_entry_commits: Dictionary = {}
|
|
var battle_checkpoints: Dictionary = {}
|
|
|
|
|
|
func initialize_from_p101(p101_snapshot: Dictionary, market_board: Dictionary) -> bool:
|
|
var probe := P101State.new()
|
|
if not probe.restore_snapshot(p101_snapshot):
|
|
return false
|
|
var source := probe.snapshot()
|
|
var previous_run: Dictionary = source.current_run
|
|
if (
|
|
previous_run.is_empty()
|
|
or String(previous_run.phase) != "market_2"
|
|
or int(previous_run.market_index) != 2
|
|
or previous_run.get("market_2_handoff", {}).is_empty()
|
|
or String(market_board.get("run_seed", "")) != String(previous_run.run_seed)
|
|
or int(market_board.get("market_index", -1)) != 2
|
|
or int(market_board.get("refresh_index", -1)) != 0
|
|
):
|
|
return false
|
|
|
|
state_kind = STATE_KIND
|
|
ruleset = RULESET_VERSION
|
|
revision = 0
|
|
lineage_id = String(source.lineage_id)
|
|
generation_root_seed = String(source.generation_root_seed)
|
|
next_region_run_ordinal = int(source.next_region_run_ordinal)
|
|
migration_source_hash = _hash(source)
|
|
migration_source_revision = int(source.revision)
|
|
guild_profile = Dictionary(source.guild_profile).duplicate(true)
|
|
operation_receipts = {}
|
|
legacy_operation_receipts = Dictionary(source.operation_receipts).duplicate(true)
|
|
legacy_entry_commits = Dictionary(source.entry_commits).duplicate(true)
|
|
battle_checkpoints = {}
|
|
|
|
var company: Array = Array(previous_run.company).duplicate()
|
|
var deployed: Array = Array(previous_run.deployed_recruit_ids).duplicate()
|
|
var reserve: Array = Array(previous_run.reserve_recruit_ids).duplicate()
|
|
var handoff: Dictionary = Dictionary(previous_run.market_2_handoff).duplicate(true)
|
|
var last_report: Dictionary = Dictionary(previous_run.last_battle_report).duplicate(true)
|
|
current_run = {
|
|
"run_id": String(previous_run.run_id),
|
|
"region_id": "RG-02",
|
|
"contract_id": "CTR-R2-01",
|
|
"difficulty": String(previous_run.difficulty),
|
|
"run_seed": String(previous_run.run_seed),
|
|
"status": "active",
|
|
"phase": "market",
|
|
"market_index": 2,
|
|
"round_index": 2,
|
|
"cleared_rounds": 1,
|
|
"coin": int(previous_run.coin),
|
|
"replacement_credit": 0,
|
|
"refresh_count": 0,
|
|
"line_capacity": 4,
|
|
"market_board": market_board.duplicate(true),
|
|
"available_recruit_offer_ids": _offer_ids(market_board.recruit_offers, "source_offer_id"),
|
|
"available_equipment_offer_ids": _offer_ids(market_board.equipment_offers, "offer_id"),
|
|
"available_battle_offer_ids": _offer_ids(market_board.battle_offers, "offer_id"),
|
|
"locked_offer_ids": [],
|
|
"company": company,
|
|
"deployed_recruit_ids": deployed,
|
|
"reserve_recruit_ids": reserve,
|
|
"recruit_records": Dictionary(previous_run.recruit_records).duplicate(true),
|
|
"inventory": Dictionary(previous_run.inventory).duplicate(true),
|
|
"equipped_by_recruit": Dictionary(previous_run.equipped_by_recruit).duplicate(true),
|
|
"formation_proposal": deployed.duplicate(),
|
|
"selected_route_id": "",
|
|
"pending_encounter": {},
|
|
"pre_battle_checkpoint_id": "",
|
|
"recovery_required": false,
|
|
"recovery_cost": 0,
|
|
"last_battle_report": last_report,
|
|
"first_crossing_handoff": handoff,
|
|
"route_history": [{
|
|
"round_index": 1,
|
|
"route_id": String(last_report.get("route_id", "")),
|
|
"plan_id": String(last_report.get("plan_id", "")),
|
|
"outcome": String(last_report.get("outcome", "")),
|
|
"battle_hash": String(handoff.get("source_battle_hash", "")),
|
|
"reward_coin": int(handoff.get("reward_coin", 0)),
|
|
}],
|
|
"recruits_hired_total": company.size(),
|
|
"recruits_fallen_total": 0,
|
|
"recruits_dismissed_total": 0,
|
|
"reed_ferry_restored": false,
|
|
}
|
|
return invariants_hold()
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
var raw := {
|
|
"state_kind": state_kind,
|
|
"ruleset": ruleset,
|
|
"revision": revision,
|
|
"lineage_id": lineage_id,
|
|
"generation_root_seed": generation_root_seed,
|
|
"next_region_run_ordinal": next_region_run_ordinal,
|
|
"migration_source_hash": migration_source_hash,
|
|
"migration_source_revision": migration_source_revision,
|
|
"guild_profile": guild_profile.duplicate(true),
|
|
"current_run": current_run.duplicate(true),
|
|
"operation_receipts": operation_receipts.duplicate(true),
|
|
"legacy_operation_receipts": legacy_operation_receipts.duplicate(true),
|
|
"legacy_entry_commits": legacy_entry_commits.duplicate(true),
|
|
"battle_checkpoints": battle_checkpoints.duplicate(true),
|
|
}
|
|
var normalized = JSON.parse_string(JSON.stringify(raw))
|
|
return Dictionary(normalized).duplicate(true) if normalized is Dictionary else {}
|
|
|
|
|
|
func restore_snapshot(raw: Dictionary) -> bool:
|
|
var normalized_value = JSON.parse_string(JSON.stringify(raw))
|
|
if not normalized_value is Dictionary:
|
|
return false
|
|
var normalized: Dictionary = normalized_value
|
|
var required := [
|
|
"state_kind", "ruleset", "revision", "lineage_id",
|
|
"generation_root_seed", "next_region_run_ordinal",
|
|
"migration_source_hash", "migration_source_revision", "guild_profile",
|
|
"current_run", "operation_receipts", "legacy_operation_receipts",
|
|
"legacy_entry_commits", "battle_checkpoints",
|
|
]
|
|
for key in required:
|
|
if not normalized.has(key):
|
|
return false
|
|
for key in [
|
|
"guild_profile", "current_run", "operation_receipts",
|
|
"legacy_operation_receipts", "legacy_entry_commits", "battle_checkpoints",
|
|
]:
|
|
if not normalized[key] is Dictionary:
|
|
return false
|
|
var before := snapshot()
|
|
_assign(normalized)
|
|
if not invariants_hold():
|
|
if not before.is_empty():
|
|
_assign(before)
|
|
return false
|
|
return true
|
|
|
|
|
|
func invariants_hold() -> bool:
|
|
if state_kind != STATE_KIND or ruleset != RULESET_VERSION or revision < 0:
|
|
return false
|
|
if not _is_hex(lineage_id, 32) or not _is_hex(generation_root_seed, 32):
|
|
return false
|
|
if next_region_run_ordinal < 0 or not _is_hex(migration_source_hash, 64) or migration_source_revision < 0:
|
|
return false
|
|
if not _guild_invariants_hold() or not _receipts_hold() or not _checkpoints_hold():
|
|
return false
|
|
return _run_invariants_hold()
|
|
|
|
|
|
func _guild_invariants_hold() -> bool:
|
|
for key in [
|
|
"persistent_renown", "contract_clear_ids", "discovery_ids",
|
|
"company_records", "unlocked_region_ids", "clear_receipts",
|
|
]:
|
|
if not guild_profile.has(key):
|
|
return false
|
|
if not guild_profile.clear_receipts is Dictionary:
|
|
return false
|
|
return (
|
|
"RG-02" in guild_profile.unlocked_region_ids
|
|
and "CLEAR-RG01-C01" in guild_profile.contract_clear_ids
|
|
and "CLEAR-RG01-C02" in guild_profile.contract_clear_ids
|
|
and _unique(guild_profile.unlocked_region_ids)
|
|
and _unique(guild_profile.contract_clear_ids)
|
|
)
|
|
|
|
|
|
func _run_invariants_hold() -> bool:
|
|
var required := [
|
|
"run_id", "region_id", "contract_id", "difficulty", "run_seed",
|
|
"status", "phase", "market_index", "round_index", "cleared_rounds",
|
|
"coin", "replacement_credit", "refresh_count", "line_capacity",
|
|
"market_board", "available_recruit_offer_ids",
|
|
"available_equipment_offer_ids", "available_battle_offer_ids",
|
|
"locked_offer_ids", "company", "deployed_recruit_ids",
|
|
"reserve_recruit_ids", "recruit_records", "inventory",
|
|
"equipped_by_recruit", "formation_proposal", "selected_route_id",
|
|
"pending_encounter", "pre_battle_checkpoint_id", "recovery_required",
|
|
"recovery_cost", "last_battle_report", "first_crossing_handoff",
|
|
"route_history", "recruits_hired_total", "recruits_fallen_total",
|
|
"recruits_dismissed_total", "reed_ferry_restored",
|
|
]
|
|
for key in required:
|
|
if not current_run.has(key):
|
|
return false
|
|
for key in [
|
|
"market_board", "recruit_records", "inventory", "equipped_by_recruit",
|
|
"pending_encounter", "last_battle_report", "first_crossing_handoff",
|
|
]:
|
|
if not current_run[key] is Dictionary:
|
|
return false
|
|
for key in [
|
|
"available_recruit_offer_ids", "available_equipment_offer_ids",
|
|
"available_battle_offer_ids", "locked_offer_ids", "company",
|
|
"deployed_recruit_ids", "reserve_recruit_ids", "formation_proposal",
|
|
"route_history",
|
|
]:
|
|
if not current_run[key] is Array:
|
|
return false
|
|
if not _is_hex(String(current_run.run_id), 32) or not _is_hex(String(current_run.run_seed), 32):
|
|
return false
|
|
if String(current_run.region_id) != "RG-02" or String(current_run.contract_id) != "CTR-R2-01":
|
|
return false
|
|
if String(current_run.status) != "active" or String(current_run.phase) not in ["market", "prepared", "recovery", "market_5"]:
|
|
return false
|
|
var market_index := int(current_run.market_index)
|
|
var cleared_rounds := int(current_run.cleared_rounds)
|
|
if market_index < 2 or market_index > 5 or int(current_run.round_index) != market_index:
|
|
return false
|
|
if cleared_rounds != market_index - 1:
|
|
return false
|
|
if int(current_run.coin) < 0 or int(current_run.replacement_credit) < 0 or int(current_run.refresh_count) < 0:
|
|
return false
|
|
var expected_capacity := mini(10, 4 + floori(float(cleared_rounds) / 2.0))
|
|
if int(current_run.line_capacity) != expected_capacity:
|
|
return false
|
|
if bool(current_run.reed_ferry_restored) != (market_index == 5):
|
|
return false
|
|
if int(current_run.recruits_hired_total) < current_run.company.size():
|
|
return false
|
|
if int(current_run.recruits_fallen_total) < 0 or int(current_run.recruits_dismissed_total) < 0:
|
|
return false
|
|
if current_run.first_crossing_handoff.is_empty() or current_run.route_history.size() != cleared_rounds:
|
|
return false
|
|
if String(current_run.route_history[0].get("battle_hash", "")) != String(current_run.first_crossing_handoff.get("source_battle_hash", "")):
|
|
return false
|
|
if not _board_invariants_hold():
|
|
return false
|
|
if not _company_invariants_hold():
|
|
return false
|
|
var phase := String(current_run.phase)
|
|
if phase == "market":
|
|
return (
|
|
market_index >= 2
|
|
and market_index <= 4
|
|
and String(current_run.selected_route_id).is_empty()
|
|
and current_run.pending_encounter.is_empty()
|
|
and String(current_run.pre_battle_checkpoint_id).is_empty()
|
|
and not bool(current_run.recovery_required)
|
|
and int(current_run.recovery_cost) == 0
|
|
)
|
|
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
|
|
if phase == "prepared":
|
|
return (
|
|
market_index >= 2
|
|
and market_index <= 4
|
|
and String(current_run.selected_route_id) in current_run.available_battle_offer_ids
|
|
and not current_run.pending_encounter.is_empty()
|
|
and String(current_run.pending_encounter.get("offer_id", "")) == String(current_run.selected_route_id)
|
|
and checkpoint_id in battle_checkpoints
|
|
and not current_run.deployed_recruit_ids.is_empty()
|
|
)
|
|
if phase == "recovery":
|
|
return (
|
|
market_index >= 2
|
|
and market_index <= 4
|
|
and bool(current_run.recovery_required)
|
|
and int(current_run.recovery_cost) > 0
|
|
and String(current_run.selected_route_id).is_empty()
|
|
and current_run.pending_encounter.is_empty()
|
|
and checkpoint_id in battle_checkpoints
|
|
and not current_run.last_battle_report.is_empty()
|
|
)
|
|
return (
|
|
market_index == 5
|
|
and String(current_run.selected_route_id).is_empty()
|
|
and current_run.pending_encounter.is_empty()
|
|
and String(current_run.pre_battle_checkpoint_id).is_empty()
|
|
and not bool(current_run.recovery_required)
|
|
and int(current_run.recovery_cost) == 0
|
|
)
|
|
|
|
|
|
func _board_invariants_hold() -> bool:
|
|
var board: Dictionary = current_run.market_board
|
|
if int(current_run.market_index) == 5:
|
|
for key in [
|
|
"handoff_kind", "board_hash", "run_seed", "contract_id", "region_id",
|
|
"market_index", "cleared_rounds", "line_capacity",
|
|
"source_settlement_hash", "carried_lock_ids",
|
|
]:
|
|
if not board.has(key):
|
|
return false
|
|
if (
|
|
String(board.handoff_kind) != "p10.2.market_5_continuation"
|
|
or String(board.run_seed) != String(current_run.run_seed)
|
|
or int(board.market_index) != 5
|
|
or int(board.cleared_rounds) != 4
|
|
or int(board.line_capacity) != 6
|
|
):
|
|
return false
|
|
var market_5_hash := String(board.board_hash)
|
|
var market_5_candidate := board.duplicate(true)
|
|
market_5_candidate.erase("board_hash")
|
|
return (
|
|
market_5_hash == _hash(market_5_candidate)
|
|
and current_run.available_recruit_offer_ids.is_empty()
|
|
and current_run.available_equipment_offer_ids.is_empty()
|
|
and current_run.available_battle_offer_ids.is_empty()
|
|
and current_run.locked_offer_ids.is_empty()
|
|
)
|
|
for key in [
|
|
"board_id", "board_hash", "run_seed", "contract_id", "region_id",
|
|
"market_index", "round_index", "cleared_rounds_before", "line_capacity",
|
|
"offer_level", "refresh_index", "recruit_offers", "equipment_offers",
|
|
"battle_offers", "tide_disclosure", "generation_authority",
|
|
]:
|
|
if not board.has(key):
|
|
return false
|
|
if (
|
|
String(board.run_seed) != String(current_run.run_seed)
|
|
or int(board.market_index) != int(current_run.market_index)
|
|
or int(board.round_index) != int(current_run.round_index)
|
|
or int(board.refresh_index) != int(current_run.refresh_count)
|
|
or int(board.line_capacity) != int(current_run.line_capacity)
|
|
):
|
|
return false
|
|
if not board.recruit_offers is Array or board.recruit_offers.size() != 3:
|
|
return false
|
|
if not board.equipment_offers is Array or board.equipment_offers.size() != 4:
|
|
return false
|
|
if not board.battle_offers is Array or board.battle_offers.size() != 3:
|
|
return false
|
|
var recruit_ids := _offer_ids(board.recruit_offers, "source_offer_id")
|
|
var equipment_ids := _offer_ids(board.equipment_offers, "offer_id")
|
|
var battle_ids := _offer_ids(board.battle_offers, "offer_id")
|
|
if (
|
|
not _subset(current_run.available_recruit_offer_ids, recruit_ids)
|
|
or not _subset(current_run.available_equipment_offer_ids, equipment_ids)
|
|
or not _subset(current_run.available_battle_offer_ids, battle_ids)
|
|
):
|
|
return false
|
|
var lock_authority: Array = (
|
|
Array(current_run.available_recruit_offer_ids).duplicate()
|
|
+ Array(current_run.available_equipment_offer_ids).duplicate()
|
|
)
|
|
return (
|
|
_unique(current_run.locked_offer_ids)
|
|
and current_run.locked_offer_ids.size() <= 3
|
|
and _subset(current_run.locked_offer_ids, lock_authority)
|
|
)
|
|
|
|
|
|
func _company_invariants_hold() -> bool:
|
|
var company: Array = current_run.company
|
|
var deployed: Array = current_run.deployed_recruit_ids
|
|
var reserve: Array = current_run.reserve_recruit_ids
|
|
if not _unique(company) or not _unique(deployed) or not _unique(reserve):
|
|
return false
|
|
if company.is_empty() or deployed.is_empty() or deployed.size() > int(current_run.line_capacity):
|
|
return false
|
|
if not _subset(deployed, company) or not _subset(reserve, company):
|
|
return false
|
|
if not _same_members(deployed + reserve, company) or not _disjoint(deployed, reserve):
|
|
return false
|
|
if current_run.recruit_records.size() != company.size() or current_run.equipped_by_recruit.size() != company.size():
|
|
return false
|
|
for recruit_id in company:
|
|
if not current_run.recruit_records.has(recruit_id) or not current_run.equipped_by_recruit.has(recruit_id):
|
|
return false
|
|
var record: Dictionary = current_run.recruit_records[recruit_id]
|
|
if (
|
|
String(record.get("state", "")) != "living"
|
|
or int(record.get("level", 0)) < 1
|
|
or int(record.get("level", 0)) > 10
|
|
or int(record.get("xp", -1)) < 0
|
|
or int(record.get("current_hp", 0)) < 1
|
|
or int(record.get("current_hp", 0)) > int(record.get("max_hp", 0))
|
|
or int(record.get("survived_battles", -1)) < 0
|
|
):
|
|
return false
|
|
var worn := {}
|
|
for instance_id in current_run.inventory:
|
|
var item = current_run.inventory[instance_id]
|
|
if not item is Dictionary or String(item.get("instance_id", "")) != instance_id:
|
|
return false
|
|
var wearer := String(item.get("wearer_id", ""))
|
|
if not wearer.is_empty():
|
|
if wearer not in company or worn.has(wearer):
|
|
return false
|
|
if String(current_run.equipped_by_recruit.get(wearer, "")) != instance_id:
|
|
return false
|
|
worn[wearer] = instance_id
|
|
for recruit_id in current_run.equipped_by_recruit:
|
|
var equipped_id := String(current_run.equipped_by_recruit[recruit_id])
|
|
if not equipped_id.is_empty():
|
|
if not current_run.inventory.has(equipped_id):
|
|
return false
|
|
if String(current_run.inventory[equipped_id].wearer_id) != recruit_id:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _receipts_hold() -> bool:
|
|
for operation_id in operation_receipts:
|
|
var row = operation_receipts[operation_id]
|
|
if not row is Dictionary or String(row.get("operation_id", "")) != operation_id:
|
|
return false
|
|
if (
|
|
String(row.get("kind", "")).is_empty()
|
|
or not _is_hex(String(row.get("intent_hash", "")), 64)
|
|
or String(row.get("status", "")) != "applied"
|
|
or String(row.get("code", "")).is_empty()
|
|
or int(row.get("revision", -1)) < 1
|
|
or int(row.get("revision", -1)) > revision
|
|
or not row.get("receipt", {}) is Dictionary
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _checkpoints_hold() -> bool:
|
|
for checkpoint_id in battle_checkpoints:
|
|
var row = battle_checkpoints[checkpoint_id]
|
|
if not row is Dictionary or String(row.get("checkpoint_id", "")) != checkpoint_id:
|
|
return false
|
|
if not _is_hex(checkpoint_id, 32) or not row.get("run", {}) is Dictionary:
|
|
return false
|
|
if not _is_hex(String(row.get("checkpoint_hash", "")), 64):
|
|
return false
|
|
var candidate: Dictionary = row.duplicate(true)
|
|
var stored_hash := String(candidate.checkpoint_hash)
|
|
candidate.erase("checkpoint_hash")
|
|
if _hash(candidate) != stored_hash:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _assign(raw: Dictionary) -> void:
|
|
state_kind = String(raw.state_kind)
|
|
ruleset = String(raw.ruleset)
|
|
revision = int(raw.revision)
|
|
lineage_id = String(raw.lineage_id)
|
|
generation_root_seed = String(raw.generation_root_seed)
|
|
next_region_run_ordinal = int(raw.next_region_run_ordinal)
|
|
migration_source_hash = String(raw.migration_source_hash)
|
|
migration_source_revision = int(raw.migration_source_revision)
|
|
guild_profile = Dictionary(raw.guild_profile).duplicate(true)
|
|
current_run = Dictionary(raw.current_run).duplicate(true)
|
|
operation_receipts = Dictionary(raw.operation_receipts).duplicate(true)
|
|
legacy_operation_receipts = Dictionary(raw.legacy_operation_receipts).duplicate(true)
|
|
legacy_entry_commits = Dictionary(raw.legacy_entry_commits).duplicate(true)
|
|
battle_checkpoints = Dictionary(raw.battle_checkpoints).duplicate(true)
|
|
|
|
|
|
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 _disjoint(left: Array, right: Array) -> bool:
|
|
for value in left:
|
|
if value in right:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _same_members(left: Array, right: Array) -> bool:
|
|
return left.size() == right.size() and _subset(left, right) and _subset(right, left)
|
|
|
|
|
|
func _unique(values: Array) -> bool:
|
|
var seen := {}
|
|
for value in values:
|
|
if seen.has(value):
|
|
return false
|
|
seen[value] = true
|
|
return true
|
|
|
|
|
|
func _is_hex(value: String, length: int) -> bool:
|
|
if value.length() != length:
|
|
return false
|
|
for character in value:
|
|
if character not in "0123456789abcdef":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _hash(value) -> String:
|
|
return JSON.stringify(_canonical(value)).sha256_text()
|
|
|
|
|
|
func _canonical(value):
|
|
if value is float and is_equal_approx(value, round(value)):
|
|
return int(round(value))
|
|
if value is Dictionary:
|
|
var result := {}
|
|
var keys: Array = value.keys()
|
|
keys.sort_custom(func(left, right): return String(left) < String(right))
|
|
for key in keys:
|
|
result[key] = _canonical(value[key])
|
|
return result
|
|
if value is Array:
|
|
var result: Array = []
|
|
for item in value:
|
|
result.append(_canonical(item))
|
|
return result
|
|
return value
|