diff --git a/scripts/core/save_service.gd b/scripts/core/save_service.gd index 236a8f1..a8f6308 100644 --- a/scripts/core/save_service.gd +++ b/scripts/core/save_service.gd @@ -1055,6 +1055,12 @@ static func _snapshot_package_format(snapshot: Dictionary) -> int: # Keep PurchaseQuotePlansV2's exact original allowlist and all format rules. runtime_data = runtime_data.duplicate() runtime_data.erase(&"special_peeping_rooms") + if runtime_data.has(&"mana_reserve"): + # A06 finite reserve: validate the section, then keep the exact original + # v15 purchase allowlist unchanged, as for the peeping-room extension. + if not preload("res://scripts/mana/mana_reserve_cycle.gd").new().restore_snapshot(runtime_data[&"mana_reserve"]): return -1 + runtime_data = runtime_data.duplicate() + runtime_data.erase(&"mana_reserve") if version >= 1 and version <= 14: return -1 if PurchaseQuoteV2.has_history(runtime_data) else FORMAT_VERSION if version != 15: return -1 diff --git a/tests/business/test_required_kitchen.gd b/tests/business/test_required_kitchen.gd index df535f9..9d59eb9 100644 --- a/tests/business/test_required_kitchen.gd +++ b/tests/business/test_required_kitchen.gd @@ -274,6 +274,42 @@ func _legacy_controls(writer: Dictionary, reader: Dictionary) -> void: _legacy_control(writer, reader, "byte-identical deep copy", "copy") _proof("legacy14-migration-controls", {"attempts": 17, "writer_sha256": _typed_sha(writer), "reader_sha256": _typed_sha(reader), "failures": failures}) +## Approved later save migrations applied when an older save is loaded, each +## checked against its own contract and then mapped into `expected` in place: +## A06 starts a fresh finite Mana reserve at the restored boundary, and P14-03 +## raises the elevator snapshot from v1 to v2 with every other field unchanged. +func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> Dictionary: + var old: Dictionary = expected[&"systems"][&"runtime"] + var now: Dictionary = reader[&"systems"][&"runtime"] + var fields: Array = [] + var proof := {} + var ok := true + if not old.has(&"mana_reserve") and now.has(&"mana_reserve"): + var minute := int(expected[&"clock"][&"absolute_minute"]) + var fresh := {&"version": 1, &"policy_id": &"MP-01-r02", &"capacity": 300, &"generation": 20, + &"interval_minutes": 10, &"minute": minute - minute % 10, &"last_flow": {}} + ok = ok and now[&"mana_reserve"] == fresh + old[&"mana_reserve"] = now[&"mana_reserve"].duplicate(true) + fields.append("mana_reserve_absent_to_A06_fresh_start") + proof["mana_reserve"] = {"fresh_start_minute": minute - minute % 10, "matches_contract": ok} + if old.get(&"elevators", {}).get(&"version") == 1 and now.get(&"elevators", {}).get(&"version") == 2: + var raised: Dictionary = old[&"elevators"].duplicate(true) + raised[&"version"] = 2 + var same_otherwise: bool = raised == now[&"elevators"] + ok = ok and same_otherwise + old[&"elevators"] = raised + fields.append("elevators_v1_to_v2") + proof["elevators"] = {"only_version_changed": same_otherwise} + if not fields.is_empty(): + # Saves keep the Runtime's own key order, so the mapped tree follows it. + ok = ok and old.size() == now.size() + var ordered := {} + for key: Variant in now: + ok = ok and old.has(key) + ordered[key] = old.get(key) + expected[&"systems"][&"runtime"] = ordered + return {"ok": ok, "fields": fields, "proof": proof} + func _compare_loaded(writer: Dictionary, write_path: String, read_path: String, label: String, migrate_relationships_v1: bool = false) -> void: var reader: Dictionary = runtime.create_snapshot() var old: Dictionary = writer[&"systems"][&"runtime"][&"domain_events"]["recent_events"].back().duplicate(true) @@ -286,6 +322,8 @@ func _compare_loaded(writer: Dictionary, write_path: String, read_path: String, valid = valid and var_to_bytes(event) == var_to_bytes(old) event["event_type"] = actual["event_type"]; event["payload"] = actual["payload"].duplicate(true); replaced += 1 var migration_proof := {} + var runtime_migrations := _approved_runtime_migrations(expected, reader) + valid = valid and runtime_migrations["ok"] if migrate_relationships_v1: _legacy_controls(expected, reader) var migration := _legacy_checkpoint(expected, reader) @@ -300,6 +338,8 @@ func _compare_loaded(writer: Dictionary, write_path: String, read_path: String, if migrate_relationships_v1: binding["mapped_fields"].append("relationships_v1_to_v2") binding["relationships_v1_to_v2"] = migration_proof + binding["mapped_fields"].append_array(runtime_migrations["fields"]) + binding["approved_runtime_migrations"] = runtime_migrations["proof"] _proof(label + "-io1-binding", binding) expect(valid, "whole typed loaded state unchanged except complete identity/saveID-bound IO1: " + label) diff --git a/tests/s5/test_runtime_defense_autonomy.gd b/tests/s5/test_runtime_defense_autonomy.gd index 441c3d4..53e9365 100644 --- a/tests/s5/test_runtime_defense_autonomy.gd +++ b/tests/s5/test_runtime_defense_autonomy.gd @@ -328,10 +328,17 @@ func _test_exhausted_generated_ids_fail_atomically() -> void: _expect(_runtime.id_generator.restore_snapshot(allocator), "valid near-exhausted allocator is a disclosed saved-state boundary") var before: Dictionary = _runtime.defense_service.create_snapshot() var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var mana_before: int = _state.get_resource(&"mana") + var reserve_minute_before: int = _runtime.mana_reserve.get_minute() _advance(5) + # A06: crossing a 10-minute boundary settles the finite reserve; that flow is + # the only Mana change allowed here. + var reserve_mana := mana_before + if _runtime.mana_reserve.get_minute() != reserve_minute_before: + reserve_mana = int(_runtime.mana_reserve.get_last_flow()[&"after"]) before[&"current_minute"] = _clock.absolute_minute _expect(before == _runtime.defense_service.create_snapshot() and rng_before == _runtime.get_rng_stream(&"defense").snapshot() and _clock.paused, "exhausted automatic allocator fails without advancing combat or RNG") - _expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == 100, "allocation failure charges no skill or outcome resources") + _expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == reserve_mana, "allocation failure charges no skill or outcome resources (Mana moves only by the A06 reserve flow)") _expect(_count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.id.exhausted") == 1 and _runtime.id_generator.get_counter(&"defense_round_resolution") == StableIdGenerator.MAX_COUNTER - 1, "exhaustion records one diagnostic and rolls skipped-ID allocation back") diff --git a/tests/ui/test_p08_normal_placement_ops.gd b/tests/ui/test_p08_normal_placement_ops.gd index aa5663d..62ecbbb 100644 --- a/tests/ui/test_p08_normal_placement_ops.gd +++ b/tests/ui/test_p08_normal_placement_ops.gd @@ -8,9 +8,9 @@ class PalettePlacement extends Placement: expect(palette != null, "production facility palette exists") if palette == null: return - expect(palette.get_child_count() == 23 and palette.columns == 23, - "palette has exactly 23 facility slots") - expect(main.build_buttons.size() == 23, "all 23 registered facility buttons exist") + expect(palette.get_child_count() == 25 and palette.columns == 25, + "palette has exactly 25 facility slots (A07/A08 warps added)") + expect(main.build_buttons.size() == 25, "all 25 registered facility buttons exist") for child: Node in palette.get_children(): expect(child is Button and main.build_buttons.values().has(child), "every palette child is a registered facility button") diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index 57dd50a..b0c0704 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -407,7 +407,7 @@ "new_audio_QA": false }, "tests/ui/test_p08_normal_placement_ops.gd": { - "source_sha256": "d3c246d34f8fd66f6ed237e42a86df89ebaf481bc9dee143b4de8d654ce0eb2e", + "source_sha256": "a5c5abdc1348e7dadafd70f73072ac98afcc4c918211c3389e88da5e903af803", "ordinary_cleanup_prepared": true, "ordinary_control_labels": [ "single"