feat(runtime): add P10.3 growth persistence
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsp103growth
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsp103runstate
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsp103savev9test
|
||||
Reference in New Issue
Block a user