//! 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![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], follow_state: None, character_pressure: None, rng_seed: None, poi_list: vec![], examine_result: None, player_knowledge: None, save_result: None, triangle_crisis_events: vec![], state_hash: None, debug_response: None, sim_errors: vec![], current_ticker: None, settings_response: None, } } #[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, tell_state: None, }], ); 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, PlayerAction::WalkAway, PlayerAction::SetFacing { facing: "north".to_string(), }, PlayerAction::TeleportToHub, PlayerAction::DialogueResponse { target_entity_id: 42, response_id: "kael-davan_d_001".to_string(), }, PlayerAction::SaveGame { path: "/tmp/test.msgpack".to_string(), }, PlayerAction::LoadGame { path: "/tmp/test.msgpack".to_string(), }, ]; 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_boundary") { // Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check rmp_serde::from_slice::(&bytes).unwrap_or_else(|e| { panic!("deserialize boundary snapshot fixture {}: {}", name, e) }); } else 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") || name.starts_with("player_input") { rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e)); } else if name.starts_with("boundary_raw") { // Raw integer boundary fixtures (#472): single u64 values rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e)); } else if name == "malformed" { // Intentionally truncated — skip deserialization check, error handling tested elsewhere } else { panic!( "unknown fixture naming convention: {} — add a deserialization branch for this prefix", 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, tell_state: None, }; 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, tell_state: None, }], visible_tiles: vec![ VisibleTile { x: 5, y: 10, z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, zone_id: None, }, VisibleTile { x: 6, y: 10, z: 0, visibility: VisibilitySector::Peripheral, tile_kind: TileKind::Wall, zone_id: None, }, ], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], follow_state: None, character_pressure: None, rng_seed: None, poi_list: vec![], examine_result: None, player_knowledge: None, save_result: None, triangle_crisis_events: vec![], state_hash: None, debug_response: None, sim_errors: vec![], current_ticker: None, settings_response: None, }; 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, 21, "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![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], sound_events: vec![], conversation_events: vec![], conversation_ended: vec![], follow_state: None, character_pressure: None, rng_seed: None, poi_list: vec![], examine_result: None, player_knowledge: None, save_result: None, triangle_crisis_events: vec![], state_hash: None, debug_response: None, sim_errors: vec![], current_ticker: None, settings_response: None, }; 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" ); assert!( decoded.blocked_entities.is_empty(), "missing blocked_entities 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, "Examine NPC"), (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" ); } // === Boundary Value Tests (#471) === // All 41 boundary values from Appendix C of workshop-outcomes.md. // Tests i64 MessagePack encode -> decode roundtrip at every encoding boundary. // Prevents Bug #4 class (MessagePack -128 encoding mismatch). /// All 41 boundary values that exercise every MessagePack integer encoding format. /// Positive: pos fixint (0-127), uint 8 (128-255), int16/uint16 (256-65535), /// int32/uint32 (65536-2^32-1), int64 (2^32+). /// Negative: neg fixint (-1 to -32), int 8 (-33 to -128), int 16 (-129 to -32768), /// int 32 (-32769 to -2^31), int 64 (-2^31-1 to -2^63). const BOUNDARY_VALUES: [i64; 41] = [ // Positive boundaries (25 values) 0, 1, 126, 127, // pos fixint 128, 129, 254, 255, // uint 8 256, 257, 32766, 32767, // int 16 / uint 16 asymmetry 32768, 32769, 65534, 65535, // uint 16 65536, 65537, 2147483646, 2147483647, // int 32 / uint 32 asymmetry 2147483648, 4294967294, 4294967295, // uint 32 4294967296, i64::MAX, // int 64 // Negative boundaries (16 values) -1, -31, -32, // neg fixint -33, -34, -127, -128, // int 8 -129, -130, -32767, -32768, // int 16 -32769, -2147483647, -2147483648, // int 32 -2147483649, i64::MIN, // int 64 ]; #[test] fn boundary_value_i64_roundtrip() { // #471: Each of the 41 boundary values must survive Rust encode -> decode. for &value in &BOUNDARY_VALUES { let bytes = rmp_serde::to_vec(&value) .unwrap_or_else(|e| panic!("encode i64 {} failed: {}", value, e)); let decoded: i64 = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("decode i64 {} failed: {}", value, e)); assert_eq!(decoded, value, "roundtrip mismatch for i64 {}", value); } } #[test] fn boundary_value_u64_roundtrip() { // #471: Positive boundary values also roundtrip as u64. // This tests the unsigned path that entity_id/tick fields use. let positive_values: Vec = BOUNDARY_VALUES .iter() .filter(|&&v| v >= 0) .map(|&v| v as u64) .collect(); for &value in &positive_values { let bytes = rmp_serde::to_vec(&value) .unwrap_or_else(|e| panic!("encode u64 {} failed: {}", value, e)); let decoded: u64 = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("decode u64 {} failed: {}", value, e)); assert_eq!(decoded, value, "roundtrip mismatch for u64 {}", value); } } #[test] fn boundary_value_in_snapshot_tick() { // #471: Boundary values survive when embedded in ObserverSnapshot.tick (u64 field). // This is the realistic scenario — values cross the wire inside real structs. let tick_values: Vec = BOUNDARY_VALUES .iter() .filter(|&&v| v >= 0) .map(|&v| v as u64) .collect(); for &tick_val in &tick_values { let snapshot = test_snapshot(tick_val, vec![]); let bytes = rmp_serde::to_vec_named(&snapshot) .unwrap_or_else(|e| panic!("encode snapshot tick={} failed: {}", tick_val, e)); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("decode snapshot tick={} failed: {}", tick_val, e)); assert_eq!( decoded.tick, tick_val, "tick roundtrip mismatch for {}", tick_val ); } } #[test] fn boundary_value_in_entity_id() { // #471: Boundary values survive in VisibleEntity.entity_id (u64 field). let id_values: Vec = BOUNDARY_VALUES .iter() .filter(|&&v| v >= 0) .map(|&v| v as u64) .collect(); for &id_val in &id_values { let snapshot = test_snapshot( 0, vec![VisibleEntity { entity_id: id_val, x: 0.0, y: 0.0, z: 0, kind: EntityKind::Npc, visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, tell_state: None, }], ); let bytes = rmp_serde::to_vec_named(&snapshot) .unwrap_or_else(|e| panic!("encode entity_id={} failed: {}", id_val, e)); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("decode entity_id={} failed: {}", id_val, e)); assert_eq!( decoded.entities[0].entity_id, id_val, "entity_id roundtrip mismatch for {}", id_val ); } } #[test] fn boundary_value_in_tile_position() { // #471: Boundary values that fit in i32 survive in VisibleTile.x/y (i32 fields). let tile_values: Vec = BOUNDARY_VALUES .iter() .filter(|&&v| v >= i32::MIN as i64 && v <= i32::MAX as i64) .map(|&v| v as i32) .collect(); for &tile_val in &tile_values { let mut snapshot = test_snapshot(0, vec![]); snapshot.visible_tiles = vec![VisibleTile { x: tile_val, y: tile_val, z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, zone_id: None, }]; let bytes = rmp_serde::to_vec_named(&snapshot) .unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e)); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("decode tile x/y={} failed: {}", tile_val, e)); assert_eq!( decoded.visible_tiles[0].x, tile_val, "tile.x roundtrip mismatch for {}", tile_val ); assert_eq!( decoded.visible_tiles[0].y, tile_val, "tile.y roundtrip mismatch for {}", tile_val ); } } // === Encoding Asymmetry Tests (#473) === // GDScript encodes positive values 256-32767 as int_16 (signed 16-bit), // while Rust encodes them as uint_16 (unsigned 16-bit). Similarly for // 65536-2147483647: GDScript uses int_32, Rust uses uint_32. // Both encodings are valid MessagePack. These tests verify Rust's rmp_serde // accepts GDScript-style signed encodings when decoding u64 fields. /// Hand-crafted GDScript-style int_16 encoding of 256 decodes as u64. /// MessagePack int_16 format: 0xd1 + 2 bytes big-endian signed. #[test] fn rust_decodes_gdscript_int16_256() { // GDScript encodes 256 as int_16: 0xd1, 0x01, 0x00 let gdscript_bytes: Vec = vec![0xd1, 0x01, 0x00]; let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes) .expect("Rust must accept GDScript int_16(256) as u64"); assert_eq!(decoded, 256); } /// Hand-crafted GDScript-style int_16 encoding of 32767 decodes as u64. #[test] fn rust_decodes_gdscript_int16_32767() { // GDScript encodes 32767 as int_16: 0xd1, 0x7f, 0xff let gdscript_bytes: Vec = vec![0xd1, 0x7f, 0xff]; let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes) .expect("Rust must accept GDScript int_16(32767) as u64"); assert_eq!(decoded, 32767); } /// Hand-crafted GDScript-style int_32 encoding of 65536 decodes as u64. /// MessagePack int_32 format: 0xd2 + 4 bytes big-endian signed. #[test] fn rust_decodes_gdscript_int32_65536() { // GDScript encodes 65536 as int_32: 0xd2, 0x00, 0x01, 0x00, 0x00 let gdscript_bytes: Vec = vec![0xd2, 0x00, 0x01, 0x00, 0x00]; let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes) .expect("Rust must accept GDScript int_32(65536) as u64"); assert_eq!(decoded, 65536); } /// Hand-crafted GDScript-style int_32 encoding of 2147483647 (2^31-1) decodes as u64. #[test] fn rust_decodes_gdscript_int32_2147483647() { // GDScript encodes 2147483647 as int_32: 0xd2, 0x7f, 0xff, 0xff, 0xff let gdscript_bytes: Vec = vec![0xd2, 0x7f, 0xff, 0xff, 0xff]; let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes) .expect("Rust must accept GDScript int_32(2147483647) as u64"); assert_eq!(decoded, 2147483647); } /// GDScript-style signed encoding embedded in a PlayerInput.tick (u64 field). /// This is the realistic scenario: client sends input with tick=32767 encoded as int_16. #[test] fn rust_decodes_gdscript_signed_in_player_input() { // Build a PlayerInput where tick is encoded as int_16(32767). // PlayerInput is a struct with named fields, so we encode it as a map. // But GDScript sends Vec via rmp_serde::to_vec (not to_vec_named). // // Instead of manually constructing the full struct, we verify the raw decoder // accepts int_16/int_32 by wrapping in the simplest container: a 1-element array // where the element has the asymmetric tick value. // // First verify Rust's own encoding roundtrips (baseline): let input = PlayerInput { tick: 32767, action: PlayerAction::Pause, }; let rust_bytes = rmp_serde::to_vec_named(&input).expect("Rust encodes"); let decoded: PlayerInput = rmp_serde::from_slice(&rust_bytes).expect("Rust decodes own encoding"); assert_eq!(decoded.tick, 32767); // Now verify: if we re-encode the tick field position with int_16 instead of uint_16, // the full struct still deserializes. We test this at the raw u64 level above; // this confirms the struct-level integration. let batch = vec![input]; let rust_batch_bytes = rmp_serde::to_vec(&batch).expect("encode batch"); let decoded_batch: Vec = rmp_serde::from_slice(&rust_batch_bytes).expect("decode batch"); assert_eq!(decoded_batch[0].tick, 32767); } // === Batch Rejection Test (#479) === /// When one input in a batch is malformed, the entire Vec /// deserialization fails — no partial processing. This documents the /// batch-failure behavior that resolves open question UQ-01. #[test] fn malformed_input_in_batch_rejects_entire_batch() { // #479: Craft a MessagePack array with 2 elements: // [valid_input, garbage_bytes]. Deserialization must fail entirely. // Step 1: Serialize a valid batch to get the wire format let valid_batch = vec![ PlayerInput { tick: 0, action: PlayerAction::MoveNorth, }, PlayerInput { tick: 1, action: PlayerAction::MoveSouth, }, ]; let valid_bytes = rmp_serde::to_vec(&valid_batch).expect("serialize valid batch"); // Step 2: Verify the valid batch deserializes correctly (baseline) let decoded: Vec = rmp_serde::from_slice(&valid_bytes).expect("valid batch should deserialize"); assert_eq!(decoded.len(), 2); // Step 3: Corrupt the payload by truncating it mid-second-element. // This simulates a malformed input in the middle of the batch. let truncated = &valid_bytes[..valid_bytes.len() - 3]; let result = rmp_serde::from_slice::>(truncated); assert!( result.is_err(), "Truncated batch must fail deserialization entirely" ); // Step 4: Also verify that random garbage bytes reject entirely. let garbage: Vec = vec![0xFF, 0xDE, 0xAD, 0xBE, 0xEF]; let result = rmp_serde::from_slice::>(&garbage); assert!( result.is_err(), "Garbage bytes must fail deserialization entirely" ); // Step 5: Verify a msgpack array header followed by one valid + one corrupt entry. // Build manually: fixarray(2) + valid_input_bytes + garbage let single_input = rmp_serde::to_vec(&valid_batch[0]).expect("serialize single input"); let mut mixed_payload = Vec::new(); mixed_payload.push(0x92); // fixarray of 2 elements mixed_payload.extend_from_slice(&single_input); mixed_payload.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage second element let result = rmp_serde::from_slice::>(&mixed_payload); assert!( result.is_err(), "Batch with one valid + one malformed element must reject entirely" ); } /// GDScript-generated fixtures must deserialize correctly (D-030 Layer 1, #475). /// Validates the reverse direction: GDScript encoder -> Rust decoder. /// Together with all_fixtures_deserialize (Rust -> GDScript), this closes the /// cross-encoder compatibility loop. /// /// Fixtures generated by: make fixtures-client /// (runs client/tests/gen_client_fixtures.gd via Godot headless) #[test] fn gdscript_generated_fixtures_deserialize() { // CWD is server/ when cargo test runs (Cargo sets it to the package root) let fixture_dir = std::path::Path::new("tests/fixtures/gdscript"); assert!( fixture_dir.exists(), "GDScript fixture directory not found at {}. Run `make fixtures-client` to generate.", fixture_dir.display() ); let mut count = 0; for entry in fs::read_dir(&fixture_dir).expect("read gdscript 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("input_batch") { let inputs: Vec = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("deserialize GDScript batch fixture {}: {}", name, e)); assert!( !inputs.is_empty(), "batch fixture {} should not be empty", name ); } else if name.starts_with("input_") || name.starts_with("boundary_tick_") { let input: PlayerInput = rmp_serde::from_slice(&bytes) .unwrap_or_else(|e| panic!("deserialize GDScript input fixture {}: {}", name, e)); // Verify specific fixtures for extra confidence match name.as_str() { "input_move_north" => { assert_eq!(input.tick, 100); assert!(matches!(input.action, PlayerAction::MoveNorth)); } "input_perception_mode" => { assert_eq!(input.tick, 200); assert!( matches!(input.action, PlayerAction::UsePerceptionMode(ref s) if s == "thermal") ); } "input_interact" => { assert_eq!(input.tick, 100); assert!(matches!(input.action, PlayerAction::Interact { .. })); } "boundary_tick_256" => { assert_eq!(input.tick, 256, "int_16 asymmetry: tick=256"); } "boundary_tick_32767" => { assert_eq!(input.tick, 32767, "int_16 asymmetry: tick=32767"); } "boundary_tick_65536" => { assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536"); } "boundary_tick_2147483647" => { assert_eq!(input.tick, 2147483647, "int_32 asymmetry: tick=2^31-1"); } _ => {} // Other fixtures: deserialization success is sufficient } } else { panic!( "unknown GDScript fixture naming convention: {} — add a deserialization branch", name ); } count += 1; } assert!( count > 0, "No .msgpack files found in {}. Run `make fixtures-client` to generate.", fixture_dir.display() ); eprintln!("Verified {} GDScript-generated fixtures", count); } /// blocked_entities Vec round-trips through MessagePack (#514). /// Guards the debug field survives serialization. #[test] fn blocked_entities_roundtrip() { let mut snapshot = test_snapshot(0, vec![]); snapshot.blocked_entities = vec![42, 99, 1024]; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!( decoded.blocked_entities, vec![42, 99, 1024], "blocked_entities should survive roundtrip" ); } /// Empty blocked_entities round-trips correctly (#514). #[test] fn blocked_entities_empty_roundtrip() { let snapshot = test_snapshot(0, vec![]); assert!(snapshot.blocked_entities.is_empty()); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); assert!( decoded.blocked_entities.is_empty(), "empty blocked_entities should survive roundtrip" ); } /// v8 payloads (without blocked_entities) must deserialize into the v9 struct /// via #[serde(default)]. Guards backwards compat during migration (#514). #[test] fn v8_payload_deserializes_into_v9_struct() { #[derive(serde::Serialize)] struct ObserverSnapshotV8 { version: u8, tick: u64, game_time: GameTime, player_facing: FacingDirection, player_stance: MovementStance, player_inventory: Vec, entities: Vec, visible_tiles: Vec, nearby_interactions: Vec, current_monologue: Option, pending_recognitions: Vec, dialogue_response: Option, } let v8 = ObserverSnapshotV8 { version: 8, tick: 100, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, player_stance: MovementStance::Walk, player_inventory: vec![], entities: vec![], visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, }; let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v8 payload should deserialize into v9 struct via serde(default)"); assert_eq!(decoded.version, 8, "version field preserved from v8"); assert_eq!(decoded.tick, 100); assert!( decoded.blocked_entities.is_empty(), "missing blocked_entities should default to empty" ); } /// rng_seed round-trips through MessagePack (#527). /// Verifies Some(seed) survives the wire and None is omitted. #[test] fn rng_seed_roundtrip() { let mut snapshot = test_snapshot(0, vec![]); snapshot.rng_seed = Some(123456789); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.rng_seed, Some(123456789)); // None case: skip_serializing_if omits the field, default restores it let mut snapshot_none = test_snapshot(0, vec![]); snapshot_none.rng_seed = None; let bytes_none = rmp_serde::to_vec_named(&snapshot_none).expect("serialize"); let decoded_none: ObserverSnapshot = rmp_serde::from_slice(&bytes_none).expect("deserialize"); assert_eq!(decoded_none.rng_seed, None); } /// v9 payloads (without rng_seed) must deserialize into the v10 struct /// via #[serde(default)]. Guards backwards compat during migration (#527). #[test] fn v9_payload_deserializes_into_v10_struct() { #[derive(serde::Serialize)] struct ObserverSnapshotV9 { version: u8, tick: u64, game_time: GameTime, player_facing: FacingDirection, player_stance: MovementStance, player_inventory: Vec, entities: Vec, visible_tiles: Vec, nearby_interactions: Vec, current_monologue: Option, pending_recognitions: Vec, dialogue_response: Option, blocked_entities: Vec, scan_events: Vec, } let v9 = ObserverSnapshotV9 { version: 9, tick: 200, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, player_stance: MovementStance::Walk, player_inventory: vec![], entities: vec![], visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], }; let bytes = rmp_serde::to_vec_named(&v9).expect("serialize v9"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v9 payload should deserialize into v10 struct via serde(default)"); assert_eq!(decoded.version, 9, "version field preserved from v9"); assert_eq!(decoded.tick, 200); assert_eq!( decoded.rng_seed, None, "missing rng_seed should default to None" ); } /// v10 payload (without zone_id on VisibleTile) deserializes into the v11 struct /// via #[serde(default)]. Guards backwards compat during migration (#523, D-077). #[test] fn v10_payload_deserializes_into_v11_struct() { // V10 VisibleTile: no zone_id field #[derive(serde::Serialize)] struct VisibleTileV10 { x: i32, y: i32, z: i32, visibility: VisibilitySector, tile_kind: TileKind, } #[derive(serde::Serialize)] struct ObserverSnapshotV10 { version: u8, tick: u64, game_time: GameTime, player_facing: FacingDirection, player_stance: MovementStance, player_inventory: Vec, entities: Vec, visible_tiles: Vec, nearby_interactions: Vec, current_monologue: Option, pending_recognitions: Vec, dialogue_response: Option, blocked_entities: Vec, scan_events: Vec, sound_events: Vec, rng_seed: Option, } let v10 = ObserverSnapshotV10 { version: 10, tick: 300, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, player_stance: MovementStance::Walk, player_inventory: vec![], entities: vec![], visible_tiles: vec![VisibleTileV10 { x: 5, y: 10, z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, }], nearby_interactions: vec![], current_monologue: None, pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], scan_events: vec![], sound_events: vec![], rng_seed: Some(42), }; let bytes = rmp_serde::to_vec_named(&v10).expect("serialize v10"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) .expect("v10 payload should deserialize into v11 struct via serde(default)"); assert_eq!(decoded.version, 10, "version field preserved from v10"); assert_eq!(decoded.tick, 300); assert_eq!(decoded.visible_tiles.len(), 1); assert_eq!( decoded.visible_tiles[0].zone_id, None, "missing zone_id should default to None" ); } // --------------------------------------------------------------------------- // #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 matches what was in the wire assert_eq!(decoded.version, 13); 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" ); // v14 fields default correctly when absent from older wire format assert!( decoded.poi_list.is_empty(), "poi_list must default to empty when absent from wire" ); assert!( decoded.examine_result.is_none(), "examine_result must default to None when absent from wire" ); assert!( decoded.player_knowledge.is_none(), "player_knowledge 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] 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) ); } // ============================================================ // #271: Named fixture validation tests (D-030 Layer 1) // // These tests read the committed .msgpack files and assert specific field // values. They serve as the Rust side of cross-language verification — the // same fixtures are decoded by client/tests/test_ipc_fixtures.gd. // ============================================================ fn read_named_fixture(name: &str) -> Vec { let path = format!("../client/tests/fixtures/msgpack/{}.msgpack", name); fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture '{}': {}", name, e)) } #[test] fn fixture_snapshot_minimal_fields() { let bytes = read_named_fixture("snapshot_minimal"); let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal"); assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); assert_eq!(snap.tick, 0, "tick should be 0"); assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity"); assert_eq!(snap.entities[0].entity_id, 1); assert!( matches!(snap.entities[0].kind, EntityKind::Player), "entity should be Player kind" ); assert!(snap.current_monologue.is_none(), "no monologue in minimal"); assert!(snap.dialogue_response.is_none(), "no dialogue in minimal"); assert!(snap.player_inventory.is_empty(), "no inventory in minimal"); assert!(snap.poi_list.is_empty(), "no POIs in minimal"); assert!(snap.player_knowledge.is_none(), "no KG in minimal"); } #[test] fn fixture_snapshot_full_fields() { let bytes = read_named_fixture("snapshot_full"); let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full"); assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); assert_eq!(snap.tick, 42, "tick should be 42"); // Monologue let monologue = snap.current_monologue.as_ref().expect("monologue absent"); assert_eq!(monologue.id, "test_monologue_001"); assert_eq!(monologue.text, "Something feels off about this place."); // Dialogue let dialogue = snap.dialogue_response.as_ref().expect("dialogue absent"); assert_eq!(dialogue.speaker_entity_id, 99); assert_eq!(dialogue.speaker_name, "Kael"); // Inventory assert_eq!(snap.player_inventory.len(), 1); assert_eq!(snap.player_inventory[0].name, "Forged Customs Cert"); // POIs assert_eq!(snap.poi_list.len(), 1); assert_eq!(snap.poi_list[0].poi_id, "docking_bay_7"); // Examine result let examine = snap.examine_result.as_ref().expect("examine_result absent"); assert_eq!(examine.entity_id, 42); // Player knowledge let kg = snap .player_knowledge .as_ref() .expect("player_knowledge absent"); assert_eq!(kg.entities.len(), 1); assert_eq!(kg.entities[0].name, "Kael"); assert_eq!(kg.facts.len(), 1); assert_eq!(kg.facts[0].fact_id, "poi.docking_bay_7"); // RNG seed assert_eq!(snap.rng_seed, Some(0xDEADBEEF)); // Pending recognitions assert_eq!(snap.pending_recognitions.len(), 1); assert_eq!(snap.pending_recognitions[0].entity_id, 7); // Blocked entities assert_eq!(snap.blocked_entities, vec![5u64, 6]); } #[test] fn fixture_player_input_move_fields() { let bytes = read_named_fixture("player_input_move"); let input: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize player_input_move"); assert_eq!(input.tick, 1, "tick should be 1"); assert!( matches!(input.action, PlayerAction::MoveNorth), "action should be MoveNorth" ); } #[test] fn fixture_player_input_interact_fields() { let bytes = read_named_fixture("player_input_interact"); let input: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize player_input_interact"); assert_eq!(input.tick, 2, "tick should be 2"); match &input.action { PlayerAction::Interact { target_entity_id, verb, } => { assert_eq!( *target_entity_id, Some(99u64), "target_entity_id should be Some(99)" ); assert_eq!( verb.as_deref(), Some("Talk"), "verb should be Some(\"Talk\")" ); } other => panic!("expected Interact, got {:?}", other), } } #[test] fn fixture_malformed_fails_deserialization() { let bytes = read_named_fixture("malformed"); // Intentionally truncated — must NOT deserialize as ObserverSnapshot let result = rmp_serde::from_slice::(&bytes); assert!( result.is_err(), "malformed fixture should fail to deserialize as ObserverSnapshot" ); // Also must NOT deserialize as PlayerInput let result2 = rmp_serde::from_slice::(&bytes); assert!( result2.is_err(), "malformed fixture should fail to deserialize as PlayerInput" ); }