fix(save-manager): 🐛 Add validation logic to SaveManager after save load to prevent invalid controller states and update corresponding tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 02:40:53 -07:00
parent 2628be038a
commit 8facb10498
2 changed files with 150 additions and 2 deletions

View file

@ -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:

View file

@ -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
)