Files
aetherbound-guild/runtime/fg3/run_state.gd
T

639 lines
23 KiB
GDScript

class_name FG3RunState
extends RefCounted
const P103State = preload("res://p10_3/run_state.gd")
const MarketGenerator = preload("res://fg3/contract1_market_generator.gd")
const Contract2MarketGenerator = preload("res://fg3/contract2_market_generator.gd")
const STATE_KIND := "fg3_complete_rg02"
const RULESET := "fg3-complete-rg02-r1"
const PHASES := [
"market", "prepared", "recovery", "boss_muster", "boss_prepared",
"boss_recovery", "contract_result", "contract2_handoff", "contract2_market",
"contract2_prepared", "contract2_recovery", "contract2_boss_muster",
"contract2_boss_prepared", "contract2_boss_recovery", "region_result",
"region_closed",
]
var state_kind := STATE_KIND
var ruleset := RULESET
var revision := 0
var lineage_id := ""
var generation_root_seed := ""
var next_region_run_ordinal := 0
var migration_source_hash := ""
var migration_source_revision := 0
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_p103(p103_snapshot: Dictionary, market_board: Dictionary) -> bool:
var source_probe := P103State.new()
if not source_probe.restore_snapshot(p103_snapshot):
return false
var source := source_probe.snapshot()
var prior: Dictionary = source.current_run
if (
String(prior.phase) != "market_8"
or int(prior.market_index) != 8
or int(prior.cleared_rounds) != 7
or String(market_board.get("run_seed", "")) != String(prior.run_seed)
or int(market_board.get("market_index", -1)) != 8
or int(market_board.get("refresh_index", -1)) != 0
):
return false
state_kind = STATE_KIND
ruleset = RULESET
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.legacy_operation_receipts).duplicate(true)
for operation_id in source.operation_receipts:
if legacy_operation_receipts.has(operation_id):
return false
legacy_operation_receipts[operation_id] = Dictionary(source.operation_receipts[operation_id]).duplicate(true)
legacy_entry_commits = Dictionary(source.legacy_entry_commits).duplicate(true)
battle_checkpoints = {}
current_run = prior.duplicate(true)
current_run.phase = "market"
current_run.market_index = 8
current_run.round_index = 8
current_run.cleared_rounds = 7
current_run.line_capacity = 7
current_run.refresh_count = 0
current_run.market_board = market_board.duplicate(true)
current_run.available_recruit_offer_ids = _offer_ids(market_board.recruit_slots, "slot_id")
current_run.available_equipment_offer_ids = _offer_ids(market_board.equipment_offers, "offer_id")
current_run.available_battle_offer_ids = _offer_ids(market_board.battle_offers, "offer_id")
current_run.locked_offer_ids = []
current_run.selected_route_id = ""
current_run.pending_encounter = {}
current_run.pre_battle_checkpoint_id = ""
current_run.recovery_required = false
current_run.recovery_cost = 0
current_run.boss_muster = {}
current_run.selected_boss_plan = ""
current_run.pending_boss_result = {}
current_run.next_contract_id = ""
current_run.fg3_activated = true
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 had_valid_before := invariants_hold()
var before := snapshot() if had_valid_before else {}
_assign(normalized)
if not invariants_hold():
if had_valid_before:
_assign(before)
return false
return true
func invariant_report() -> Dictionary:
var phase := String(current_run.get("phase", ""))
var run_holds: bool = _run_holds()
var company_fields := [
"company", "deployed_recruit_ids", "reserve_recruit_ids",
"recruit_records", "inventory", "equipped_by_recruit",
]
var company_ready: bool = company_fields.all(func(key): return current_run.has(key))
return {
"state_identity": state_kind == STATE_KIND and ruleset == RULESET and revision >= 0,
"lineage": _is_hex(lineage_id, 32) and _is_hex(generation_root_seed, 32),
"migration": _is_hex(migration_source_hash, 64) and migration_source_revision >= 0,
"guild": _guild_holds(),
"receipts": _receipts_hold(),
"company": _company_holds() if company_ready else false,
"checkpoints": _checkpoints_hold(),
"phase": phase,
"phase_holds": run_holds,
"run": run_holds,
}
func invariants_hold() -> bool:
if state_kind != STATE_KIND or ruleset != RULESET or revision < 0:
return false
if not _is_hex(lineage_id, 32) or not _is_hex(generation_root_seed, 32):
return false
if not _is_hex(migration_source_hash, 64) or migration_source_revision < 0:
return false
if next_region_run_ordinal < 0 or not _guild_holds() or not _receipts_hold():
return false
return _run_holds()
func _guild_holds() -> 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", "unlocked_region_ids"]:
if not guild_profile[key] is Array or not _unique(guild_profile[key]):
return false
if not guild_profile.company_records is Array:
return false
var record_ids: Array = []
for record in guild_profile.company_records:
if not record is Dictionary or String(record.get("record_id", "")).is_empty():
return false
record_ids.append(String(record.record_id))
if not _unique(record_ids):
return false
return (
guild_profile.clear_receipts is Dictionary
and int(guild_profile.persistent_renown) >= 0
and "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
)
func _run_holds() -> 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", "route_history",
"recruits_hired_total", "recruits_fallen_total",
"recruits_dismissed_total", "active_artifact_ids",
"artifact_rule_state", "artifact_draft", "promotion_draft",
"boss_muster", "selected_boss_plan", "pending_boss_result",
"next_contract_id", "fg3_activated",
]
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", "artifact_rule_state",
"artifact_draft", "promotion_draft", "boss_muster",
"pending_boss_result",
]:
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", "active_artifact_ids",
]:
if not current_run[key] is Array:
return false
if (
not bool(current_run.fg3_activated)
or String(current_run.region_id) != "RG-02"
or String(current_run.phase) not in PHASES
or not _is_hex(String(current_run.run_id), 32)
or not _is_hex(String(current_run.run_seed), 32)
):
return false
var phase := String(current_run.phase)
var expected_status := "closed" if phase == "region_closed" else "active"
if String(current_run.status) != expected_status:
return false
if int(current_run.coin) < 0 or int(current_run.replacement_credit) < 0:
return false
if current_run.route_history.size() != int(current_run.cleared_rounds):
return false
var contract2_phase := (
phase.begins_with("contract2_")
and phase != "contract2_handoff"
) or phase in ["region_result", "region_closed"]
var expected_contract := "CTR-R2-02" if contract2_phase else "CTR-R2-01"
if String(current_run.contract_id) != expected_contract:
return false
if not _company_holds() or not _checkpoints_hold():
return false
if phase in ["contract2_market", "contract2_prepared", "contract2_recovery"]:
return _contract2_ordinary_holds(phase)
if phase in [
"contract2_boss_muster", "contract2_boss_prepared",
"contract2_boss_recovery", "region_result",
]:
return _contract2_boss_holds(phase)
if phase == "region_closed":
return _region_closed_holds()
if phase in ["market", "prepared", "recovery"]:
return _ordinary_phase_holds(phase)
if phase in ["boss_muster", "boss_prepared", "boss_recovery", "contract_result"]:
return _boss_phase_holds(phase)
return _handoff_holds()
func _ordinary_phase_holds(phase: String) -> bool:
var market_index := int(current_run.market_index)
var expected_capacity: int = {8: 7, 9: 8, 10: 8, 11: 9, 12: 9}.get(market_index, -1)
if (
market_index < 8 or market_index > 12
or int(current_run.round_index) != market_index
or int(current_run.cleared_rounds) != market_index - 1
or int(current_run.line_capacity) != expected_capacity
):
return false
var generated := MarketGenerator.new().generate(
String(current_run.run_seed), market_index, int(current_run.refresh_count)
)
if not generated.ok or _hash(current_run.market_board) != _hash(generated.board):
return false
var recruit_ids := _offer_ids(current_run.market_board.recruit_slots, "slot_id")
var equipment_ids := _offer_ids(current_run.market_board.equipment_offers, "offer_id")
var battle_ids := _offer_ids(current_run.market_board.battle_offers, "offer_id")
if (
current_run.available_recruit_offer_ids != recruit_ids
or current_run.available_equipment_offer_ids != equipment_ids
or current_run.available_battle_offer_ids != battle_ids
):
return false
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
if phase == "market":
return (
String(current_run.selected_route_id).is_empty()
and current_run.pending_encounter.is_empty()
and checkpoint_id.is_empty()
and not bool(current_run.recovery_required)
and int(current_run.recovery_cost) == 0
)
if phase == "prepared":
return (
String(current_run.selected_route_id) in battle_ids
and String(current_run.pending_encounter.get("offer_id", "")) == String(current_run.selected_route_id)
and checkpoint_id in battle_checkpoints
and not bool(current_run.recovery_required)
)
return (
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()
)
func _boss_phase_holds(phase: String) -> bool:
if (
int(current_run.market_index) != 13
or int(current_run.round_index) != 13
or int(current_run.cleared_rounds) != 12
or int(current_run.line_capacity) != 10
or current_run.boss_muster.is_empty()
or String(current_run.boss_muster.get("boss_id", "")) != "BO-R2-01"
or String(current_run.boss_muster.get("muster_hash", "")) != _muster_hash(current_run.boss_muster)
):
return false
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
if phase == "boss_muster":
return String(current_run.selected_boss_plan).is_empty() and checkpoint_id.is_empty()
if phase == "boss_prepared":
return (
String(current_run.selected_boss_plan) in current_run.boss_muster.plan_ids
and checkpoint_id in battle_checkpoints
and not bool(current_run.recovery_required)
)
if phase == "boss_recovery":
return (
bool(current_run.recovery_required)
and int(current_run.recovery_cost) > 0
and checkpoint_id in battle_checkpoints
and not current_run.pending_boss_result.is_empty()
)
return (
not current_run.pending_boss_result.is_empty()
and String(current_run.pending_boss_result.get("outcome", "")) == "victory"
and checkpoint_id.is_empty()
)
func _handoff_holds() -> bool:
if (
int(current_run.market_index) != 13
or int(current_run.cleared_rounds) != 12
or String(current_run.next_contract_id) != "CTR-R2-02"
or String(current_run.market_board.get("handoff_kind", "")) != "fg3.contract2_handoff"
or String(current_run.market_board.get("handoff_hash", "")) != _handoff_hash(current_run.market_board)
):
return false
return (
"CLEAR-RG02-C01" in guild_profile.contract_clear_ids
and guild_profile.clear_receipts.has("CLEAR-RG02-C01")
and guild_profile.company_records.any(
func(record): return String(record.get("contract_id", "")) == "CTR-R2-01"
)
)
func _contract2_ordinary_holds(phase: String) -> bool:
var market_index := int(current_run.market_index)
if (
market_index < 1 or market_index > 12
or int(current_run.round_index) != market_index
or int(current_run.cleared_rounds) != 12 + market_index - 1
or int(current_run.line_capacity) != 10
or int(current_run.refresh_count) < 0
or String(current_run.next_contract_id) != ""
or not current_run.boss_muster.is_empty()
or not current_run.pending_boss_result.is_empty()
):
return false
var generated := Contract2MarketGenerator.new().generate(
String(current_run.run_seed),
market_index,
int(current_run.refresh_count)
)
if not generated.ok or _hash(current_run.market_board) != _hash(generated.board):
return false
if (
current_run.available_recruit_offer_ids != _offer_ids(current_run.market_board.recruit_slots, "slot_id")
or current_run.available_equipment_offer_ids != _offer_ids(current_run.market_board.equipment_offers, "offer_id")
or current_run.available_battle_offer_ids != _offer_ids(current_run.market_board.battle_offers, "offer_id")
or not current_run.locked_offer_ids.is_empty()
):
return false
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
if phase == "contract2_market":
return (
String(current_run.selected_route_id).is_empty()
and current_run.pending_encounter.is_empty()
and checkpoint_id.is_empty()
and not bool(current_run.recovery_required)
and int(current_run.recovery_cost) == 0
)
if phase == "contract2_prepared":
return (
String(current_run.selected_route_id) in current_run.available_battle_offer_ids
and String(current_run.pending_encounter.get("offer_id", "")) == String(current_run.selected_route_id)
and checkpoint_id in battle_checkpoints
and not bool(current_run.recovery_required)
)
return (
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()
)
func _contract2_boss_holds(phase: String) -> bool:
if (
int(current_run.market_index) != 13
or int(current_run.round_index) != 13
or int(current_run.cleared_rounds) != 24
or int(current_run.line_capacity) != 10
or current_run.boss_muster.is_empty()
or String(current_run.boss_muster.get("boss_id", "")) != "BO-R2-02"
or String(current_run.boss_muster.get("muster_hash", "")) != _muster_hash(current_run.boss_muster)
):
return false
var checkpoint_id := String(current_run.pre_battle_checkpoint_id)
if phase == "contract2_boss_muster":
return String(current_run.selected_boss_plan).is_empty() and checkpoint_id.is_empty()
if phase == "contract2_boss_prepared":
return (
String(current_run.selected_boss_plan) in current_run.boss_muster.plan_ids
and checkpoint_id in battle_checkpoints
and not bool(current_run.recovery_required)
)
if phase == "contract2_boss_recovery":
return (
bool(current_run.recovery_required)
and int(current_run.recovery_cost) > 0
and checkpoint_id in battle_checkpoints
and not current_run.pending_boss_result.is_empty()
)
return (
not current_run.pending_boss_result.is_empty()
and String(current_run.pending_boss_result.get("outcome", "")) == "victory"
and checkpoint_id.is_empty()
)
func _region_closed_holds() -> bool:
if (
int(current_run.market_index) != 13
or int(current_run.cleared_rounds) != 24
or String(current_run.market_board.get("result_kind", "")) != "fg3.rg02_closed"
or String(current_run.market_board.get("result_hash", "")) != _result_hash(current_run.market_board)
):
return false
return (
"CLEAR-RG02-C01" in guild_profile.contract_clear_ids
and "CLEAR-RG02-C02" in guild_profile.contract_clear_ids
and guild_profile.clear_receipts.has("CLEAR-RG02-C02")
and "RG-03" in guild_profile.unlocked_region_ids
and guild_profile.company_records.any(
func(record): return String(record.get("contract_id", "")) == "CTR-R2-02"
)
)
func _company_holds() -> bool:
if current_run.company.is_empty() or not _unique(current_run.company):
return false
if not _unique(current_run.deployed_recruit_ids) or not _subset(current_run.deployed_recruit_ids, current_run.company):
return false
var expected_reserve: Array = []
for recruit_id in current_run.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 int(record.get("current_hp", 0)) <= 0 or int(record.get("current_hp", 0)) > int(record.get("max_hp", 0)):
return false
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].get("wearer_id", "")) != recruit_id:
return false
if recruit_id not in current_run.deployed_recruit_ids:
expected_reserve.append(recruit_id)
if current_run.reserve_recruit_ids != expected_reserve:
return false
for recruit_id in current_run.recruit_records:
if recruit_id not in current_run.company:
return false
for instance_id in current_run.inventory:
var item = current_run.inventory[instance_id]
if not item is Dictionary:
return false
var wearer := String(item.get("wearer_id", ""))
if not wearer.is_empty() and wearer not in current_run.company:
return false
return true
func _receipts_hold() -> bool:
for operation_id in operation_receipts:
var record = operation_receipts[operation_id]
if (
String(operation_id).is_empty()
or not record is Dictionary
or not _is_hex(String(record.get("intent_hash", "")), 64)
or not record.get("receipt", {}) is Dictionary
):
return false
return true
func _checkpoints_hold() -> bool:
for checkpoint_id in battle_checkpoints:
var checkpoint = battle_checkpoints[checkpoint_id]
if (
not _is_hex(String(checkpoint_id), 64)
or not checkpoint is Dictionary
or String(checkpoint.get("checkpoint_id", "")) != checkpoint_id
or not checkpoint.get("source_snapshot", {}) is Dictionary
):
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 _muster_hash(muster: Dictionary) -> String:
var projection := muster.duplicate(true)
projection.erase("muster_hash")
return _hash(projection)
func _handoff_hash(handoff: Dictionary) -> String:
var projection := handoff.duplicate(true)
projection.erase("handoff_hash")
return _hash(projection)
func _result_hash(result: Dictionary) -> String:
var projection := result.duplicate(true)
projection.erase("result_hash")
return _hash(projection)
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 _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