461 lines
20 KiB
GDScript
461 lines
20 KiB
GDScript
class_name P100RunState
|
|
extends RefCounted
|
|
|
|
const CONTENT_SOURCE := "a603f8242a61b3aa946d16da4bb5f42bd7490fb5"
|
|
const RULESET_VERSION := "p10.0-contract-r1"
|
|
const STATE_KIND := "p100_run_market"
|
|
const VALID_ARTIFACT_IDS := ["AR-001", "AR-002", "AR-003", "AR-004", "AR-005", "AR-006", "AR-007", "AR-008"]
|
|
|
|
var state_kind := STATE_KIND
|
|
var revision := 0
|
|
var lineage_id := ""
|
|
var generation_root_seed := ""
|
|
var next_run_ordinal := 0
|
|
var guild_profile: Dictionary = {}
|
|
var run_creation_draft: Dictionary = {}
|
|
var current_run: Dictionary = {}
|
|
var operation_receipts: Dictionary = {}
|
|
var run_creation_commits: Dictionary = {}
|
|
|
|
|
|
func initialize(root_seed: String, lineage: String) -> bool:
|
|
if not _is_hex(root_seed, 32) or not _is_hex(lineage, 32):
|
|
return false
|
|
state_kind = STATE_KIND
|
|
revision = 0
|
|
lineage_id = lineage
|
|
generation_root_seed = root_seed
|
|
next_run_ordinal = 0
|
|
guild_profile = {
|
|
"schema": 1,
|
|
"persistent_renown": 0,
|
|
"contract_clear_ids": [],
|
|
"discovery_ids": [],
|
|
"company_records": [],
|
|
"unlocked_region_ids": ["RG-01"],
|
|
"clear_receipts": {},
|
|
}
|
|
run_creation_draft.clear()
|
|
current_run.clear()
|
|
operation_receipts.clear()
|
|
run_creation_commits.clear()
|
|
return invariants_hold()
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
var raw := {
|
|
"state_kind": state_kind,
|
|
"revision": revision,
|
|
"lineage_id": lineage_id,
|
|
"generation_root_seed": generation_root_seed,
|
|
"next_run_ordinal": next_run_ordinal,
|
|
"guild_profile": guild_profile.duplicate(true),
|
|
"run_creation_draft": run_creation_draft.duplicate(true),
|
|
"current_run": current_run.duplicate(true),
|
|
"operation_receipts": operation_receipts.duplicate(true),
|
|
"run_creation_commits": run_creation_commits.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", "revision", "lineage_id", "generation_root_seed",
|
|
"next_run_ordinal", "guild_profile", "run_creation_draft", "current_run",
|
|
"operation_receipts", "run_creation_commits",
|
|
]
|
|
for key in required:
|
|
if not normalized.has(key):
|
|
return false
|
|
for key in ["guild_profile", "run_creation_draft", "current_run", "operation_receipts", "run_creation_commits"]:
|
|
if not normalized[key] is Dictionary:
|
|
return false
|
|
var before := snapshot()
|
|
_assign(normalized)
|
|
if not invariants_hold():
|
|
_assign(before)
|
|
return false
|
|
return true
|
|
|
|
|
|
func invariants_hold() -> bool:
|
|
if state_kind != STATE_KIND or revision < 0 or next_run_ordinal < 0:
|
|
return false
|
|
if not _is_hex(lineage_id, 32) or not _is_hex(generation_root_seed, 32):
|
|
return false
|
|
if not _guild_invariants_hold():
|
|
return false
|
|
if not run_creation_draft.is_empty() and not current_run.is_empty():
|
|
return false
|
|
if not run_creation_draft.is_empty() and not _draft_invariants_hold():
|
|
return false
|
|
if not current_run.is_empty() and not _run_invariants_hold():
|
|
return false
|
|
if not _receipt_invariants_hold():
|
|
return false
|
|
return true
|
|
|
|
|
|
func _draft_invariants_hold() -> bool:
|
|
var required := [
|
|
"draft_id", "run_id", "contract_id", "difficulty", "status",
|
|
"seed_ordinal", "run_seed", "source_commit_id", "ruleset",
|
|
"content_revision", "role_weights", "opening_offers",
|
|
"opening_offer_set_hash", "selected_offer_ids", "selection_revision",
|
|
"seed_reservation_applied", "read_back_validated", "region_floor",
|
|
]
|
|
for key in required:
|
|
if not run_creation_draft.has(key):
|
|
return false
|
|
if not _is_hex(String(run_creation_draft.draft_id), 32) or not _is_hex(String(run_creation_draft.run_id), 32) or not _is_hex(String(run_creation_draft.run_seed), 32):
|
|
return false
|
|
if int(run_creation_draft.seed_ordinal) < 0 or int(run_creation_draft.seed_ordinal) >= next_run_ordinal:
|
|
return false
|
|
if String(run_creation_draft.contract_id) not in ["CTR-R1-01", "CTR-R1-02"]:
|
|
return false
|
|
if String(run_creation_draft.source_commit_id) != CONTENT_SOURCE or String(run_creation_draft.ruleset) != RULESET_VERSION:
|
|
return false
|
|
if not run_creation_draft.role_weights is Dictionary or not run_creation_draft.opening_offers is Array or not run_creation_draft.selected_offer_ids is Array:
|
|
return false
|
|
if not bool(run_creation_draft.seed_reservation_applied) or not bool(run_creation_draft.read_back_validated):
|
|
return false
|
|
var status := String(run_creation_draft.status)
|
|
var offers: Array = run_creation_draft.opening_offers
|
|
var selected: Array = run_creation_draft.selected_offer_ids
|
|
if status == "seed_reserved":
|
|
return offers.is_empty() and selected.is_empty() and String(run_creation_draft.opening_offer_set_hash).is_empty() and int(run_creation_draft.selection_revision) == 0
|
|
if status not in ["offers_ready", "selection_ready"] or offers.size() != 4:
|
|
return false
|
|
var offer_ids: Array[String] = []
|
|
for index in range(offers.size()):
|
|
if not offers[index] is Dictionary:
|
|
return false
|
|
var offer: Dictionary = offers[index]
|
|
if int(offer.get("offer_index", -1)) != index or not _is_hex(String(offer.get("source_offer_id", "")), 32) or not _is_hex(String(offer.get("canonical_output_hash", "")), 64):
|
|
return false
|
|
offer_ids.append(String(offer.source_offer_id))
|
|
if not _unique(offer_ids) or not _is_hex(String(run_creation_draft.opening_offer_set_hash), 64):
|
|
return false
|
|
if status == "offers_ready":
|
|
return selected.is_empty() and int(run_creation_draft.selection_revision) == 0
|
|
return selected.size() == 2 and _unique(selected) and _subset(selected, offer_ids) and int(run_creation_draft.selection_revision) >= 1
|
|
|
|
|
|
func _run_invariants_hold() -> bool:
|
|
var required := [
|
|
"run_id", "contract_id", "difficulty", "run_seed", "status", "phase",
|
|
"content_revision", "content_hash", "market_index", "cleared_rounds",
|
|
"coin", "replacement_credit", "line_capacity", "market_board",
|
|
"refresh_count", "locks", "available_recruit_offer_ids",
|
|
"available_equipment_offer_ids", "available_battle_offer_ids", "company",
|
|
"deployed_recruit_ids", "reserve_recruit_ids", "recruit_records",
|
|
"inventory", "equipped_by_recruit", "formation_proposal", "encounter",
|
|
"active_artifact_ids", "artifact_rule_state", "pending_reward",
|
|
"last_battle_report", "recovery_required",
|
|
"protected_recovery_offer_id",
|
|
"boss_muster", "pending_close",
|
|
"recruits_hired_total", "recruits_fallen_total",
|
|
"recruits_dismissed_total",
|
|
]
|
|
for key in required:
|
|
if not current_run.has(key):
|
|
return false
|
|
for key in ["market_board", "locks", "recruit_records", "inventory", "equipped_by_recruit", "encounter", "artifact_rule_state", "pending_reward", "last_battle_report", "boss_muster", "pending_close"]:
|
|
if not current_run[key] is Dictionary:
|
|
return false
|
|
for key in ["available_recruit_offer_ids", "available_equipment_offer_ids", "available_battle_offer_ids", "company", "deployed_recruit_ids", "reserve_recruit_ids", "formation_proposal", "active_artifact_ids"]:
|
|
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.contract_id) not in ["CTR-R1-01", "CTR-R1-02"] or String(current_run.status) != "active":
|
|
return false
|
|
if String(current_run.phase) not in ["market", "encounter", "boss_muster", "boss_encounter", "contract_result"]:
|
|
return false
|
|
if int(current_run.market_index) < 1 or int(current_run.market_index) > 12 or int(current_run.cleared_rounds) < 0 or int(current_run.cleared_rounds) > 12:
|
|
return false
|
|
if int(current_run.coin) < 0 or int(current_run.replacement_credit) < 0 or int(current_run.replacement_credit) > 54:
|
|
return false
|
|
if int(current_run.line_capacity) != capacity_for_clears(int(current_run.cleared_rounds)):
|
|
return false
|
|
if int(current_run.refresh_count) < 0:
|
|
return false
|
|
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() and (not bool(current_run.recovery_required) or String(current_run.protected_recovery_offer_id).is_empty() or String(current_run.phase) != "market"):
|
|
return false
|
|
if deployed.size() > int(current_run.line_capacity) or not _subset(deployed, company) or not _subset(reserve, company):
|
|
return false
|
|
if not _disjoint(deployed, reserve) or not _same_members(deployed + reserve, company):
|
|
return false
|
|
if not _subset(current_run.formation_proposal, company) or not _unique(current_run.formation_proposal) or current_run.formation_proposal.size() > int(current_run.line_capacity):
|
|
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 recruit: Dictionary = current_run.recruit_records[recruit_id]
|
|
if String(recruit.get("state", "")) != "living" or int(recruit.get("level", 0)) < 1 or int(recruit.get("level", 0)) > 10:
|
|
return false
|
|
if int(recruit.get("current_hp", -1)) < 0 or int(recruit.get("current_hp", 0)) > int(recruit.get("max_hp", 0)):
|
|
return false
|
|
var worn_instances := {}
|
|
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", "")) != String(instance_id):
|
|
return false
|
|
var wearer := String(item.get("wearer_id", ""))
|
|
if not wearer.is_empty():
|
|
if wearer not in company or worn_instances.has(wearer) or String(current_run.equipped_by_recruit.get(wearer, "")) != String(instance_id):
|
|
return false
|
|
worn_instances[wearer] = instance_id
|
|
for recruit_id in current_run.equipped_by_recruit:
|
|
var instance_id := String(current_run.equipped_by_recruit[recruit_id])
|
|
if not instance_id.is_empty() and (not current_run.inventory.has(instance_id) or String(current_run.inventory[instance_id].wearer_id) != recruit_id):
|
|
return false
|
|
if not _board_invariants_hold():
|
|
return false
|
|
if not _lock_invariants_hold():
|
|
return false
|
|
if not _unique(current_run.active_artifact_ids) or current_run.active_artifact_ids.size() > 3:
|
|
return false
|
|
if current_run.artifact_rule_state.size() != current_run.active_artifact_ids.size():
|
|
return false
|
|
for artifact_id in current_run.active_artifact_ids:
|
|
if artifact_id not in VALID_ARTIFACT_IDS or not current_run.artifact_rule_state.has(artifact_id):
|
|
return false
|
|
var artifact_state = current_run.artifact_rule_state[artifact_id]
|
|
if not artifact_state is Dictionary or not artifact_state.get("uses_by_market", {}) is Dictionary or not artifact_state.get("rule", {}) is Dictionary:
|
|
return false
|
|
if not current_run.pending_reward.is_empty() and not _reward_invariants_hold():
|
|
return false
|
|
if bool(current_run.recovery_required):
|
|
if String(current_run.protected_recovery_offer_id).is_empty() or String(current_run.protected_recovery_offer_id) not in current_run.available_recruit_offer_ids:
|
|
return false
|
|
elif not String(current_run.protected_recovery_offer_id).is_empty():
|
|
return false
|
|
if String(current_run.phase) == "market" and (not current_run.encounter.is_empty() or not current_run.pending_close.is_empty()):
|
|
return false
|
|
if String(current_run.phase) == "market" and not current_run.boss_muster.is_empty() and int(current_run.cleared_rounds) != 12:
|
|
return false
|
|
if String(current_run.phase) == "encounter":
|
|
if current_run.encounter.is_empty() or deployed.is_empty() or not current_run.boss_muster.is_empty() or not current_run.pending_close.is_empty():
|
|
return false
|
|
if String(current_run.encounter.get("board_hash", "")) != String(current_run.market_board.get("board_hash", "")):
|
|
return false
|
|
if String(current_run.phase) in ["boss_muster", "boss_encounter", "contract_result"]:
|
|
if current_run.boss_muster.is_empty() or int(current_run.cleared_rounds) != 12 or int(current_run.market_index) != 12:
|
|
return false
|
|
if String(current_run.boss_muster.get("contract_id", "")) != String(current_run.contract_id):
|
|
return false
|
|
if String(current_run.phase) == "boss_muster" and (not current_run.encounter.is_empty() or not current_run.pending_close.is_empty()):
|
|
return false
|
|
if String(current_run.phase) == "boss_encounter" and (current_run.encounter.is_empty() or String(current_run.encounter.get("kind", "")) != "boss" or not current_run.pending_close.is_empty()):
|
|
return false
|
|
if String(current_run.phase) == "contract_result" and (not current_run.encounter.is_empty() or current_run.pending_close.is_empty()):
|
|
return false
|
|
return int(current_run.recruits_hired_total) >= 0 and int(current_run.recruits_fallen_total) >= 0 and int(current_run.recruits_dismissed_total) >= 0
|
|
|
|
|
|
func _reward_invariants_hold() -> bool:
|
|
var reward: Dictionary = current_run.pending_reward
|
|
for key in ["reward_id", "battle_hash", "round", "risk", "budget", "cards", "draft_hash"]:
|
|
if not reward.has(key):
|
|
return false
|
|
if not _is_hex(String(reward.reward_id), 32) or not _is_hex(String(reward.battle_hash), 64) or not _is_hex(String(reward.draft_hash), 64):
|
|
return false
|
|
if not reward.cards is Array or int(reward.risk) < 0 or int(reward.risk) > 2 or int(reward.round) < 1 or int(reward.round) > 12:
|
|
return false
|
|
if reward.cards.size() != (2 if int(reward.risk) == 0 else 3):
|
|
return false
|
|
var hashless := reward.duplicate(true)
|
|
hashless.erase("draft_hash")
|
|
if _hash(hashless) != String(reward.draft_hash):
|
|
return false
|
|
var card_ids: Array[String] = []
|
|
var kind_counts := {"coin": 0, "credit": 0, "equipment": 0, "artifact": 0}
|
|
for card in reward.cards:
|
|
if not card is Dictionary or not _is_hex(String(card.get("card_id", "")), 32) or String(card.get("kind", "")) not in ["coin", "credit", "equipment", "artifact"]:
|
|
return false
|
|
var kind := String(card.kind)
|
|
kind_counts[kind] = int(kind_counts[kind]) + 1
|
|
if kind in ["coin", "credit"] and int(card.get("amount", -1)) < 0:
|
|
return false
|
|
if kind == "equipment" and (not String(card.get("catalog_id", "")).begins_with("EQ-") or not _is_hex(String(card.get("instance_id", "")), 32)):
|
|
return false
|
|
if kind == "artifact":
|
|
var artifact_ids = card.get("artifact_ids", [])
|
|
if not artifact_ids is Array or artifact_ids.size() != 3 or not _unique(artifact_ids):
|
|
return false
|
|
for artifact_id in artifact_ids:
|
|
if artifact_id not in VALID_ARTIFACT_IDS:
|
|
return false
|
|
card_ids.append(String(card.card_id))
|
|
var expected_counts := {
|
|
0: {"coin": 1, "credit": 0, "equipment": 1, "artifact": 0},
|
|
1: {"coin": 1, "credit": 0, "equipment": 2, "artifact": 0},
|
|
2: {"coin": 0, "credit": 1, "equipment": 1, "artifact": 1},
|
|
}
|
|
return _unique(card_ids) and kind_counts == expected_counts[int(reward.risk)] and String(current_run.phase) == "market"
|
|
|
|
|
|
func _guild_invariants_hold() -> bool:
|
|
for key in ["schema", "persistent_renown", "contract_clear_ids", "discovery_ids", "company_records", "unlocked_region_ids", "clear_receipts"]:
|
|
if not guild_profile.has(key):
|
|
return false
|
|
for key in ["contract_clear_ids", "discovery_ids", "company_records", "unlocked_region_ids"]:
|
|
if not guild_profile[key] is Array:
|
|
return false
|
|
if not guild_profile.clear_receipts is Dictionary or int(guild_profile.schema) != 1 or int(guild_profile.persistent_renown) < 0 or int(guild_profile.persistent_renown) > 9999:
|
|
return false
|
|
if not _unique(guild_profile.contract_clear_ids) or not _unique(guild_profile.discovery_ids) or not _unique(guild_profile.unlocked_region_ids):
|
|
return false
|
|
if "RG-01" not in guild_profile.unlocked_region_ids:
|
|
return false
|
|
for record in guild_profile.company_records:
|
|
if not record is Dictionary or String(record.get("record_id", "")).is_empty() or String(record.get("clear_id", "")).is_empty():
|
|
return false
|
|
return true
|
|
|
|
|
|
func _board_invariants_hold() -> bool:
|
|
var board: Dictionary = current_run.market_board
|
|
for key in ["board_id", "board_hash", "run_seed", "contract_id", "market_index", "refresh_index", "recruit_offers", "equipment_offers", "battle_offers"]:
|
|
if not board.has(key):
|
|
return false
|
|
if not board.recruit_offers is Array or not board.equipment_offers is Array or not board.battle_offers is Array:
|
|
return false
|
|
if board.recruit_offers.size() != 3 or board.equipment_offers.size() != 4 or board.battle_offers.size() != 3:
|
|
return false
|
|
if int(board.market_index) != int(current_run.market_index) or int(board.refresh_index) != int(current_run.refresh_count):
|
|
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 _unique(recruit_ids) or not _unique(equipment_ids) or not _unique(battle_ids):
|
|
return false
|
|
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 risks: Array[int] = []
|
|
for offer in board.battle_offers:
|
|
risks.append(int(offer.get("risk", -1)))
|
|
return risks == [0, 1, 2]
|
|
|
|
|
|
func _lock_invariants_hold() -> bool:
|
|
if current_run.locks.size() > 3:
|
|
return false
|
|
var legal: Array = current_run.available_recruit_offer_ids + current_run.available_equipment_offer_ids
|
|
for offer_id in current_run.locks:
|
|
var lock = current_run.locks[offer_id]
|
|
if offer_id not in legal or not lock is Dictionary:
|
|
return false
|
|
if String(lock.get("offer_id", "")) != offer_id or String(lock.get("kind", "")) not in ["recruit", "equipment"] or int(lock.get("coin_paid", -1)) != 2:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _receipt_invariants_hold() -> bool:
|
|
for operation_id in operation_receipts:
|
|
var row = operation_receipts[operation_id]
|
|
if String(operation_id).is_empty() or not row is Dictionary:
|
|
return false
|
|
if String(row.get("status", "")) != "applied" or not _is_hex(String(row.get("intent_hash", "")), 64) or not row.get("receipt", {}) is Dictionary:
|
|
return false
|
|
for draft_id in run_creation_commits:
|
|
var row = run_creation_commits[draft_id]
|
|
if not _is_hex(String(draft_id), 32) or not row is Dictionary or String(row.get("draft_id", "")) != draft_id:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func capacity_for_clears(clears: int) -> int:
|
|
return mini(10, 4 + int(floor(float(clears) / 2.0)))
|
|
|
|
|
|
func _assign(raw: Dictionary) -> void:
|
|
state_kind = String(raw.state_kind)
|
|
revision = int(raw.revision)
|
|
lineage_id = String(raw.lineage_id)
|
|
generation_root_seed = String(raw.generation_root_seed)
|
|
next_run_ordinal = int(raw.next_run_ordinal)
|
|
guild_profile = Dictionary(raw.guild_profile).duplicate(true)
|
|
run_creation_draft = Dictionary(raw.run_creation_draft).duplicate(true)
|
|
current_run = Dictionary(raw.current_run).duplicate(true)
|
|
operation_receipts = Dictionary(raw.operation_receipts).duplicate(true)
|
|
run_creation_commits = Dictionary(raw.run_creation_commits).duplicate(true)
|
|
|
|
|
|
func _offer_ids(offers: Array, field: String) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for offer in offers:
|
|
if not offer is Dictionary or String(offer.get(field, "")).is_empty():
|
|
return []
|
|
result.append(String(offer[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 _unique(left) and _unique(right) and _subset(left, right)
|
|
|
|
|
|
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 index in range(value.length()):
|
|
if value.substr(index, 1) not in "0123456789abcdef":
|
|
return false
|
|
return true
|
|
|
|
|
|
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
|