test(simulation): protocol versioning tests (#232)

Version field round-trip, mismatch detection, serde_default migration
pattern, all TellCategory and VerbKind variant coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:15:29 +01:00
co-authored by Claude Opus 4.6
parent d5cf97a793
commit 22724ee51c
+223
View File
@@ -1364,6 +1364,229 @@ fn v10_payload_deserializes_into_v11_struct() {
);
}
// ---------------------------------------------------------------------------
// #232: Protocol versioning scheme tests
// ---------------------------------------------------------------------------
/// A snapshot serialized without newer optional fields (simulating an older server)
/// must deserialize with serde defaults — core migration pattern (#232).
///
/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify
/// they fill in as their zero/None values on deserialization.
#[test]
fn serde_default_fields_fill_in_when_missing_from_wire() {
// JSON with only the required fields (simulating a minimal old snapshot).
// `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc.
// are all `#[serde(default)]` — they must default to None/empty when absent.
let minimal_json = serde_json::json!({
"version": 13,
"tick": 42,
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": "Full"
},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [{
"entity_id": 1,
"x": 5.0,
"y": 5.0,
"z": 0,
"kind": "Npc",
"visibility": "Forward",
"relationship": "Unknown",
"observation": "Visible"
// "tell_state" intentionally absent
}],
"visible_tiles": [],
"nearby_interactions": [],
"current_monologue": null,
"pending_recognitions": [],
"dialogue_response": null,
"blocked_entities": [],
"scan_events": [],
"sound_events": [],
"conversation_events": [],
"conversation_ended": [],
"follow_state": null,
"rng_seed": null
});
let decoded: ObserverSnapshot =
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
// Version and required fields present
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
// `#[serde(default, skip_serializing_if = "Option::is_none")]` field
// defaults to None when absent from the wire
assert_eq!(
decoded.entities[0].tell_state, None,
"tell_state must default to None when absent from wire"
);
assert_eq!(
decoded.rng_seed, None,
"rng_seed must default to None when absent from wire"
);
assert!(
decoded.follow_state.is_none(),
"follow_state must default to None when absent from wire"
);
}
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
/// the version field after deserialization (#232 compatibility checking).
#[test]
fn snapshot_version_mismatch_is_detectable() {
let mut snapshot = test_snapshot(0, vec![]);
let future_version: u8 = PROTOCOL_VERSION + 1;
snapshot.version = future_version;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
// The version field faithfully preserves the value — caller detects mismatch
assert_eq!(
decoded.version, future_version,
"version field must survive round-trip unchanged"
);
assert_ne!(
decoded.version, PROTOCOL_VERSION,
"client should detect this as a version mismatch"
);
}
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
/// A snapshot with tell_state=None produces fewer bytes than one with
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
#[test]
fn tell_state_none_is_omitted_from_wire() {
let entity_no_tell = VisibleEntity {
entity_id: 1,
x: 0.0,
y: 0.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
};
let entity_with_tell = VisibleEntity {
tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous),
..entity_no_tell.clone()
};
let bytes_no_tell =
rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state");
let bytes_with_tell =
rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state");
assert!(
bytes_no_tell.len() < bytes_with_tell.len(),
"tell_state=None should produce fewer bytes (skip_serializing_if contract)"
);
}
/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity.
/// Closing coverage gap for v13 tell_state field (#90, D-024).
#[test]
fn all_tell_category_variants_roundtrip() {
use settled_reach_server::npc::tell_state::TellCategory;
let categories = [
TellCategory::Nervous,
TellCategory::Angry,
TellCategory::Friendly,
TellCategory::Guarded,
TellCategory::RoutineDeviation,
];
for category in categories {
let entity = VisibleEntity {
entity_id: 1,
x: 3.0,
y: 4.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: Some(category),
};
let bytes = rmp_serde::to_vec_named(&entity).expect("serialize");
let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.tell_state,
Some(category),
"TellCategory::{:?} did not survive round-trip",
category
);
}
}
/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232).
/// Closes a coverage gap — not all VerbKind variants were previously verified.
#[test]
fn all_verb_kind_variants_roundtrip_v232() {
let all_verbs = [
VerbKind::ExamineNpc,
VerbKind::Talk,
VerbKind::Observe,
VerbKind::Read,
VerbKind::Open,
VerbKind::Close,
VerbKind::Search,
VerbKind::Use,
VerbKind::Take,
VerbKind::Sit,
VerbKind::Follow,
VerbKind::Confront,
VerbKind::ExamineObject,
];
for kind in all_verbs {
let interaction = NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind,
label: "Test".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: false,
};
let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize");
let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.verbs[0].kind, kind,
"VerbKind::{:?} did not survive round-trip",
kind
);
}
}
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
/// This guards against accidental widening of the version type.
#[test]
fn protocol_version_fits_in_u8() {
// u8 max is 255 — enough for ~242 more protocol iterations.
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
assert!(
PROTOCOL_VERSION <= 200,
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
PROTOCOL_VERSION
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]