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:
parent
2628be038a
commit
8facb10498
2 changed files with 150 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
124
src/game/engine/tests/ffi/test_controller_validation.gd
Normal file
124
src/game/engine/tests/ffi/test_controller_validation.gd
Normal 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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue