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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user