feat(api-gdext): Introduce SaveEnvelope struct and serialization/deserialization functions for Godot Engine API extension envelopes

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

View file

@ -2904,6 +2904,56 @@ impl SaveEnvelope {
pub const CURRENT_VERSION: u32 = 2;
}
/// Stage 7 — inspect a presentation side-table for controller ids that
/// are not currently registered in `mc_player_api`'s controller
/// registry. Returns a list of human-readable error messages, one per
/// slot whose `controller_id` is non-empty and unknown.
///
/// Read-only: the registry is *not* mutated. Empty `controller_id`
/// entries (legacy / pre-Stage-3 saves) are treated as
/// `scripted:default` and skipped. Non-zero `controller_hash` values
/// are recorded in the error string for diagnostics but do not by
/// themselves fail validation at this stage.
///
/// Pure function so unit tests can call it without instantiating
/// Godot — `GdGameState::validate_save_controllers` is a thin
/// `#[func]` wrapper around this.
pub fn validate_presentation_controllers(
presentation: &[mc_core::PresentationPlayer],
) -> Vec<String> {
let registered = mc_player_api::registered_ids();
let mut errors: Vec<String> = Vec::new();
for entry in presentation {
if entry.controller_id.is_empty() {
// Pre-Stage-3 / legacy slot — loader treats empty as
// `scripted:default`, which is always registered.
continue;
}
if !registered.iter().any(|id| id == &entry.controller_id) {
// TODO: Stage 5c — compare against
// ModLoader.installed_hashes(id) once the loader is in
// place. For now we surface the recorded hash purely as
// diagnostic context.
let hash_hint = if entry.controller_hash == [0u8; 32] {
String::from("hash=builtin-sentinel")
} else {
let prefix: String = entry
.controller_hash
.iter()
.take(4)
.map(|b| format!("{:02x}", b))
.collect();
format!("hash={}", prefix)
};
errors.push(format!(
"missing controller for slot {}: {} ({})",
entry.slot, entry.controller_id, hash_hint
));
}
}
errors
}
#[godot_api]
impl IRefCounted for GdGameState {
fn init(base: Base<RefCounted>) -> Self {
@ -3776,6 +3826,31 @@ impl GdGameState {
true
}
/// Stage 7 — after `load_from_json` succeeds, GDScript calls this
/// to ask: "are all the controllers this save references currently
/// registered with `mc_player_api`?" Returns an empty `GString` on
/// all-clear, or a comma-joined error string listing every slot
/// whose `controller_id` is non-empty and unknown.
///
/// Read-only on `self` — does not mutate state, registry, or
/// presentation table. GDScript checks `!result.is_empty()` to
/// decide whether to surface a friendly dialog and short-circuit
/// the load (otherwise the first AI turn would crash on a missing
/// controller).
///
/// Empty `controller_id` slots (legacy / pre-Stage-3 saves) are
/// silently treated as `"scripted:default"`, which is guaranteed
/// registered at engine boot.
#[func]
fn validate_save_controllers(&self) -> GString {
let errors = validate_presentation_controllers(&self.presentation_players);
if errors.is_empty() {
GString::new()
} else {
GString::from(errors.join(", "))
}
}
/// List every registered AI controller id (sorted, ascending).
/// Bridges `mc_player_api::registered_ids` to GDScript so the
/// game-setup screen (Stage 8) can populate its per-slot

View file

@ -6,7 +6,7 @@
//! which is the same surface those bridge methods serialise / deserialise into.
//! Mid-game save+load behaviour is covered by the GUT integration tests.
use magic_civ_physics_gdext::SaveEnvelope;
use magic_civ_physics_gdext::{validate_presentation_controllers, SaveEnvelope};
use mc_core::PresentationPlayer;
use mc_turn::game_state::GameState;
@ -96,6 +96,63 @@ fn populated_envelope_round_trips_byte_identical() {
assert!(!back.presentation[1].is_human);
}
#[test]
fn controller_validation_passes_for_known_controllers() {
// `scripted:default` is auto-registered at engine boot by
// `mc_player_api::controllers::register_builtins()` (invoked via
// the registry's `OnceCell` initialiser). Empty `controller_id`
// is treated as legacy → skip.
let presentation = vec![
PresentationPlayer {
slot: 0,
controller_id: String::new(),
..Default::default()
},
PresentationPlayer {
slot: 1,
controller_id: "scripted:default".into(),
..Default::default()
},
];
let errors = validate_presentation_controllers(&presentation);
assert!(
errors.is_empty(),
"expected no errors for known/empty controllers, got {errors:?}"
);
}
#[test]
fn controller_validation_flags_missing_controller() {
// Use an id that no built-in or test ever registers. The registry
// persists across in-process tests, so we deliberately pick a
// distinctive id that no other test would have registered.
let presentation = vec![
PresentationPlayer {
slot: 0,
controller_id: "scripted:default".into(),
..Default::default()
},
PresentationPlayer {
slot: 2,
controller_id: "definitely:not:registered".into(),
controller_hash: [0xAB; 32],
..Default::default()
},
];
let errors = validate_presentation_controllers(&presentation);
assert_eq!(errors.len(), 1, "expected exactly one missing-controller error, got {errors:?}");
let msg = &errors[0];
assert!(msg.contains("slot 2"), "error must name the slot: {msg}");
assert!(
msg.contains("definitely:not:registered"),
"error must name the controller id: {msg}"
);
assert!(
msg.contains("hash=ababab"),
"non-zero controller_hash should appear as a hex prefix hint: {msg}"
);
}
#[test]
fn version_two_is_locked() {
// Lock the wire format version. Future breaking changes must bump