feat(runtime): add P10.3 growth persistence

This commit is contained in:
2026-08-16 09:57:28 +08:00
parent 4f3dbe52fd
commit 26ef043bfb
16 changed files with 1639 additions and 0 deletions
+468
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://bsp103growth
+64
View File
@@ -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
@@ -0,0 +1 @@
uid://bsp103marketboard
+139
View File
@@ -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
@@ -0,0 +1 @@
uid://bsp103recruits
+332
View File
@@ -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),
]
+1
View File
@@ -0,0 +1 @@
uid://bsp103runstate
+248
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bsp103savev9
@@ -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
@@ -0,0 +1 @@
uid://bsp103fixture
+143
View File
@@ -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)
@@ -0,0 +1 @@
uid://bsp103growthtest
+139
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bsp103savev9test