From 8facb104981214b541554d8d30221467f4b61c0a Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 02:40:53 -0700 Subject: [PATCH] =?UTF-8?q?fix(save-manager):=20=F0=9F=90=9B=20Add=20valid?= =?UTF-8?q?ation=20logic=20to=20SaveManager=20after=20save=20load=20to=20p?= =?UTF-8?q?revent=20invalid=20controller=20states=20and=20update=20corresp?= =?UTF-8?q?onding=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/src/core/save_manager.gd | 28 +++- .../tests/ffi/test_controller_validation.gd | 124 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/game/engine/tests/ffi/test_controller_validation.gd diff --git a/src/game/engine/src/core/save_manager.gd b/src/game/engine/src/core/save_manager.gd index e85b94dd..129a6a70 100644 --- a/src/game/engine/src/core/save_manager.gd +++ b/src/game/engine/src/core/save_manager.gd @@ -149,7 +149,7 @@ static func load_from_path(abs_path: String) -> Error: return ERR_FILE_CORRUPT GameState.deserialize(envelope.get("game_state", {})) GameState.rebuild_layer_references() - return OK + return _validate_controllers_after_load(abs_path) static func get_save_slots() -> Array[Dictionary]: @@ -266,7 +266,31 @@ static func _read_slot(slot_name: String) -> Error: var state_entry: Dictionary = envelope.get("game_state", {}) GameState.deserialize(state_entry) GameState.rebuild_layer_references() - return OK + return _validate_controllers_after_load(slot_name) + + +## Stage 7 — after a save has been deserialised into the live GameState, +## ask the Rust simulator whether every controller id the save references +## is currently registered. Returns OK on all-clear, ERR_INVALID_DATA when +## a mismatch is found (caller surfaces a friendly dialog and aborts the +## load — silently continuing would crash on the first AI turn). +## +## Defensive: if GdGameState is unavailable (headless tests w/o gdext) or +## lacks the method (older binary), returns OK so legacy saves still load. +static func _validate_controllers_after_load(source_label: String) -> Error: + var gd_state: RefCounted = GameState.get_gd_state() + if gd_state == null: + return OK + if not gd_state.has_method("validate_save_controllers"): + return OK + var report: String = String(gd_state.call("validate_save_controllers")) + if report.is_empty(): + return OK + push_warning( + "SaveManager: '%s' references unregistered controllers: %s" + % [source_label, report] + ) + return ERR_INVALID_DATA static func _read_envelope(slot_name: String) -> Dictionary: diff --git a/src/game/engine/tests/ffi/test_controller_validation.gd b/src/game/engine/tests/ffi/test_controller_validation.gd new file mode 100644 index 00000000..879c4cc0 --- /dev/null +++ b/src/game/engine/tests/ffi/test_controller_validation.gd @@ -0,0 +1,124 @@ +extends GutTest +## Stage 7 — controller_hash mismatch detection on save load. +## +## Verifies the `GdGameState::validate_save_controllers` `#[func]` bridge: +## 1. A save whose presentation list references only built-in controller +## ids (or empty / sentinel) validates clean. +## 2. A save referencing an unknown controller id surfaces a non-empty +## error report naming both the slot and the bogus id. +## 3. The error survives the on-disk round trip: `load_from_json` of +## a hand-crafted envelope, then `validate_save_controllers`, still +## flags the bad slot — proving the load surface chains correctly +## into the validator. + + +const DEFAULT_ID: String = "scripted:default" +const BOGUS_ID: String = "fake:not-registered-anywhere" + + +func _make_state() -> RefCounted: + assert_true( + ClassDB.class_exists("GdGameState"), + "GdGameState must be registered by the GDExtension" + ) + var gs: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted + assert_not_null(gs, "ClassDB.instantiate(\"GdGameState\") returned null") + assert_true( + gs.has_method("validate_save_controllers"), + "GdGameState must expose validate_save_controllers() — gdext rebuild missed Stage 7" + ) + return gs + + +func _envelope(presentation: Array) -> String: + ## Build the minimal v2 SaveEnvelope shape `load_from_json` accepts. + ## `sim` mirrors `mc_turn::GameState::default()` — empty players list, + ## zero turn/era — which is sufficient because the validator only + ## inspects the presentation side-table. + var env: Dictionary = { + "save_format_version": 2, + "sim": { + "turn": 0, + "era": 0, + "current_player_index": 0, + "map_seed": 0, + "game_rng_seed": 0, + "game_rng_state": 0, + "players": [], + }, + "presentation": presentation, + } + return JSON.stringify(env) + + +func _slot(idx: int, controller_id: String) -> Dictionary: + return { + "slot": idx, + "player_name": "P%d" % idx, + "race_id": "dwarf", + "gender_preset": "male", + "color": [51, 102, 255, 255], + "is_human": false, + "controller_id": controller_id, + "controller_hash": [0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0], + } + + +func test_validator_passes_for_known_controller() -> void: + var gs: RefCounted = _make_state() + var json_str: String = _envelope([ + _slot(0, ""), # legacy / human slot — empty maps to scripted:default + _slot(1, DEFAULT_ID), + ]) + var ok: bool = bool(gs.call("load_from_json", json_str)) + assert_true(ok, "load_from_json should accept the hand-built envelope") + var report: String = String(gs.call("validate_save_controllers")) + assert_eq(report, "", "Known controller must validate clean (got: %s)" % report) + + +func test_validator_flags_unknown_controller() -> void: + var gs: RefCounted = _make_state() + var json_str: String = _envelope([ + _slot(0, DEFAULT_ID), + _slot(2, BOGUS_ID), + ]) + var ok: bool = bool(gs.call("load_from_json", json_str)) + assert_true(ok, "load_from_json should accept the envelope (validation runs separately)") + var report: String = String(gs.call("validate_save_controllers")) + assert_false(report.is_empty(), "Unknown controller must produce an error report") + assert_true( + report.find("slot 2") != -1, + "Report must name the offending slot: %s" % report + ) + assert_true( + report.find(BOGUS_ID) != -1, + "Report must name the unknown controller id: %s" % report + ) + + +func test_validator_round_trips_via_serialize_full() -> void: + ## Use a known controller in slot 0, bogus in slot 1, then verify the + ## full pipeline: load → serialize_full → load_from_json → validate. + var gs: RefCounted = _make_state() + var initial: String = _envelope([ + _slot(0, DEFAULT_ID), + _slot(1, BOGUS_ID), + ]) + assert_true(bool(gs.call("load_from_json", initial)), "initial load failed") + var rountripped: String = String(gs.call("serialize_full")) + assert_false(rountripped.is_empty(), "serialize_full produced empty payload") + + var gs2: RefCounted = _make_state() + assert_true(bool(gs2.call("load_from_json", rountripped)), "round-trip load failed") + var report: String = String(gs2.call("validate_save_controllers")) + assert_false( + report.is_empty(), + "After save+load round trip, validator must still flag the bogus controller" + ) + assert_true( + report.find(BOGUS_ID) != -1, + "Round-tripped report must name the bogus id: %s" % report + )