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:
parent
8facb10498
commit
18baeacc03
2 changed files with 133 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue