Files
settled-reach/server/tests/serialization.rs
T
jpmschweitzerandClaude Opus 4.6 d4fdbf426e fix(server): address PR #23 review — 4 critical bugs, 3 warnings, 11 suggestions
Critical fixes:
- Add PendingRecognitionWire serialization roundtrip test
- Check Option return from delay.cancel() before logging
- InputQueue capacity limit (1000) with drop-oldest and warning
- TODO in observation.rs references ticket #450

Warning fixes:
- Hot-reload guards against invalid/empty content root
- Location header mismatch warning in line pool indexing
- walk_yaml() depth limit (100) against symlink loops
- Consecutive reload failure counter (warns after 5+)

Test additions:
- Negative prerequisite filtering test for monologue lines
- Integration test for pending_recognitions in observer snapshot
- Eavesdrop threshold ordering assertion (Careful < default)

Documentation:
- Playtesting expectation comments on delay constants
- is_pending() scalability note for future NPC cognitive delay
- ID format regex validation in line pool spec
- BTreeMap vs sort() ordering clarification in loader
- Multiplayer TODO in relationships.rs references D-010
- ContentSlug ticket #452 filed for entity slug resolution

394 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:03:51 +01:00

696 lines
23 KiB
Rust

//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
use std::fs;
/// Helper to create a minimal v2 snapshot for tests
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities,
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
}
}
#[test]
fn observer_snapshot_roundtrip() {
let snapshot = test_snapshot(
42,
vec![VisibleEntity {
entity_id: 1,
x: 10.0,
y: 20.0,
z: 0,
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
);
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
assert_eq!(decoded.entities[0].entity_id, 1);
}
#[test]
fn player_input_roundtrip() {
let input = PlayerInput {
tick: 100,
action: PlayerAction::MoveNorth,
};
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 100);
}
#[test]
fn empty_snapshot_roundtrip() {
let snapshot = test_snapshot(0, vec![]);
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 0);
assert!(decoded.entities.is_empty());
}
/// All PlayerAction variants must survive MessagePack round-trip (D-030 Layer 1)
#[test]
fn all_player_action_variants_roundtrip() {
let actions = vec![
PlayerAction::MoveNorth,
PlayerAction::MoveSouth,
PlayerAction::MoveEast,
PlayerAction::MoveWest,
PlayerAction::MoveNortheast,
PlayerAction::MoveNorthwest,
PlayerAction::MoveSoutheast,
PlayerAction::MoveSouthwest,
PlayerAction::Interact {
target_entity_id: None,
verb: None,
},
PlayerAction::UsePerceptionMode("thermal".to_string()),
PlayerAction::Pause,
PlayerAction::Unpause,
PlayerAction::SetTickRate(TickRate::Half),
PlayerAction::ToggleStanceUp,
PlayerAction::ToggleStanceDown,
];
for action in actions {
let input = PlayerInput {
tick: 1,
action: action.clone(),
};
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.tick, 1);
// Verify the variant survived by re-serializing and comparing bytes
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
}
}
/// All .msgpack fixtures must deserialize without error (guards against corruption in git).
/// Snapshot fixtures deserialize as ObserverSnapshot, input_* as PlayerInput,
/// input_batch_* as Vec<PlayerInput>.
#[test]
fn all_fixtures_deserialize() {
let fixture_dir = std::path::Path::new("../client/tests/fixtures/msgpack");
assert!(
fixture_dir.exists(),
"Fixture directory not found: {}",
fixture_dir.display()
);
let mut count = 0;
for entry in fs::read_dir(fixture_dir).expect("read fixture dir") {
let entry = entry.expect("read dir entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("msgpack") {
continue;
}
let name = path.file_stem().unwrap().to_str().unwrap().to_string();
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
if name.starts_with("snapshot") {
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
assert_eq!(
snap.version, PROTOCOL_VERSION,
"fixture {} has wrong version",
name
);
} else if name.starts_with("input_batch") {
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
} else if name.starts_with("input") {
rmp_serde::from_slice::<PlayerInput>(&bytes)
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
} else {
panic!("unknown fixture naming convention: {}", name);
}
count += 1;
}
assert!(count > 0, "no fixtures found");
eprintln!("Verified {} fixtures", count);
}
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
#[test]
fn all_entity_kind_variants_roundtrip() {
let kinds = vec![
EntityKind::Player,
EntityKind::Npc,
EntityKind::Object,
EntityKind::Terrain,
];
for (i, kind) in kinds.into_iter().enumerate() {
let entity = VisibleEntity {
entity_id: i as u64,
x: 0.0,
y: 0.0,
z: 0,
kind,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
};
let snapshot = test_snapshot(0, vec![entity]);
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
assert_eq!(
bytes, re_bytes,
"round-trip mismatch for EntityKind variant"
);
}
}
/// v2 snapshot fields round-trip correctly
#[test]
fn snapshot_v2_fields_roundtrip() {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 100,
game_time: GameTime {
day: 3,
time_of_day: 720,
day_phase: DayPhase::Evening,
tick_rate: TickRate::Paused,
},
player_facing: FacingDirection::Southeast,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![VisibleEntity {
entity_id: 1,
x: 5.5,
y: 10.5,
z: 0,
kind: EntityKind::Player,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
}],
visible_tiles: vec![
VisibleTile {
x: 5,
y: 10,
z: 0,
visibility: VisibilitySector::Forward,
tile_kind: TileKind::Floor,
},
VisibleTile {
x: 6,
y: 10,
z: 0,
visibility: VisibilitySector::Peripheral,
tile_kind: TileKind::Wall,
},
],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.game_time.day, 3);
assert_eq!(decoded.game_time.time_of_day, 720);
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
assert_eq!(decoded.game_time.tick_rate, TickRate::Paused);
assert_eq!(decoded.player_facing, FacingDirection::Southeast);
assert_eq!(decoded.visible_tiles.len(), 2);
assert_eq!(
decoded.visible_tiles[0].visibility,
VisibilitySector::Forward
);
assert_eq!(
decoded.visible_tiles[1].visibility,
VisibilitySector::Peripheral
);
assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward);
}
/// Entity::to_bits() must roundtrip through from_bits() — guards against
/// bevy version changes silently breaking wire IDs (Hoshe #12).
#[test]
fn entity_to_bits_roundtrip() {
use bevy_ecs::entity::Entity;
// Create entities via a World so we get valid index+generation pairs
let mut world = bevy_ecs::world::World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
// Despawn and respawn to get a higher generation
world.despawn(e2);
let e4 = world.spawn_empty().id();
for entity in [e1, e2, e3, e4] {
let bits = entity.to_bits();
let restored = Entity::from_bits(bits);
assert_eq!(
entity, restored,
"Entity::to_bits() roundtrip failed for {:?}",
entity
);
}
}
/// PROTOCOL_VERSION constant matches snapshot version field
#[test]
fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 7,
"bump this assertion when protocol version changes"
);
}
/// All FacingDirection variants round-trip
#[test]
fn all_facing_direction_variants_roundtrip() {
let directions = [
FacingDirection::North,
FacingDirection::Northeast,
FacingDirection::East,
FacingDirection::Southeast,
FacingDirection::South,
FacingDirection::Southwest,
FacingDirection::West,
FacingDirection::Northwest,
];
for dir in directions {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: dir,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_facing, dir);
}
}
/// v6 fields: all MovementStance variants round-trip (#449, D-053)
#[test]
fn all_movement_stance_variants_roundtrip() {
let stances = [
MovementStance::Sprint,
MovementStance::Walk,
MovementStance::Careful,
MovementStance::Crouch,
];
for stance in stances {
let snapshot = test_snapshot(0, vec![]);
let mut snapshot = snapshot;
snapshot.player_stance = stance;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_stance, stance);
}
}
/// v6 fields: player_inventory with items round-trips (#449, D-065)
#[test]
fn snapshot_v6_inventory_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.player_stance = MovementStance::Careful;
snapshot.player_inventory = vec![
InventoryItem {
item_id: 100,
name: "Manifest Copy".into(),
slot: 0,
},
InventoryItem {
item_id: 101,
name: "Access Token".into(),
slot: 1,
},
InventoryItem {
item_id: 102,
name: "Comm Log".into(),
slot: 2,
},
];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_stance, MovementStance::Careful);
assert_eq!(decoded.player_inventory.len(), 3);
assert_eq!(decoded.player_inventory[0].item_id, 100);
assert_eq!(decoded.player_inventory[0].name, "Manifest Copy");
assert_eq!(decoded.player_inventory[0].slot, 0);
assert_eq!(decoded.player_inventory[2].name, "Comm Log");
assert_eq!(decoded.player_inventory[2].slot, 2);
}
/// v6 fields: default stance is Walk, default inventory is empty (#449)
#[test]
fn snapshot_v6_defaults() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.player_stance, MovementStance::Walk);
assert!(snapshot.player_inventory.is_empty());
}
/// v5 payloads (without player_stance/player_inventory) must deserialize into
/// the v6 struct via #[serde(default)]. Guards backwards compat during migration.
#[test]
fn v5_payload_deserializes_into_v6_struct() {
// Local v5 struct: ObserverSnapshot without player_stance and player_inventory
#[derive(serde::Serialize)]
struct ObserverSnapshotV5 {
version: u8,
tick: u64,
game_time: GameTime,
player_facing: FacingDirection,
entities: Vec<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
}
let v5 = ObserverSnapshotV5 {
version: 5,
tick: 42,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
};
let bytes = rmp_serde::to_vec_named(&v5).expect("serialize v5");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v5 payload should deserialize into v6 struct via serde(default)");
// New fields should get their defaults
assert_eq!(decoded.version, 5, "version field preserved from v5");
assert_eq!(decoded.tick, 42);
assert_eq!(
decoded.player_stance,
MovementStance::Walk,
"missing stance should default to Walk"
);
assert!(
decoded.player_inventory.is_empty(),
"missing inventory should default to empty"
);
assert!(
decoded.current_monologue.is_none(),
"missing monologue should default to None"
);
assert!(
decoded.pending_recognitions.is_empty(),
"missing pending_recognitions should default to empty"
);
}
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
#[test]
fn snapshot_v6_full_inventory_roundtrip() {
let items: Vec<InventoryItem> = (0..9)
.map(|i| InventoryItem {
item_id: 100 + i as u64,
name: format!("Item {}", i),
slot: i,
})
.collect();
let mut snapshot = test_snapshot(0, vec![]);
snapshot.player_inventory = items;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_inventory.len(), 9);
for (i, item) in decoded.player_inventory.iter().enumerate() {
assert_eq!(item.slot, i as u8, "slot {} should match index", i);
assert_eq!(item.item_id, 100 + i as u64);
}
// Slot 8 is max valid (0-indexed, 3x3 grid)
assert_eq!(decoded.player_inventory[8].slot, 8);
}
/// PendingRecognitionWire round-trips through MessagePack (#423, D-060).
/// Guards against cognitive delay wire data corruption during serialization.
#[test]
fn pending_recognition_wire_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.pending_recognitions = vec![
PendingRecognitionWire {
entity_id: 42,
x: 10.5,
y: 20.0,
z: 0,
remaining_ticks: 4,
total_delay_ticks: 6,
},
PendingRecognitionWire {
entity_id: 99,
x: 15.0,
y: 8.5,
z: 1,
remaining_ticks: 1,
total_delay_ticks: 3,
},
];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.pending_recognitions.len(), 2);
assert_eq!(decoded.pending_recognitions[0].entity_id, 42);
assert_eq!(decoded.pending_recognitions[0].remaining_ticks, 4);
assert_eq!(decoded.pending_recognitions[0].total_delay_ticks, 6);
assert!((decoded.pending_recognitions[0].x - 10.5).abs() < f32::EPSILON);
assert_eq!(decoded.pending_recognitions[1].entity_id, 99);
assert_eq!(decoded.pending_recognitions[1].z, 1);
assert_eq!(decoded.pending_recognitions[1].total_delay_ticks, 3);
}
/// All VerbKind variants must survive MessagePack round-trip (#421, D-057).
/// Guards against serde mapping breakage when new verbs are added.
#[test]
fn all_verb_kind_variants_roundtrip() {
let all_verbs = [
(VerbKind::ExamineNpc, "Observe"),
(VerbKind::Talk, "Talk"),
(VerbKind::Observe, "Observe"),
(VerbKind::Read, "Read"),
(VerbKind::Open, "Open"),
(VerbKind::Close, "Close"),
(VerbKind::Search, "Search"),
(VerbKind::Use, "Use"),
(VerbKind::Take, "Take"),
(VerbKind::Sit, "Sit"),
(VerbKind::Confront, "Confront"),
(VerbKind::ExamineObject, "Examine"),
];
for (kind, label) in all_verbs {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind,
label: label.into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(
decoded.nearby_interactions[0].verbs[0].kind, kind,
"VerbKind::{:?} did not roundtrip",
kind
);
}
}
/// ObjectType enum round-trips through MessagePack (#421).
/// While not on the wire in ObserverSnapshot, ObjectType has Serialize/Deserialize
/// for future save/load and must round-trip cleanly.
#[test]
fn all_object_type_variants_roundtrip() {
use settled_reach_server::simulation::interaction::ObjectType;
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize");
let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded, obj_type,
"ObjectType::{:?} roundtrip failed",
obj_type
);
}
}
/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip.
/// Guards against Confront being omitted from serde mapping.
#[test]
fn verb_kind_confront_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(
decoded.nearby_interactions[0].verbs[0].kind,
VerbKind::Confront
);
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded, archetype,
"CharacterArchetype::{:?} roundtrip failed",
archetype
);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
fn nearby_interaction_contradicted_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: true,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert!(
decoded.nearby_interactions[0].contradicted,
"contradicted flag should survive roundtrip"
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
fn nearby_interaction_object_type_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Open,
label: "Open".into(),
priority: 1,
available: true,
}],
object_type: Some(ObjectType::Container),
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded.nearby_interactions[0].object_type,
Some(ObjectType::Container)
);
}