diff --git a/runtime/p10_3/growth_service.gd b/runtime/p10_3/growth_service.gd new file mode 100644 index 00000000..663bccc7 --- /dev/null +++ b/runtime/p10_3/growth_service.gd @@ -0,0 +1,468 @@ +class_name P103GrowthService +extends RefCounted + +const Content = preload("res://p10_3/content_registry.gd") +const ProfessionContent = preload("res://p10_0/content_registry.gd") +const BoardService = preload("res://p10_3/market_board_service.gd") +const State = preload("res://p10_3/run_state.gd") + +const ARTIFACT_IDS := ["AR-011", "AR-012", "AR-015"] +const ARTIFACT_RULES := { + "AR-011": { + "kind": "narrow_vanguard", + "capacity_delta": -1, + "front_guarded": true, + "forced_target_priority": true, + }, + "AR-012": { + "kind": "long_company", + "capacity_delta": 1, + "opening_guard_disabled": true, + }, + "AR-015": { + "kind": "paired_duties", + "capacity_delta": 0, + "adjacent_pair_priority": true, + "cross_pair_delay_cadences": 1, + }, +} + +var state := State.new() +var valid := false +var error_code := "invalid_state" +var _artifact_rows := Content.rows_by_id(Content.snapshot(), "artifacts") + + +func _init(snapshot_value: Dictionary) -> void: + valid = state.restore_snapshot(snapshot_value) + if valid: + error_code = "" + + +func restore_snapshot(snapshot_value: Dictionary) -> bool: + valid = state.restore_snapshot(snapshot_value) + error_code = "" if valid else "invalid_state" + return valid + + +static func artifact_draft_for(run_seed: String) -> Dictionary: + if run_seed.length() != 32: + return {} + for character in run_seed: + if character not in "0123456789abcdef": + return {} + var draft := { + "draft_id": "AD-R2-M07-01", + "run_seed": run_seed, + "offer_ids": ARTIFACT_IDS.duplicate(), + "created_after_round": 6, + "apply_from_market": 7, + } + draft["draft_hash"] = BoardService.hash_value(draft) + draft["status"] = "available" + draft["selected_artifact_id"] = "" + return draft + + +static func profession_descendants() -> Dictionary: + var rows := ProfessionContent.rows_by_id(ProfessionContent.snapshot(), "professions") + var result := {} + for base_index in range(1, 13): + result["PF-B%02d" % base_index] = [] + for profession_id in rows: + var row: Dictionary = rows[profession_id] + if String(row.get("family", "")) == "advanced": + var parent := String(row.get("parent_profession_id", "")) + if result.has(parent): + result[parent].append(String(profession_id)) + for base_id in result: + result[base_id].sort() + return result + + +func artifact_draft() -> Dictionary: + if not valid: + return _reject(error_code) + if state.current_run.artifact_draft.is_empty(): + return _reject("artifact_draft_required") + return _query("artifact_draft", Dictionary(state.current_run.artifact_draft).duplicate(true)) + + +func defer_artifact( + 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, "defer_artifact", intent) + if prior.known: + return prior.result + var guard := _artifact_guard(expected_revision, expected_draft_hash) + if not guard.is_empty(): + return _reject(guard) + if String(state.current_run.artifact_draft.status) == "deferred": + return _no_commit("artifact_already_deferred", { + "draft_id": String(state.current_run.artifact_draft.draft_id), + }) + var before := state.snapshot() + state.current_run.artifact_draft.status = "deferred" + return _commit(operation_id, "defer_artifact", intent, "artifact_deferred", { + "operation_id": operation_id, + "draft_id": String(state.current_run.artifact_draft.draft_id), + "draft_hash": expected_draft_hash, + }, before) + + +func select_artifact( + operation_id: String, + expected_revision: int, + expected_draft_hash: String, + artifact_id: String +) -> Dictionary: + var intent := { + "expected_revision": expected_revision, + "expected_draft_hash": expected_draft_hash, + "artifact_id": artifact_id, + } + var prior := _prior_operation(operation_id, "select_artifact", intent) + if prior.known: + return prior.result + var guard := _artifact_guard(expected_revision, expected_draft_hash) + if not guard.is_empty(): + return _reject(guard) + if artifact_id not in state.current_run.artifact_draft.offer_ids: + return _reject("artifact_unavailable") + if not state.current_run.active_artifact_ids.is_empty(): + return _reject("artifact_already_selected") + var row: Dictionary = _artifact_rows.get(artifact_id, {}) + if row.is_empty() or not ARTIFACT_RULES.has(artifact_id): + return _reject("artifact_authority_missing") + var before := state.snapshot() + state.current_run.active_artifact_ids = [artifact_id] + state.current_run.artifact_rule_state = { + artifact_id: { + "artifact_id": artifact_id, + "activated_market_index": int(state.current_run.market_index), + "rule": Dictionary(ARTIFACT_RULES[artifact_id]).duplicate(true), + "conflict_id": String(row.conflict_id), + }, + } + state.current_run.artifact_draft.status = "selected" + state.current_run.artifact_draft.selected_artifact_id = artifact_id + return _commit(operation_id, "select_artifact", intent, "artifact_selected", { + "operation_id": operation_id, + "artifact_id": artifact_id, + "effective_line_capacity": effective_line_capacity(), + "rule": Dictionary(ARTIFACT_RULES[artifact_id]).duplicate(true), + }, before) + + +func effective_line_capacity() -> int: + var base_capacity := int(state.current_run.get("line_capacity", 0)) + if not valid or state.current_run.active_artifact_ids.is_empty(): + return base_capacity + var artifact_id := String(state.current_run.active_artifact_ids[0]) + return clampi( + base_capacity + int(ARTIFACT_RULES.get(artifact_id, {}).get("capacity_delta", 0)), + 1, + 10 + ) + + +func open_promotion_draft(operation_id: String, expected_revision: int) -> Dictionary: + var intent := {"expected_revision": expected_revision} + var prior := _prior_operation(operation_id, "open_promotion_draft", intent) + if prior.known: + return prior.result + var guard := _promotion_market_guard(expected_revision) + if not guard.is_empty(): + return _reject(guard) + if not state.current_run.promotion_draft.is_empty(): + return _no_commit("promotion_draft_exists", Dictionary(state.current_run.promotion_draft).duplicate(true)) + var mapping := profession_descendants() + if mapping.size() != 12 or mapping.values().any(func(options): return options.size() != 2): + return _reject("profession_tree_invalid") + var recruit_id := _first_eligible_recruit(mapping) + if recruit_id.is_empty(): + return _reject("promotion_not_eligible") + var record: Dictionary = state.current_run.recruit_records[recruit_id] + var immutable := { + "draft_id": BoardService.hash_value({ + "run_id": String(state.current_run.run_id), + "market_index": 7, + "recruit_id": recruit_id, + "profession_id": String(record.profession_id), + }).substr(0, 32), + "recruit_id": recruit_id, + "origin_profession_id": String(record.origin_profession_id), + "current_profession_id": String(record.profession_id), + "option_ids": Array(mapping[String(record.profession_id)]).duplicate(), + "minimum_level": 4, + "minimum_survived_battles": 3, + } + var draft := immutable.duplicate(true) + draft["draft_hash"] = BoardService.hash_value(immutable) + draft["status"] = "available" + draft["chosen_profession_id"] = "" + var before := state.snapshot() + state.current_run.promotion_draft = draft + return _commit(operation_id, "open_promotion_draft", intent, "promotion_draft_opened", { + "operation_id": operation_id, + "draft": draft.duplicate(true), + }, before) + + +func defer_promotion( + 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, "defer_promotion", intent) + if prior.known: + return prior.result + var guard := _promotion_guard(expected_revision, expected_draft_hash) + if not guard.is_empty(): + return _reject(guard) + if String(state.current_run.promotion_draft.status) == "deferred": + return _no_commit("promotion_already_deferred", { + "draft_id": String(state.current_run.promotion_draft.draft_id), + }) + var before := state.snapshot() + state.current_run.promotion_draft.status = "deferred" + return _commit(operation_id, "defer_promotion", intent, "promotion_deferred", { + "operation_id": operation_id, + "draft_id": String(state.current_run.promotion_draft.draft_id), + "draft_hash": expected_draft_hash, + }, before) + + +func confirm_promotion( + operation_id: String, + expected_revision: int, + expected_draft_hash: String, + recruit_id: String, + expected_profession_id: String, + chosen_profession_id: String +) -> Dictionary: + var intent := { + "expected_revision": expected_revision, + "expected_draft_hash": expected_draft_hash, + "recruit_id": recruit_id, + "expected_profession_id": expected_profession_id, + "chosen_profession_id": chosen_profession_id, + } + var prior := _prior_operation(operation_id, "confirm_promotion", intent) + if prior.known: + return prior.result + var guard := _promotion_guard(expected_revision, expected_draft_hash) + if not guard.is_empty(): + return _reject(guard) + var draft: Dictionary = state.current_run.promotion_draft + if recruit_id != String(draft.recruit_id) or expected_profession_id != String(draft.current_profession_id): + return _reject("promotion_identity_mismatch") + if chosen_profession_id not in draft.option_ids: + return _reject("promotion_option_unavailable") + var record: Dictionary = state.current_run.recruit_records.get(recruit_id, {}) + if record.is_empty() or String(record.profession_id) != expected_profession_id: + return _reject("profession_mismatch") + if int(record.level) < 4 or int(record.survived_battles) < 3 or String(record.promotion_state) != "base": + return _reject("promotion_not_eligible") + var before := state.snapshot() + var preserved := _preserved_recruit_projection(record, recruit_id) + record.profession_id = chosen_profession_id + record.profession_overlay = chosen_profession_id + record.promotion_state = "regular_advanced" + record.promotion_history = [{ + "from": expected_profession_id, + "to": chosen_profession_id, + "market_index": int(state.current_run.market_index), + }] + state.current_run.recruit_records[recruit_id] = record + state.current_run.promotion_draft.status = "selected" + state.current_run.promotion_draft.chosen_profession_id = chosen_profession_id + if _preserved_recruit_projection(record, recruit_id) != preserved: + state.restore_snapshot(before) + return _reject("promotion_preservation_failed") + return _commit(operation_id, "confirm_promotion", intent, "promotion_confirmed", { + "operation_id": operation_id, + "recruit_id": recruit_id, + "from_profession_id": expected_profession_id, + "to_profession_id": chosen_profession_id, + "preserved": preserved, + }, before) + + +func route_growth_ready() -> Dictionary: + if not valid: + return _reject(error_code) + if int(state.current_run.market_index) != 7: + return _query("growth_not_required", {"ready": true}) + var artifact_status := String(state.current_run.artifact_draft.get("status", "")) + if artifact_status not in ["deferred", "selected"]: + return _query("artifact_decision_required", {"ready": false}) + return _query("growth_ready", { + "ready": true, + "artifact_status": artifact_status, + "promotion_status": String(state.current_run.promotion_draft.get("status", "not_opened")), + }) + + +func _artifact_guard(expected_revision: int, expected_draft_hash: String) -> String: + if not valid: + return error_code + if expected_revision != state.revision: + return "stale_revision" + if String(state.current_run.phase) != "market" or int(state.current_run.market_index) != 7: + return "market_7_required" + if state.current_run.artifact_draft.is_empty(): + return "artifact_draft_required" + if String(state.current_run.artifact_draft.draft_hash) != expected_draft_hash: + return "artifact_draft_hash_mismatch" + if String(state.current_run.artifact_draft.status) == "selected": + return "artifact_already_selected" + return "" + + +func _promotion_market_guard(expected_revision: int) -> String: + if not valid: + return error_code + if expected_revision != state.revision: + return "stale_revision" + if String(state.current_run.phase) != "market" or int(state.current_run.market_index) != 7: + return "market_7_required" + if String(state.current_run.artifact_draft.get("status", "")) not in ["deferred", "selected"]: + return "artifact_decision_required" + return "" + + +func _promotion_guard(expected_revision: int, expected_draft_hash: String) -> String: + var market_guard := _promotion_market_guard(expected_revision) + if not market_guard.is_empty(): + return market_guard + if state.current_run.promotion_draft.is_empty(): + return "promotion_draft_required" + if String(state.current_run.promotion_draft.draft_hash) != expected_draft_hash: + return "promotion_draft_hash_mismatch" + if String(state.current_run.promotion_draft.status) == "selected": + return "promotion_already_selected" + return "" + + +func _first_eligible_recruit(mapping: Dictionary) -> String: + for recruit_id in state.current_run.company: + var record: Dictionary = state.current_run.recruit_records[recruit_id] + var profession_id := String(record.profession_id) + if ( + mapping.has(profession_id) + and String(record.promotion_state) == "base" + and int(record.level) >= 4 + and int(record.survived_battles) >= 3 + ): + return String(recruit_id) + return "" + + +func _preserved_recruit_projection(record: Dictionary, recruit_id: String) -> Dictionary: + return { + "recruit_id": String(record.recruit_id), + "origin_profession_id": String(record.origin_profession_id), + "base_profession_id": String(record.base_profession_id), + "level": int(record.level), + "xp": int(record.xp), + "current_hp": int(record.current_hp), + "max_hp": int(record.max_hp), + "trait_id": String(record.trait_id), + "equipment_instance_id": String(state.current_run.equipped_by_recruit[recruit_id]), + "company_order": state.current_run.company.find(recruit_id), + "deployed_order": state.current_run.deployed_recruit_ids.find(recruit_id), + "reserve_order": state.current_run.reserve_recruit_ids.find(recruit_id), + "survived_battles": int(record.survived_battles), + "custom_history_probe": Array(record.get("custom_history_probe", [])).duplicate(true), + } + + +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 valid or not state.operation_receipts.has(operation_id): + return {"known": false} + var stored: Dictionary = state.operation_receipts[operation_id] + if String(stored.kind) != kind or String(stored.intent_hash) != BoardService.hash_value({"kind": kind, "intent": intent}): + return {"known": true, "result": _reject("operation_conflict")} + return {"known": true, "result": { + "ok": true, + "code": String(stored.code), + "duplicate": true, + "revision": int(stored.revision), + "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.revision += 1 + state.operation_receipts[operation_id] = { + "operation_id": operation_id, + "kind": kind, + "intent_hash": BoardService.hash_value({"kind": kind, "intent": intent}), + "status": "applied", + "code": code, + "revision": state.revision, + "receipt": receipt.duplicate(true), + } + if not state.invariants_hold(): + state.restore_snapshot(before) + return _reject("postcondition_failed") + return { + "ok": true, + "code": code, + "duplicate": false, + "revision": state.revision, + "receipt": receipt.duplicate(true), + "snapshot": state.snapshot(), + } + + +func _query(code: String, receipt: Dictionary) -> Dictionary: + return { + "ok": true, + "code": code, + "duplicate": false, + "revision": state.revision, + "receipt": receipt, + "snapshot": state.snapshot(), + } + + +func _no_commit(code: String, receipt: Dictionary) -> Dictionary: + return { + "ok": true, + "code": code, + "duplicate": true, + "revision": state.revision, + "receipt": receipt, + "snapshot": state.snapshot(), + } + + +func _reject(code: String) -> Dictionary: + return { + "ok": false, + "code": code, + "duplicate": false, + "revision": state.revision if valid else -1, + } diff --git a/runtime/p10_3/growth_service.gd.uid b/runtime/p10_3/growth_service.gd.uid new file mode 100644 index 00000000..dcd1c1a0 --- /dev/null +++ b/runtime/p10_3/growth_service.gd.uid @@ -0,0 +1 @@ +uid://bsp103growth diff --git a/runtime/p10_3/market_board_service.gd b/runtime/p10_3/market_board_service.gd new file mode 100644 index 00000000..790ba149 --- /dev/null +++ b/runtime/p10_3/market_board_service.gd @@ -0,0 +1,64 @@ +class_name P103MarketBoardService +extends RefCounted + +const Generator = preload("res://p10_3/deep_market_generator.gd") +const RecruitMaterializer = preload("res://p10_3/recruit_materializer.gd") + +var generator = Generator.new() +var recruits = RecruitMaterializer.new() + + +func validation() -> Dictionary: + return generator.validation() + + +func generate(run_seed: String, market_index: int, refresh_index: int = 0) -> Dictionary: + var overlay: Dictionary = generator.generate(run_seed, market_index, refresh_index) + if not overlay.ok: + return overlay + var materialized := recruits.materialize( + run_seed, + market_index, + refresh_index, + Array(overlay.board.recruit_slots).duplicate(true) + ) + if not materialized.ok: + return materialized + var board: Dictionary = Dictionary(overlay.board).duplicate(true) + board.erase("recruit_slots") + board["recruit_offers"] = Array(materialized.offers).duplicate(true) + board["generation_authority"] = { + "provider": String(materialized.materialization.provider), + "materialization_hash": String(materialized.materialization.materialization_hash), + } + for offer in board.equipment_offers: + offer["instance_id"] = hash_value({ + "run_seed": run_seed, + "label": "p10.3.equipment-instance", + "offer_id": String(offer.offer_id), + }).substr(0, 32) + board.erase("board_hash") + board["board_hash"] = hash_value(board) + return {"ok": true, "code": "bound_market_generated", "board": board} + + +static func hash_value(value) -> String: + return JSON.stringify(_canonical(value)).sha256_text() + + +static 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 diff --git a/runtime/p10_3/market_board_service.gd.uid b/runtime/p10_3/market_board_service.gd.uid new file mode 100644 index 00000000..937c9f64 --- /dev/null +++ b/runtime/p10_3/market_board_service.gd.uid @@ -0,0 +1 @@ +uid://bsp103marketboard diff --git a/runtime/p10_3/recruit_materializer.gd b/runtime/p10_3/recruit_materializer.gd new file mode 100644 index 00000000..b11b43aa --- /dev/null +++ b/runtime/p10_3/recruit_materializer.gd @@ -0,0 +1,139 @@ +class_name P103RecruitMaterializer +extends RefCounted + +const BaseContent = preload("res://p10_0/content_registry.gd") +const BaseGenerator = preload("res://p10_0/offer_generator.gd") +const BaseMarket = preload("res://p10_0/market_service.gd") + +const PROVIDER_CONTRACT_ID := "CTR-R1-01" + +var _generator = BaseGenerator.new(BaseContent.snapshot()) + + +func materialize( + run_seed: String, + market_index: int, + refresh_index: int, + slots: Array +) -> Dictionary: + if not _is_hex(run_seed, 32): + return {"ok": false, "code": "invalid_run_seed"} + if market_index < 5 or market_index > 7 or refresh_index < 0: + return {"ok": false, "code": "invalid_market_inputs"} + if slots.size() != 3: + return {"ok": false, "code": "recruit_slot_count"} + for slot_index in range(slots.size()): + var slot = slots[slot_index] + if ( + not slot is Dictionary + or int(slot.get("slot", -1)) != slot_index + or not _is_hex(String(slot.get("slot_id", "")), 32) + or not _is_hex(String(slot.get("generation_key", "")), 64) + or int(slot.get("offer_level", 0)) < 1 + ): + return {"ok": false, "code": "invalid_recruit_slot"} + + var generated: Dictionary = _generator.market_board( + run_seed, + PROVIDER_CONTRACT_ID, + market_index, + refresh_index + ) + if not generated.ok or generated.board.recruit_offers.size() != 3: + return {"ok": false, "code": "base_recruit_generation_failed"} + + var offers: Array[Dictionary] = [] + for slot_index in range(3): + var slot: Dictionary = slots[slot_index] + var base: Dictionary = Dictionary(generated.board.recruit_offers[slot_index]).duplicate(true) + var upstream_level := int(base.level) + var offer_level := int(slot.offer_level) + var recruit_id := _low128(_digest(String(slot.generation_key), "p10.3.recruit-instance")) + base["upstream_recruit_id"] = String(base.recruit_id) + base["upstream_source_offer_id"] = String(base.source_offer_id) + base["upstream_generation_key"] = String(base.generation_key) + base["upstream_market_id"] = String(base.market_id) + base["recruit_id"] = recruit_id + base["recruit_instance_id"] = recruit_id + base["generation_key"] = String(slot.generation_key).substr(0, 32) + base["generation_seed"] = String(slot.generation_key).substr(0, 32) + base["source_offer_id"] = String(slot.slot_id) + base["market_id"] = "MKT-CTR-R2-01-%02d-R%02d" % [market_index, refresh_index] + base["region_id"] = "RG-02" + base["contract_id"] = "CTR-R2-01" + base["offer_index"] = slot_index + base["offer_level"] = offer_level + base["profession_tier"] = 0 + base["advanced_profession_allowed"] = bool(slot.advanced_profession_allowed) + base["level"] = offer_level + base["xp"] = 0 + base["max_hp"] = int(base.max_hp) + 4 * (offer_level - upstream_level) + base["current_hp"] = int(base.max_hp) + base["equipment_instance_id"] = _low128(_digest( + String(slot.generation_key), + "p10.3.starting-equipment-instance", + [String(base.starting_equipment_id)] + )) + base["list_price"] = BaseMarket.hire_list_price(offer_level, 0) + base["coin_price_paid"] = 0 + base["state"] = "market_offer" + base.erase("canonical_output_hash") + base["canonical_output_hash"] = _hash(base) + offers.append(base) + + var materialization := { + "run_seed": run_seed, + "region_id": "RG-02", + "contract_id": "CTR-R2-01", + "market_index": market_index, + "refresh_index": refresh_index, + "provider": "p10_0.offer_generator.recruit_projection.v3", + "provider_contract_id": PROVIDER_CONTRACT_ID, + "offers": offers, + } + materialization["materialization_hash"] = _hash(materialization) + return { + "ok": true, + "code": "recruits_materialized", + "offers": offers, + "materialization": materialization, + } + + +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 _digest(seed: String, label: String, parts: Array = []) -> String: + return _hash({"seed": seed, "label": label, "parts": parts}) + + +func _low128(value: String) -> String: + return value.substr(0, 32) + + +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 diff --git a/runtime/p10_3/recruit_materializer.gd.uid b/runtime/p10_3/recruit_materializer.gd.uid new file mode 100644 index 00000000..c45eb138 --- /dev/null +++ b/runtime/p10_3/recruit_materializer.gd.uid @@ -0,0 +1 @@ +uid://bsp103recruits diff --git a/runtime/p10_3/run_state.gd b/runtime/p10_3/run_state.gd new file mode 100644 index 00000000..94eeab33 --- /dev/null +++ b/runtime/p10_3/run_state.gd @@ -0,0 +1,332 @@ +class_name P103RunState +extends "res://p10_2/run_state.gd" + +const P102State = preload("res://p10_2/run_state.gd") + +const P103_STATE_KIND := "p103_siltwake_deep_channels" +const P103_RULESET_VERSION := "p10.3-siltwake-deep-channels-r1" +const ARTIFACT_IDS := ["AR-011", "AR-012", "AR-015"] + + +func initialize_from_p102(p102_snapshot: Dictionary, market_board: Dictionary) -> bool: + var probe := P102State.new() + if not probe.restore_snapshot(p102_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_5" + or int(previous_run.market_index) != 5 + or not bool(previous_run.reed_ferry_restored) + or String(market_board.get("run_seed", "")) != String(previous_run.run_seed) + or int(market_board.get("market_index", -1)) != 5 + or int(market_board.get("refresh_index", -1)) != 0 + ): + return false + + state_kind = P103_STATE_KIND + ruleset = P103_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.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 = previous_run.duplicate(true) + current_run.phase = "market" + current_run.market_board = market_board.duplicate(true) + current_run.refresh_count = 0 + current_run.available_recruit_offer_ids = _offer_ids(market_board.recruit_offers, "source_offer_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.active_artifact_ids = [] + current_run.artifact_rule_state = {} + current_run.artifact_draft = {} + current_run.promotion_draft = {} + for recruit_id in current_run.company: + var record: Dictionary = current_run.recruit_records[recruit_id] + var origin := String(record.get("base_profession_id", record.profession_id)) + record["origin_profession_id"] = origin + record["base_profession_id"] = origin + record["promotion_state"] = "base" + record["promotion_history"] = [] + current_run.recruit_records[recruit_id] = record + return invariants_hold() + + +func invariants_hold() -> bool: + if state_kind != P103_STATE_KIND or ruleset != P103_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 _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", + "active_artifact_ids", "artifact_rule_state", "artifact_draft", + "promotion_draft", + ] + 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", + "artifact_rule_state", "artifact_draft", "promotion_draft", + ]: + 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 _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) != "market": + return false + var market_index := int(current_run.market_index) + var cleared_rounds := int(current_run.cleared_rounds) + var expected_capacity: int = {5: 6, 6: 6, 7: 7}.get(market_index, -1) + if market_index < 5 or market_index > 7 or int(current_run.round_index) != market_index: + return false + if cleared_rounds != market_index - 1 or int(current_run.line_capacity) != expected_capacity: + return false + if int(current_run.coin) < 0 or int(current_run.replacement_credit) < 0 or int(current_run.refresh_count) < 0: + return false + if not bool(current_run.reed_ferry_restored): + return false + if current_run.route_history.size() != cleared_rounds or current_run.first_crossing_handoff.is_empty(): + return false + if not String(current_run.selected_route_id).is_empty() or not current_run.pending_encounter.is_empty(): + return false + if not String(current_run.pre_battle_checkpoint_id).is_empty() or bool(current_run.recovery_required) or int(current_run.recovery_cost) != 0: + return false + return ( + _board_invariants_hold() + and _company_invariants_hold() + and _artifact_invariants_hold() + and _promotion_invariants_hold() + ) + + +func _board_invariants_hold() -> bool: + var board: Dictionary = current_run.market_board + var expected_offer_level: int = { + 5: 2, + 6: 2, + 7: 3, + }.get(int(current_run.market_index), -1) + 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", "growth_disclosure", + "generation_authority", + ]: + if not board.has(key): + return false + if ( + String(board.run_seed) != String(current_run.run_seed) + or String(board.contract_id) != "CTR-R2-01" + or String(board.region_id) != "RG-02" + 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) + or int(board.cleared_rounds_before) != int(current_run.cleared_rounds) + or int(board.offer_level) != expected_offer_level + ): + 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 candidate := board.duplicate(true) + var stored_hash := String(candidate.board_hash) + candidate.erase("board_hash") + if stored_hash != _hash(candidate): + return false + var expected_milestone: String = { + 5: "linked_positions", + 6: "artifact_after_victory", + 7: "first_promotion", + }.get(int(current_run.market_index), "") + if ( + not board.growth_disclosure is Dictionary + or String(board.growth_disclosure.get("milestone", "")) != expected_milestone + ): + 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") + var lock_authority: Array = recruit_ids + equipment_ids + return ( + _subset(current_run.available_recruit_offer_ids, recruit_ids) + and _subset(current_run.available_equipment_offer_ids, equipment_ids) + and _subset(current_run.available_battle_offer_ids, battle_ids) + and _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: + if not super._company_invariants_hold(): + return false + for recruit_id in current_run.company: + var record: Dictionary = current_run.recruit_records[recruit_id] + var origin := String(record.get("origin_profession_id", "")) + var current := String(record.get("profession_id", "")) + var promotion_state := String(record.get("promotion_state", "")) + if not origin.begins_with("PF-B") or String(record.get("base_profession_id", "")) != origin: + return false + if not record.get("promotion_history", []) is Array: + return false + if promotion_state == "base" and (current != origin or not record.promotion_history.is_empty()): + return false + if promotion_state == "regular_advanced" and (not current.begins_with("PF-A") or record.promotion_history.size() != 1): + return false + if promotion_state not in ["base", "regular_advanced"]: + return false + return true + + +func _artifact_invariants_hold() -> bool: + var active: Array = current_run.active_artifact_ids + var rules: Dictionary = current_run.artifact_rule_state + if not _unique(active) or active.size() > 1 or rules.size() != active.size(): + return false + for artifact_id in active: + if artifact_id not in ARTIFACT_IDS or not rules.has(artifact_id): + return false + if ( + String(rules[artifact_id].get("artifact_id", "")) != artifact_id + or int(rules[artifact_id].get("activated_market_index", -1)) != 7 + or not rules[artifact_id].get("rule", {}) is Dictionary + or rules[artifact_id].get("rule", {}).is_empty() + ): + return false + var draft: Dictionary = current_run.artifact_draft + if int(current_run.market_index) < 7: + return draft.is_empty() and active.is_empty() + if draft.is_empty(): + return false + var status := String(draft.get("status", "")) + if ( + String(draft.get("draft_id", "")) != "AD-R2-M07-01" + or String(draft.get("run_seed", "")) != String(current_run.run_seed) + or draft.get("offer_ids", []) != ARTIFACT_IDS + or int(draft.get("created_after_round", -1)) != 6 + or int(draft.get("apply_from_market", -1)) != 7 + or String(draft.get("draft_hash", "")) != _artifact_draft_hash(draft) + or status not in ["available", "deferred", "selected"] + ): + return false + var selected := String(draft.get("selected_artifact_id", "")) + if status == "selected": + return active == [selected] and selected in ARTIFACT_IDS + return selected.is_empty() and active.is_empty() + + +func _promotion_invariants_hold() -> bool: + var draft: Dictionary = current_run.promotion_draft + if draft.is_empty(): + return true + if int(current_run.market_index) != 7: + return false + var recruit_id := String(draft.get("recruit_id", "")) + var options = draft.get("option_ids", []) + var status := String(draft.get("status", "")) + var record: Dictionary = current_run.recruit_records.get(recruit_id, {}) + var current_profession := String(draft.get("current_profession_id", "")) + if ( + recruit_id not in current_run.company + or record.is_empty() + or String(draft.get("origin_profession_id", "")) != String(record.get("origin_profession_id", "")) + or current_profession != String(record.get("origin_profession_id", "")) + or not options is Array + or options != _expected_descendants(current_profession) + or String(draft.get("draft_hash", "")) != _promotion_draft_hash(draft) + or status not in ["available", "deferred", "selected"] + ): + return false + var chosen := String(draft.get("chosen_profession_id", "")) + if status == "selected": + return ( + chosen in options + and String(record.profession_id) == chosen + and String(record.promotion_state) == "regular_advanced" + ) + return ( + chosen.is_empty() + and String(record.profession_id) == current_profession + and String(record.promotion_state) == "base" + ) + + +func _artifact_draft_hash(draft: Dictionary) -> String: + var projection := draft.duplicate(true) + for key in ["draft_hash", "status", "selected_artifact_id"]: + projection.erase(key) + return _hash(projection) + + +func _promotion_draft_hash(draft: Dictionary) -> String: + var projection := draft.duplicate(true) + for key in ["draft_hash", "status", "chosen_profession_id"]: + projection.erase(key) + return _hash(projection) + + +func _expected_descendants(base_profession_id: String) -> Array[String]: + if not base_profession_id.begins_with("PF-B"): + return [] + var base_index := int(base_profession_id.trim_prefix("PF-B")) + if base_index < 1 or base_index > 12: + return [] + return [ + "PF-A%02d" % (2 * base_index - 1), + "PF-A%02d" % (2 * base_index), + ] diff --git a/runtime/p10_3/run_state.gd.uid b/runtime/p10_3/run_state.gd.uid new file mode 100644 index 00000000..044f689f --- /dev/null +++ b/runtime/p10_3/run_state.gd.uid @@ -0,0 +1 @@ +uid://bsp103runstate diff --git a/runtime/p10_3/save_v9_service.gd b/runtime/p10_3/save_v9_service.gd new file mode 100644 index 00000000..82f3e6af --- /dev/null +++ b/runtime/p10_3/save_v9_service.gd @@ -0,0 +1,248 @@ +class_name P103SaveV9Service +extends RefCounted + +const P102Save = preload("res://p10_2/save_v8_service.gd") +const BoardService = preload("res://p10_3/market_board_service.gd") +const State = preload("res://p10_3/run_state.gd") + +const SAVE_VERSION := 9 +const SAVE_KIND := "aetherbound_guild_p10" +const OFFLINE_ZERO := { + "coin": 0, + "credit": 0, + "xp": 0, + "renown": 0, + "healing": 0, + "battle_ticks": 0, +} + + +func encode_snapshot( + snapshot_value: Dictionary, + sequence: int, + previous_commit_hash: String = "" +) -> Dictionary: + var probe := State.new() + if not probe.restore_snapshot(snapshot_value): + return {"ok": false, "code": "invalid_state", "text": ""} + if sequence < 0: + return {"ok": false, "code": "invalid_sequence", "text": ""} + if not previous_commit_hash.is_empty() and not _is_hex(previous_commit_hash, 64): + return {"ok": false, "code": "invalid_previous_hash", "text": ""} + var envelope := { + "save_kind": SAVE_KIND, + "save_version": SAVE_VERSION, + "sequence": sequence, + "previous_commit_hash": previous_commit_hash, + "state": probe.snapshot(), + } + envelope["state_hash"] = _hash(envelope.state) + envelope["commit_hash"] = _hash({ + "sequence": sequence, + "previous_commit_hash": previous_commit_hash, + "state_hash": envelope.state_hash, + }) + envelope["checksum"] = _hash(envelope) + return { + "ok": true, + "code": "encoded", + "text": JSON.stringify(_canonical(envelope)), + "envelope": envelope, + } + + +func decode_text(text: String, minimum_sequence: int = -1) -> Dictionary: + if text.is_empty(): + return {"ok": false, "code": "save_missing"} + var parser := JSON.new() + if parser.parse(text) != OK or not parser.data is Dictionary: + return {"ok": false, "code": "save_malformed"} + var envelope: Dictionary = parser.data + for key in [ + "save_kind", "save_version", "sequence", "previous_commit_hash", + "state", "state_hash", "commit_hash", "checksum", + ]: + if not envelope.has(key): + return {"ok": false, "code": "save_field_missing:%s" % key} + if String(envelope.save_kind) != SAVE_KIND: + return {"ok": false, "code": "save_kind_unknown"} + if int(envelope.save_version) != SAVE_VERSION: + return {"ok": false, "code": "save_version_unknown"} + if int(envelope.sequence) < minimum_sequence: + return { + "ok": false, + "code": "rollback_rejected", + "sequence": int(envelope.sequence), + } + var checksum := String(envelope.checksum) + var checksum_input := envelope.duplicate(true) + checksum_input.erase("checksum") + if not _is_hex(checksum, 64) or _hash(checksum_input) != checksum: + return {"ok": false, "code": "checksum_mismatch"} + if String(envelope.state_hash) != _hash(envelope.state): + return {"ok": false, "code": "state_hash_mismatch"} + var expected_commit := _hash({ + "sequence": int(envelope.sequence), + "previous_commit_hash": String(envelope.previous_commit_hash), + "state_hash": String(envelope.state_hash), + }) + if String(envelope.commit_hash) != expected_commit: + return {"ok": false, "code": "commit_hash_mismatch"} + var state_probe := State.new() + if not envelope.state is Dictionary or not state_probe.restore_snapshot(envelope.state): + return {"ok": false, "code": "state_invalid"} + return { + "ok": true, + "code": "loaded", + "sequence": int(envelope.sequence), + "commit_hash": String(envelope.commit_hash), + "snapshot": state_probe.snapshot(), + "offline_delta": OFFLINE_ZERO.duplicate(true), + } + + +func migrate_v8_text(text: String, minimum_sequence: int = -1) -> Dictionary: + var decoded: Dictionary = P102Save.new().decode_text(text, minimum_sequence) + if not decoded.ok: + return {"ok": false, "code": "v8_%s" % String(decoded.code)} + var previous_run: Dictionary = decoded.snapshot.current_run + if ( + previous_run.is_empty() + or String(previous_run.phase) != "market_5" + or int(previous_run.market_index) != 5 + or not bool(previous_run.reed_ferry_restored) + ): + return {"ok": false, "code": "v8_saved_market_5_required"} + var generated := BoardService.new().generate(String(previous_run.run_seed), 5, 0) + if not generated.ok: + return {"ok": false, "code": "market_5_generation_failed"} + var state := State.new() + if not state.initialize_from_p102(decoded.snapshot, generated.board): + return {"ok": false, "code": "v8_state_rejected"} + var encoded := encode_snapshot( + state.snapshot(), + int(decoded.sequence) + 1, + String(decoded.commit_hash) + ) + if not encoded.ok: + return {"ok": false, "code": "v9_encode_failed"} + return { + "ok": true, + "code": "v8_migrated_to_v9", + "text": String(encoded.text), + "envelope": encoded.envelope, + "snapshot": state.snapshot(), + "source_sequence": int(decoded.sequence), + "sequence": int(decoded.sequence) + 1, + "previous_commit_hash": String(decoded.commit_hash), + "offline_delta": OFFLINE_ZERO.duplicate(true), + } + + +func load_with_backup( + active_text: String, + backup_text: String, + minimum_sequence: int = -1 +) -> Dictionary: + var active := decode_text(active_text, minimum_sequence) + if active.ok: + active["source"] = "active" + return active + var backup := decode_text(backup_text, minimum_sequence) + if backup.ok: + backup["source"] = "backup" + backup["active_failure"] = String(active.code) + return backup + return { + "ok": false, + "code": "no_valid_save", + "active_failure": String(active.code), + "backup_failure": String(backup.code), + } + + +func write_slot( + active_path: String, + snapshot_value: Dictionary, + sequence: int, + previous_commit_hash: String = "" +) -> Dictionary: + var encoded := encode_snapshot(snapshot_value, sequence, previous_commit_hash) + if not encoded.ok: + return encoded + var candidate_path := "%s.candidate" % active_path + var backup_path := "%s.bak" % active_path + var candidate := FileAccess.open(candidate_path, FileAccess.WRITE) + if candidate == null: + return {"ok": false, "code": "candidate_open_failed"} + candidate.store_string(String(encoded.text)) + candidate.flush() + candidate.close() + var verified := decode_text(FileAccess.get_file_as_string(candidate_path), sequence) + if not verified.ok or String(verified.commit_hash) != String(encoded.envelope.commit_hash): + return {"ok": false, "code": "candidate_verify_failed"} + if FileAccess.file_exists(backup_path): + DirAccess.remove_absolute(backup_path) + if FileAccess.file_exists(active_path): + var backup_error := DirAccess.rename_absolute(active_path, backup_path) + if backup_error != OK: + return {"ok": false, "code": "backup_rotate_failed"} + var promote_error := DirAccess.rename_absolute(candidate_path, active_path) + if promote_error != OK: + if FileAccess.file_exists(backup_path) and not FileAccess.file_exists(active_path): + DirAccess.rename_absolute(backup_path, active_path) + return {"ok": false, "code": "candidate_promote_failed"} + return { + "ok": true, + "code": "saved", + "sequence": sequence, + "commit_hash": String(encoded.envelope.commit_hash), + "active_path": active_path, + "backup_path": backup_path, + } + + +func load_slot(active_path: String, minimum_sequence: int = -1) -> Dictionary: + var active_text := ( + FileAccess.get_file_as_string(active_path) + if FileAccess.file_exists(active_path) + else "" + ) + var backup_path := "%s.bak" % active_path + var backup_text := ( + FileAccess.get_file_as_string(backup_path) + if FileAccess.file_exists(backup_path) + else "" + ) + return load_with_backup(active_text, backup_text, minimum_sequence) + + +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 diff --git a/runtime/p10_3/save_v9_service.gd.uid b/runtime/p10_3/save_v9_service.gd.uid new file mode 100644 index 00000000..0b735700 --- /dev/null +++ b/runtime/p10_3/save_v9_service.gd.uid @@ -0,0 +1 @@ +uid://bsp103savev9 diff --git a/runtime/tests/p10_3/deep_channels_fixture.gd b/runtime/tests/p10_3/deep_channels_fixture.gd new file mode 100644 index 00000000..4ea783f0 --- /dev/null +++ b/runtime/tests/p10_3/deep_channels_fixture.gd @@ -0,0 +1,98 @@ +class_name P103DeepChannelsFixture +extends RefCounted + +const P102Fixture = preload("res://tests/p10_2/mid_contract_fixture.gd") +const SaveV8 = preload("res://p10_2/save_v8_service.gd") +const BoardService = preload("res://p10_3/market_board_service.gd") +const GrowthService = preload("res://p10_3/growth_service.gd") +const SaveV9 = preload("res://p10_3/save_v9_service.gd") +const State = preload("res://p10_3/run_state.gd") + + +func p102_market_5_snapshot() -> Dictionary: + var service = P102Fixture.new().market_service_at(5) + return service.state.snapshot() if service != null else {} + + +func encoded_v8(sequence: int = 20) -> Dictionary: + return SaveV8.new().encode_snapshot(p102_market_5_snapshot(), sequence) + + +func migrated_snapshot(sequence: int = 20) -> Dictionary: + var source := encoded_v8(sequence) + if not source.ok: + return {} + var migrated := SaveV9.new().migrate_v8_text(String(source.text), sequence) + return migrated.snapshot if migrated.ok else {} + + +func market_7_snapshot(eligible: bool = true) -> Dictionary: + var raw := migrated_snapshot() + if raw.is_empty(): + return {} + var generated := BoardService.new().generate(String(raw.current_run.run_seed), 7, 0) + if not generated.ok: + return {} + var run: Dictionary = raw.current_run + run.phase = "market" + run.market_index = 7 + run.round_index = 7 + run.cleared_rounds = 6 + run.line_capacity = 7 + run.refresh_count = 0 + run.market_board = Dictionary(generated.board).duplicate(true) + run.available_recruit_offer_ids = _offer_ids(generated.board.recruit_offers, "source_offer_id") + run.available_equipment_offer_ids = _offer_ids(generated.board.equipment_offers, "offer_id") + run.available_battle_offer_ids = _offer_ids(generated.board.battle_offers, "offer_id") + run.locked_offer_ids = [] + run.selected_route_id = "" + run.pending_encounter = {} + run.pre_battle_checkpoint_id = "" + run.recovery_required = false + run.recovery_cost = 0 + for round_index in [5, 6]: + run.route_history.append({ + "round_index": round_index, + "route_id": "RO-R2-M%02d-Q0" % round_index, + "plan_id": "fixture_only", + "outcome": "victory", + "battle_hash": "%s|%d" % [run.run_seed, round_index], + "reward_coin": 18 + 2 * round_index + 2, + }) + run.artifact_draft = GrowthService.artifact_draft_for(String(run.run_seed)) + run.active_artifact_ids = [] + run.artifact_rule_state = {} + run.promotion_draft = {} + var candidate_id := "" + for recruit_id in run.company: + var record: Dictionary = run.recruit_records[recruit_id] + record.level = mini(int(record.level), 3) + record.survived_battles = mini(int(record.survived_battles), 2) + if String(record.profession_id) == "PF-B07" and candidate_id.is_empty(): + candidate_id = String(recruit_id) + run.recruit_records[recruit_id] = record + if eligible: + if candidate_id.is_empty(): + return {} + var candidate: Dictionary = run.recruit_records[candidate_id] + candidate.level = 4 + candidate.xp = 8 + candidate.survived_battles = 3 + candidate.current_hp = maxi(1, int(floor(float(candidate.max_hp) * 0.5))) + candidate.custom_history_probe = ["round-1", "round-3", "round-6"] + run.recruit_records[candidate_id] = candidate + raw.current_run = run + var probe := State.new() + return probe.snapshot() if probe.restore_snapshot(raw) else {} + + +func growth_service(eligible: bool = true): + var snapshot_value := market_7_snapshot(eligible) + return GrowthService.new(snapshot_value) if not snapshot_value.is_empty() else null + + +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 diff --git a/runtime/tests/p10_3/deep_channels_fixture.gd.uid b/runtime/tests/p10_3/deep_channels_fixture.gd.uid new file mode 100644 index 00000000..8ddb134f --- /dev/null +++ b/runtime/tests/p10_3/deep_channels_fixture.gd.uid @@ -0,0 +1 @@ +uid://bsp103fixture diff --git a/runtime/tests/p10_3/growth_choices_test.gd b/runtime/tests/p10_3/growth_choices_test.gd new file mode 100644 index 00000000..dbd16ba9 --- /dev/null +++ b/runtime/tests/p10_3/growth_choices_test.gd @@ -0,0 +1,143 @@ +extends SceneTree + +const Growth = preload("res://p10_3/growth_service.gd") +const SaveV9 = preload("res://p10_3/save_v9_service.gd") +const Fixture = preload("res://tests/p10_3/deep_channels_fixture.gd") + +var failures: Array[String] = [] +var applied_operations := 0 +var rejected_cases := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + verify_profession_tree() + verify_artifact_choices() + verify_artifact_defer_and_reload() + verify_promotion_defer_reload_and_confirm() + verify_atomic_boundaries() + if failures.is_empty(): + print("ABG_P10_3_GROWTH_CHOICES_OK artifacts=AR-011,AR-012,AR-015 capacities=6,8,7 artifact_defer_resume=true professions=12x2 first_eligible=PF-B07 options=PF-A13,PF-A14 promotion_defer_resume=true identity_hp_trait_item_order_history=preserved receipts=exact applied=%d rejected=%d" % [applied_operations, rejected_cases]) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func verify_profession_tree() -> void: + var mapping := Growth.profession_descendants() + expect(mapping.size() == 12, "all twelve base Profession families are present") + expect(Growth.artifact_draft_for("bad-seed").is_empty(), "malformed Run seed cannot create an Artifact draft") + for base_index in range(1, 13): + var base_id := "PF-B%02d" % base_index + var expected := ["PF-A%02d" % (2 * base_index - 1), "PF-A%02d" % (2 * base_index)] + expect(mapping.get(base_id, []) == expected, "%s has exact regular descendants %s" % [base_id, str(expected)]) + + +func verify_artifact_choices() -> void: + var expected_capacities := {"AR-011": 6, "AR-012": 8, "AR-015": 7} + for artifact_id in Growth.ARTIFACT_IDS: + var service = Fixture.new().growth_service() + expect(service != null and service.valid, "%s fixture is valid" % artifact_id) + var draft: Dictionary = service.artifact_draft().receipt + expect(draft.offer_ids == Growth.ARTIFACT_IDS and String(draft.draft_hash).length() == 64, "%s sees the exact stable draft" % artifact_id) + var revision_before := int(service.state.revision) + var selected := apply(service, "select-%s" % artifact_id, func(): return service.select_artifact("select-%s" % artifact_id, revision_before, String(draft.draft_hash), artifact_id), "select %s" % artifact_id) + expect(selected.ok and service.state.current_run.active_artifact_ids == [artifact_id], "%s becomes the sole Run rule" % artifact_id) + expect(service.state.current_run.line_capacity == 7 and service.effective_line_capacity() == expected_capacities[artifact_id], "%s changes only effective capacity" % artifact_id) + var after: Dictionary = service.state.snapshot() + var duplicate: Dictionary = service.select_artifact("select-%s" % artifact_id, revision_before, String(draft.draft_hash), artifact_id) + expect(duplicate.ok and duplicate.duplicate and service.state.snapshot() == after, "%s retry returns one stored receipt" % artifact_id) + expect_rejected(service.select_artifact("second-%s" % artifact_id, service.state.revision, String(draft.draft_hash), "AR-012" if artifact_id != "AR-012" else "AR-011"), service, after, "%s conflicting second Artifact" % artifact_id, "artifact_already_selected") + + +func verify_artifact_defer_and_reload() -> void: + var service = Fixture.new().growth_service() + var draft: Dictionary = service.artifact_draft().receipt + apply(service, "defer-artifact", func(): return service.defer_artifact("defer-artifact", service.state.revision, String(draft.draft_hash)), "defer Artifact") + expect(service.state.current_run.artifact_draft.status == "deferred" and service.state.current_run.artifact_draft.offer_ids == Growth.ARTIFACT_IDS, "deferral preserves the exact Artifact draft") + expect(service.route_growth_ready().receipt.ready, "explicit Artifact deferral unblocks a Round-7 route") + var encoded := SaveV9.new().encode_snapshot(service.state.snapshot(), 30) + var decoded := SaveV9.new().decode_text(String(encoded.text), 30) + expect(decoded.ok and decoded.snapshot.current_run.artifact_draft == service.state.current_run.artifact_draft, "deferred Artifact survives Save v9 reload") + var resumed = Growth.new(decoded.snapshot) + apply(resumed, "resume-artifact", func(): return resumed.select_artifact("resume-artifact", resumed.state.revision, String(draft.draft_hash), "AR-015"), "resume deferred Artifact") + expect(resumed.state.current_run.active_artifact_ids == ["AR-015"] and resumed.effective_line_capacity() == 7, "deferred Artifact can later be selected without redraw") + + +func verify_promotion_defer_reload_and_confirm() -> void: + var service = Fixture.new().growth_service() + var artifact_hash := String(service.artifact_draft().receipt.draft_hash) + apply(service, "promotion-artifact-defer", func(): return service.defer_artifact("promotion-artifact-defer", service.state.revision, artifact_hash), "defer Artifact before promotion") + var opened := apply(service, "open-promotion", func(): return service.open_promotion_draft("open-promotion", service.state.revision), "open first eligible promotion") + var draft: Dictionary = opened.receipt.draft + expect(draft.current_profession_id == "PF-B07" and draft.option_ids == ["PF-A13", "PF-A14"], "first eligible Kindhand receives Fieldmender and Aftercare") + var recruit_id := String(draft.recruit_id) + var before_record: Dictionary = service.state.current_run.recruit_records[recruit_id].duplicate(true) + var company_before: Array = Array(service.state.current_run.company).duplicate() + var deployed_before: Array = Array(service.state.current_run.deployed_recruit_ids).duplicate() + var reserve_before: Array = Array(service.state.current_run.reserve_recruit_ids).duplicate() + var item_before := String(service.state.current_run.equipped_by_recruit[recruit_id]) + apply(service, "defer-promotion", func(): return service.defer_promotion("defer-promotion", service.state.revision, String(draft.draft_hash)), "defer promotion") + var encoded := SaveV9.new().encode_snapshot(service.state.snapshot(), 31) + var decoded := SaveV9.new().decode_text(String(encoded.text), 31) + expect(decoded.ok and decoded.snapshot.current_run.promotion_draft.status == "deferred", "deferred promotion survives Save v9 reload") + var resumed = Growth.new(decoded.snapshot) + var promoted := apply(resumed, "confirm-promotion", func(): return resumed.confirm_promotion("confirm-promotion", resumed.state.revision, String(draft.draft_hash), recruit_id, "PF-B07", "PF-A14"), "confirm Aftercare") + var after_record: Dictionary = resumed.state.current_run.recruit_records[recruit_id] + expect(promoted.ok and after_record.profession_id == "PF-A14" and after_record.origin_profession_id == "PF-B07", "promotion changes only current Profession authority") + expect(after_record.recruit_id == before_record.recruit_id and after_record.trait_id == before_record.trait_id, "promotion preserves Recruit ID and Trait") + expect(after_record.level == before_record.level and after_record.xp == before_record.xp and after_record.current_hp == before_record.current_hp and after_record.max_hp == before_record.max_hp, "promotion preserves level, XP and exact HP ratio") + expect(resumed.state.current_run.equipped_by_recruit[recruit_id] == item_before and after_record.custom_history_probe == before_record.custom_history_probe, "promotion preserves equipment and history") + expect(resumed.state.current_run.company == company_before and resumed.state.current_run.deployed_recruit_ids == deployed_before and resumed.state.current_run.reserve_recruit_ids == reserve_before, "promotion preserves stable Company/Line/Reserve order") + var final_state: Dictionary = resumed.state.snapshot() + expect_rejected(resumed.confirm_promotion("promote-twice", resumed.state.revision, String(draft.draft_hash), recruit_id, "PF-A14", "PF-A13"), resumed, final_state, "second permanent promotion", "promotion_already_selected") + + +func verify_atomic_boundaries() -> void: + var pending = Fixture.new().growth_service() + var pending_before: Dictionary = pending.state.snapshot() + expect_rejected(pending.open_promotion_draft("blocked-promotion", pending.state.revision), pending, pending_before, "promotion before Artifact decision", "artifact_decision_required") + var draft_hash := String(pending.artifact_draft().receipt.draft_hash) + expect_rejected(pending.select_artifact("", pending.state.revision, draft_hash, "AR-011"), pending, pending_before, "empty operation ID", "operation_id_required") + expect_rejected(pending.select_artifact("bad-artifact", pending.state.revision, draft_hash, "AR-999"), pending, pending_before, "unknown Artifact", "artifact_unavailable") + expect_rejected(pending.defer_artifact("bad-hash", pending.state.revision, "0".repeat(64)), pending, pending_before, "wrong Artifact draft hash", "artifact_draft_hash_mismatch") + + var no_candidate = Fixture.new().growth_service(false) + var no_candidate_hash := String(no_candidate.artifact_draft().receipt.draft_hash) + apply(no_candidate, "no-candidate-defer", func(): return no_candidate.defer_artifact("no-candidate-defer", no_candidate.state.revision, no_candidate_hash), "defer Artifact without eligible Recruit") + var no_candidate_before: Dictionary = no_candidate.state.snapshot() + expect_rejected(no_candidate.open_promotion_draft("no-candidate", no_candidate.state.revision), no_candidate, no_candidate_before, "no eligible promotion", "promotion_not_eligible") + + var wrong_option = Fixture.new().growth_service() + var wrong_hash := String(wrong_option.artifact_draft().receipt.draft_hash) + apply(wrong_option, "wrong-option-defer", func(): return wrong_option.defer_artifact("wrong-option-defer", wrong_option.state.revision, wrong_hash), "defer Artifact for wrong-option fixture") + var opened := apply(wrong_option, "wrong-option-open", func(): return wrong_option.open_promotion_draft("wrong-option-open", wrong_option.state.revision), "open wrong-option fixture") + var promotion: Dictionary = opened.receipt.draft + var wrong_before: Dictionary = wrong_option.state.snapshot() + expect_rejected(wrong_option.confirm_promotion("wrong-option", wrong_option.state.revision, String(promotion.draft_hash), String(promotion.recruit_id), String(promotion.current_profession_id), "PF-H01"), wrong_option, wrong_before, "hidden hybrid selection", "promotion_option_unavailable") + + +func apply(service, label: String, operation: Callable, message: String) -> Dictionary: + var revision_before := int(service.state.revision) + var result: Dictionary = operation.call() + expect(result.ok and service.state.revision == revision_before + 1, "%s applies exactly one revision: %s" % [message, str(result)]) + if result.ok and service.state.revision == revision_before + 1: + applied_operations += 1 + return result + + +func expect_rejected(result: Dictionary, service, before: Dictionary, label: String, code: String) -> void: + var accepted: bool = not result.ok and result.code == code and service.state.snapshot() == before + expect(accepted, "%s rejects as %s without mutation: %s" % [label, code, str(result)]) + if accepted: + rejected_cases += 1 + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P10.3 growth choices: %s" % message) diff --git a/runtime/tests/p10_3/growth_choices_test.gd.uid b/runtime/tests/p10_3/growth_choices_test.gd.uid new file mode 100644 index 00000000..12c67cc2 --- /dev/null +++ b/runtime/tests/p10_3/growth_choices_test.gd.uid @@ -0,0 +1 @@ +uid://bsp103growthtest diff --git a/runtime/tests/p10_3/save_v9_test.gd b/runtime/tests/p10_3/save_v9_test.gd new file mode 100644 index 00000000..f3f5fab4 --- /dev/null +++ b/runtime/tests/p10_3/save_v9_test.gd @@ -0,0 +1,139 @@ +extends SceneTree + +const SaveV8 = preload("res://p10_2/save_v8_service.gd") +const SaveV9 = preload("res://p10_3/save_v9_service.gd") +const Fixture = preload("res://tests/p10_3/deep_channels_fixture.gd") + +const SLOT_PATH := "/private/tmp/abg-p103-save-v9-test.json" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + cleanup_files() + verify_exact_v8_migration_and_roundtrip() + verify_checksum_backup_and_rollback() + verify_atomic_file_rotation_and_interruption() + verify_migration_boundaries() + verify_state_corruption_boundaries() + cleanup_files() + if failures.is_empty(): + print("ABG_P10_3_SAVE_V9_OK version=9 migration=v8_market5_exact board=3,4,3 cleared=4 capacity=6 company=true route_history=4 artifact_state=true promotion_state=true checksum=true active_backup=true rollback=true candidate_interruption=true legacy_receipts=true offline=coin0,credit0,xp0,renown0,healing0,ticks0") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func verify_exact_v8_migration_and_roundtrip() -> void: + var fixture = Fixture.new() + var source_snapshot: Dictionary = fixture.p102_market_5_snapshot() + var v8 := SaveV8.new().encode_snapshot(source_snapshot, 20) + expect(v8.ok, "stable Market 5 fixture encodes as Save v8") + var saves := SaveV9.new() + var migrated := saves.migrate_v8_text(String(v8.text), 20) + expect(migrated.ok and migrated.sequence == 21 and migrated.previous_commit_hash == v8.envelope.commit_hash, "migration advances sequence and binds the v8 commit") + expect(migrated.snapshot.current_run.run_id == source_snapshot.current_run.run_id and migrated.snapshot.current_run.run_seed == source_snapshot.current_run.run_seed, "migration preserves exact Run identity") + expect(migrated.snapshot.current_run.company == source_snapshot.current_run.company and migrated.snapshot.current_run.coin == source_snapshot.current_run.coin, "migration preserves Company and Coin") + expect(migrated.snapshot.current_run.inventory == source_snapshot.current_run.inventory and migrated.snapshot.current_run.equipped_by_recruit == source_snapshot.current_run.equipped_by_recruit, "migration preserves equipment ownership") + expect(migrated.snapshot.current_run.market_index == 5 and migrated.snapshot.current_run.phase == "market" and migrated.snapshot.current_run.cleared_rounds == 4, "migration opens exact deep-channel Market 5") + expect(migrated.snapshot.current_run.line_capacity == 6 and migrated.snapshot.current_run.market_board.recruit_offers.size() == 3 and migrated.snapshot.current_run.market_board.equipment_offers.size() == 4 and migrated.snapshot.current_run.market_board.battle_offers.size() == 3, "migration binds exact capacity and 3/4/3 board") + expect(migrated.snapshot.current_run.route_history == source_snapshot.current_run.route_history, "four completed routes remain byte-equivalent") + expect(migrated.snapshot.current_run.active_artifact_ids.is_empty() and migrated.snapshot.current_run.artifact_draft.is_empty() and migrated.snapshot.current_run.promotion_draft.is_empty(), "growth state starts empty before Round 6") + for recruit_id in migrated.snapshot.current_run.company: + var before: Dictionary = source_snapshot.current_run.recruit_records[recruit_id] + var after: Dictionary = migrated.snapshot.current_run.recruit_records[recruit_id] + expect(after.origin_profession_id == before.base_profession_id and after.profession_id == before.profession_id, "%s preserves Profession identity" % recruit_id) + expect(after.level == before.level and after.xp == before.xp and after.current_hp == before.current_hp and after.max_hp == before.max_hp, "%s preserves level, XP and HP" % recruit_id) + expect(after.promotion_state == "base" and after.promotion_history.is_empty(), "%s receives an empty promotion state" % recruit_id) + for operation_id in source_snapshot.operation_receipts: + expect(migrated.snapshot.legacy_operation_receipts.has(operation_id), "P10.2 operation %s is retained" % operation_id) + expect(migrated.offline_delta == SaveV9.OFFLINE_ZERO, "migration invents no offline value") + var decoded := saves.decode_text(String(migrated.text), 21) + expect(decoded.ok and decoded.snapshot == migrated.snapshot, "migrated Save v9 roundtrips byte-equivalent") + + +func verify_checksum_backup_and_rollback() -> void: + var saves := SaveV9.new() + var snapshot_value := Fixture.new().migrated_snapshot(4) + var first := saves.encode_snapshot(snapshot_value, 5) + var selected_value := Fixture.new().market_7_snapshot() + var second := saves.encode_snapshot(selected_value, 6, String(first.envelope.commit_hash)) + expect(first.ok and second.ok, "adjacent v9 commits encode") + var corrupt = JSON.parse_string(String(second.text)) + corrupt.state.current_run.coin = 999 + var fallback := saves.load_with_backup(JSON.stringify(corrupt), String(first.text), 5) + expect(fallback.ok and fallback.source == "backup" and fallback.active_failure == "checksum_mismatch", "corrupt active falls back to valid backup") + var rollback := saves.load_with_backup(String(first.text), "", 6) + expect(not rollback.ok and rollback.active_failure == "rollback_rejected", "minimum sequence rejects rollback") + var future = JSON.parse_string(String(second.text)) + future.save_version = 10 + var rejected := saves.decode_text(JSON.stringify(future)) + expect(not rejected.ok and rejected.code == "save_version_unknown", "future Save blocks instead of resetting") + + +func verify_atomic_file_rotation_and_interruption() -> void: + var saves := SaveV9.new() + var first := saves.write_slot(SLOT_PATH, Fixture.new().migrated_snapshot(), 1) + expect(first.ok and FileAccess.file_exists(SLOT_PATH), "first write promotes verified candidate") + var second := saves.write_slot(SLOT_PATH, Fixture.new().market_7_snapshot(), 2, String(first.commit_hash)) + expect(second.ok and FileAccess.file_exists("%s.bak" % SLOT_PATH), "second write rotates active to backup") + var candidate := FileAccess.open("%s.candidate" % SLOT_PATH, FileAccess.WRITE) + candidate.store_string("interrupted partial growth bytes") + candidate.close() + var after_interruption := saves.load_slot(SLOT_PATH, 2) + expect(after_interruption.ok and after_interruption.sequence == 2 and after_interruption.snapshot.current_run.market_index == 7, "orphan candidate never overrides committed growth state") + var active := FileAccess.open(SLOT_PATH, FileAccess.WRITE) + active.store_string("corrupt active") + active.close() + var backup := saves.load_slot(SLOT_PATH, 1) + expect(backup.ok and backup.source == "backup" and backup.sequence == 1, "corrupt active falls back to committed Market-5 backup") + + +func verify_migration_boundaries() -> void: + var p102_market_4 = preload("res://tests/p10_2/mid_contract_fixture.gd").new().market_service_at(4) + var active_v8 := SaveV8.new().encode_snapshot(p102_market_4.state.snapshot(), 1) + expect(active_v8.ok, "active P10.2 Market 4 fixture is a valid v8 state") + var active := SaveV9.new().migrate_v8_text(String(active_v8.text)) + expect(not active.ok and active.code == "v8_saved_market_5_required", "active Round 4 cannot skip into deep channels") + var malformed := SaveV9.new().migrate_v8_text("not-json") + expect(not malformed.ok and malformed.code == "v8_save_malformed", "malformed v8 save rejects explicitly") + var source := Fixture.new().encoded_v8(2) + var corrupt = JSON.parse_string(String(source.text)) + corrupt.state.current_run.reed_ferry_restored = false + var rejected := SaveV9.new().migrate_v8_text(JSON.stringify(corrupt)) + expect(not rejected.ok and rejected.code == "v8_checksum_mismatch", "tampered Ferry handoff cannot migrate") + + +func verify_state_corruption_boundaries() -> void: + var saves := SaveV9.new() + var market_7 := Fixture.new().market_7_snapshot() + var wrong_seed := market_7.duplicate(true) + wrong_seed.current_run.artifact_draft.run_seed = "0".repeat(32) + expect(not saves.encode_snapshot(wrong_seed, 1).ok, "Artifact draft bound to another Run rejects") + var wrong_level := market_7.duplicate(true) + wrong_level.current_run.market_board.offer_level = 2 + wrong_level.current_run.market_board.erase("board_hash") + wrong_level.current_run.market_board.board_hash = preload("res://p10_3/market_board_service.gd").hash_value(wrong_level.current_run.market_board) + expect(not saves.encode_snapshot(wrong_level, 1).ok, "Market-7 offer-level drift rejects even with a recomputed board hash") + var wrong_growth := market_7.duplicate(true) + wrong_growth.current_run.market_board.growth_disclosure.milestone = "linked_positions" + wrong_growth.current_run.market_board.erase("board_hash") + wrong_growth.current_run.market_board.board_hash = preload("res://p10_3/market_board_service.gd").hash_value(wrong_growth.current_run.market_board) + expect(not saves.encode_snapshot(wrong_growth, 1).ok, "Market-7 growth disclosure drift rejects even with a recomputed board hash") + + +func cleanup_files() -> void: + for file_path in [SLOT_PATH, "%s.bak" % SLOT_PATH, "%s.candidate" % SLOT_PATH]: + if FileAccess.file_exists(file_path): + DirAccess.remove_absolute(file_path) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P10.3 Save v9: %s" % message) diff --git a/runtime/tests/p10_3/save_v9_test.gd.uid b/runtime/tests/p10_3/save_v9_test.gd.uid new file mode 100644 index 00000000..cf8839ac --- /dev/null +++ b/runtime/tests/p10_3/save_v9_test.gd.uid @@ -0,0 +1 @@ +uid://bsp103savev9test