//! 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) -> 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. #[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::(&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::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); } else if name.starts_with("input") { rmp_serde::from_slice::(&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, visible_tiles: Vec, nearby_interactions: Vec, current_monologue: Option, } 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 = (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) ); }