diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 17301d47f..8e3267879 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -102,7 +102,8 @@ pub fn generate_snapshot( day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused, + paused: time.paused(), + tick_rate: time.tick_rate, }; tracing::trace!( @@ -111,12 +112,13 @@ pub fn generate_snapshot( visible.len() ); buffer.snapshot = Some(ObserverSnapshot { - version: 3, + version: 4, tick: time.tick, game_time, player_facing: FacingDirection::default(), entities: visible, visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering + nearby_interactions: Vec::new(), }); } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index d3a2a7f53..cbebfe2ac 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -6,18 +6,19 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; -pub use crate::simulation::time::DayPhase; +pub use crate::simulation::time::{DayPhase, TickRate}; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. /// /// v2 adds: game_time, player_facing, visible_tiles, visibility sectors. /// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered). +/// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]). /// Future fields: ambient sound events, internal monologue triggers, /// HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 3. + /// Protocol version for forward compatibility. Current: 4. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -29,6 +30,10 @@ pub struct ObserverSnapshot { pub entities: Vec, /// Tiles visible to the observer for fog rendering pub visible_tiles: Vec, + /// Entities within interaction range with available verbs (D-060, #404). + /// Sorted by distance (nearest first). v0.1 client reads verbs[0] on the nearest entity. + #[serde(default)] + pub nearby_interactions: Vec, } /// Game time data for client display (D-031) @@ -41,8 +46,11 @@ pub struct GameTime { pub time_of_day: u64, /// Current day phase (Morning/Afternoon/Evening/Night) pub day_phase: DayPhase, - /// Whether simulation is paused + /// Whether simulation is paused (tick_rate == Paused) pub paused: bool, + /// Current tick rate state (D-052) + #[serde(default)] + pub tick_rate: TickRate, } /// 8-directional facing direction, matching movement system. @@ -136,6 +144,45 @@ pub enum PlayerAction { UsePerceptionMode(String), Pause, Unpause, + /// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052 + SetTickRate(TickRate), +} + +/// Available interaction verbs for a nearby entity (D-060, #404) +/// Embedded in ObserverSnapshot.nearby_interactions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NearbyInteraction { + /// Wire-format entity identifier + pub entity_id: u64, + /// Entity type for client-side verb display + pub entity_type: EntityKind, + /// Manhattan distance from player + pub distance: f32, + /// Available verbs sorted by priority (index 0 = highest priority) + pub verbs: Vec, +} + +/// A single available verb on a nearby entity +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerbOption { + /// Verb type + pub kind: VerbKind, + /// Display label for the context prompt (e.g. "Talk", "Observe", "Examine") + pub label: String, + /// Priority rank (lower = higher priority). v0.1 client reads only priority 1. + pub priority: u8, + /// Whether this verb is currently available (false = greyed out in v0.2) + pub available: bool, +} + +/// Verb types for the interaction system (D-060) +/// Only active verbs appear in verbs[]. Passive (Look, Overhear) and +/// reactive (Monologue) verbs fire independently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum VerbKind { + ExamineObject, + ExamineNpc, + Talk, } /// Snapshot buffer resource for staging outgoing ObserverSnapshots diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index d09ce6887..f77474c21 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -199,6 +199,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 31e91ff1f..ee5fa9d85 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -99,6 +99,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 0a096e0a4..bbee9457a 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -11,6 +11,7 @@ use crate::bridge::types::*; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; +use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::time::SimulationTime; @@ -22,6 +23,7 @@ pub fn compute_observer_snapshot( time: Res, walkability: Res, registry: Res, + interaction_buffer: Res, observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With>, all_entities: Query<( Entity, @@ -185,7 +187,8 @@ pub fn compute_observer_snapshot( day: time.day(), time_of_day: time.time_of_day_minutes(), day_phase: time.day_phase(), - paused: time.paused, + paused: time.paused(), + tick_rate: time.tick_rate, }; tracing::trace!( @@ -196,14 +199,15 @@ pub fn compute_observer_snapshot( visible_tiles.len(), ); - // Step 8: Assemble snapshot (v3: added relationship + observation fields) + // Step 8: Assemble snapshot (v4: added nearby_interactions) buffer.snapshot = Some(ObserverSnapshot { - version: 3, + version: 4, tick: time.tick, game_time, player_facing: facing, entities, visible_tiles, + nearby_interactions: interaction_buffer.interactions.clone(), }); } @@ -221,6 +225,7 @@ mod tests { world.insert_resource(WalkabilityMap::new(width, height, 1)); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } @@ -240,7 +245,7 @@ mod tests { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 3); + assert_eq!(snapshot.version, 4); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -359,10 +364,10 @@ mod tests { #[test] fn game_time_populated() { let mut world = setup_world(32, 32); - world.insert_resource(SimulationTime { - tick: 7200, // 720 minutes = Evening - paused: true, - }); + let mut time = SimulationTime::default(); + time.tick = 7200; // 720 minutes = Evening + time.tick_rate = crate::simulation::time::TickRate::Paused; + world.insert_resource(time); world.spawn(( PlayerCharacter, TilePosition::new(16, 16, 0), @@ -479,7 +484,7 @@ mod tests { .id(); registry.register(player); world.insert_resource(registry); - world.insert_resource(SimulationTime { tick: 100, paused: false }); + world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_observer_snapshot); diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs new file mode 100644 index 000000000..d29327519 --- /dev/null +++ b/server/src/simulation/interaction.rs @@ -0,0 +1,332 @@ +// Interaction system — proximity detection + multi-verb InteractionOptions +// Implements #404: server-side verb computation for context-sensitive [E] key +// Spec: docs/design/interaction-verbs-v0.1.md +// D-060: actions[] renamed to verbs[] across all surfaces + +use bevy_ecs::prelude::*; +use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption}; +use crate::knowledge::types::RelationshipState; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::Npc; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; + +/// Interaction range thresholds (Manhattan distance, same z-level) +pub const CLOSE_RANGE: u32 = 2; +pub const MID_RANGE: u32 = 5; + +/// Component marking an entity as having available interactions. +/// Attached to NPCs and examinable objects by the world setup or content loader. +#[derive(Component, Debug, Clone)] +pub struct Interactable; + +/// Compute nearby interactions for the player character. +/// For each visible entity in range, determines available verbs sorted by priority. +/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot. +#[allow(clippy::type_complexity)] +pub fn compute_nearby_interactions( + player_query: Query<(&TilePosition, &KnowledgeGraph), With>, + registry: Res, + interactables: Query< + (Entity, &TilePosition, Option<&Npc>), + (With, Without), + >, + mut buffer: ResMut, +) { + buffer.interactions.clear(); + + let Ok((player_pos, knowledge)) = player_query.single() else { + return; + }; + + for (entity, pos, is_npc) in interactables.iter() { + let Some(distance) = player_pos.manhattan_distance(pos) else { + continue; // Different z-level + }; + + if distance > MID_RANGE { + continue; // Out of interaction range + } + + let entity_type = if is_npc.is_some() { + EntityKind::Npc + } else { + EntityKind::Object + }; + + // Look up relationship state from knowledge graph + let relationship = if let Some(stable_id) = registry.to_stable(entity) { + knowledge.relationship_with(&stable_id) + } else { + RelationshipState::Unknown + }; + + let is_poi = relationship == RelationshipState::PersonOfInterest; + let is_close = distance <= CLOSE_RANGE; + + let mut verbs = Vec::new(); + + match entity_type { + EntityKind::Npc => { + if is_close { + if is_poi { + // Post-contradiction: Observe takes priority over Talk + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 2, + available: true, + }); + } else { + // Default: Talk takes priority + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 2, + available: true, + }); + } + } else { + // Mid range: only Examine NPC (Talk requires close range) + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 1, + available: true, + }); + } + } + EntityKind::Object | EntityKind::Terrain => { + if is_close { + verbs.push(VerbOption { + kind: VerbKind::ExamineObject, + label: "Examine".into(), + priority: 1, + available: true, + }); + } + } + EntityKind::Player => {} // No self-interaction + } + + if verbs.is_empty() { + continue; + } + + // Sort by priority (lower number = higher priority) + verbs.sort_by_key(|v| v.priority); + + buffer.interactions.push(NearbyInteraction { + entity_id: entity.to_bits(), + entity_type, + distance: distance as f32, + verbs, + }); + } + + // Sort interactions by distance (nearest first) + buffer + .interactions + .sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap()); +} + +/// Buffer for nearby interaction results, consumed by snapshot generation +#[derive(Resource, Debug, Default)] +pub struct NearbyInteractionBuffer { + pub interactions: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::EntityRegistry; + use bevy_ecs::world::World; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world + } + + #[test] + fn npc_in_close_range_gets_talk_and_observe() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + // Talk should be priority 1 (default, not POI) + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + } + + #[test] + fn npc_in_mid_range_gets_observe_only() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Distance 4 (mid range, beyond close) + world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + } + + #[test] + fn npc_out_of_range_no_interactions() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Distance 8 (beyond mid range) + world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert!(buffer.interactions.is_empty()); + } + + #[test] + fn poi_npc_observe_takes_priority() { + let mut world = setup_world(); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((Npc, TilePosition::new(5, 6, 0), Interactable)) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert_eq!(buffer.interactions.len(), 1); + // Observe should be priority 1 for POI NPC + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk); + } + + #[test] + fn object_in_close_range_gets_examine() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Object (no Npc component) at close range + world.spawn((TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject); + } + + #[test] + fn different_z_level_no_interactions() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert!(buffer.interactions.is_empty()); + } + + #[test] + fn multiple_entities_sorted_by_distance() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // Farther NPC + world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable)); + // Closer NPC + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert_eq!(buffer.interactions.len(), 2); + assert!(buffer.interactions[0].distance < buffer.interactions[1].distance); + } + + #[test] + fn non_interactable_entity_ignored() { + let mut world = setup_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + // NPC without Interactable component + world.spawn((Npc, TilePosition::new(5, 6, 0))); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = world.resource::(); + assert!(buffer.interactions.is_empty()); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 937b3384e..086365d82 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; +pub mod interaction; pub mod movement; pub mod path_follow; pub mod pathfinding; @@ -22,6 +23,8 @@ impl Plugin for SimulationPlugin { app.init_resource::() .insert_resource(rng::SimRng::new(0)) .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -29,8 +32,12 @@ impl Plugin for SimulationPlugin { pathfinding::compute_paths.after(input::process_player_input), path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), + interaction::compute_nearby_interactions + .after(movement::validate_movement), path_follow::cleanup_path_blocked.after(movement::validate_movement), - time::advance_tick.after(path_follow::cleanup_path_blocked), + time::advance_tick + .after(path_follow::cleanup_path_blocked) + .after(interaction::compute_nearby_interactions), ), ); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index c55bfa4a5..b2e8648ba 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::local::LocalBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::SimBridge; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::thread; @@ -33,13 +33,14 @@ fn snapshot_roundtrip_over_unix_socket() { let bridge = LocalBridge::accept(&server_path).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 42, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities: vec![VisibleEntity { @@ -53,6 +54,7 @@ fn snapshot_roundtrip_over_unix_socket() { observation: EntityVisibility::Visible, }], visible_tiles: vec![], + nearby_interactions: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index c15cc484a..b3bd68877 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -4,7 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; use settled_reach_server::bridge::SimBridge; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::net::{TcpListener, TcpStream}; use std::thread; @@ -19,13 +19,14 @@ fn snapshot_roundtrip_over_tcp() { let bridge = TcpBridge::accept_on(listener).expect("failed to accept"); let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 42, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities: vec![VisibleEntity { @@ -39,6 +40,7 @@ fn snapshot_roundtrip_over_tcp() { observation: EntityVisibility::Visible, }], visible_tiles: vec![], + nearby_interactions: vec![], }; bridge diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index bbced55fc..5f60df679 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -61,7 +61,7 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 3); + assert_eq!(snapshot.version, 4); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index d421b743c..7ee1ef36d 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,7 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; -use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; use std::path::Path; @@ -18,17 +18,19 @@ fn write_fixture(name: &str, bytes: &[u8]) { /// Helper to create a minimal v2 snapshot for fixtures fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { ObserverSnapshot { - version: 3, + version: 4, tick, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities, visible_tiles: vec![], + nearby_interactions: vec![], } } @@ -150,13 +152,14 @@ fn generate_msgpack_fixtures() { // v2 snapshot with visible_tiles and game_time populated let snapshot_v2_full = ObserverSnapshot { - version: 3, + version: 4, tick: 500, game_time: GameTime { day: 1, time_of_day: 720, day_phase: DayPhase::Evening, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::Southeast, entities: vec![VisibleEntity { @@ -189,6 +192,7 @@ fn generate_msgpack_fixtures() { visibility: VisibilitySector::Forward, }, ], + nearby_interactions: vec![], }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index a768136fe..e308963ff 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1,23 +1,25 @@ //! IPC serialization round-trip tests (D-030 Layer 1: fixture-based). use settled_reach_server::bridge::types::*; -use settled_reach_server::simulation::time::DayPhase; +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: 3, + version: 4, tick, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, entities, visible_tiles: vec![], + nearby_interactions: vec![], } } @@ -40,7 +42,7 @@ fn observer_snapshot_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.version, 3); + assert_eq!(decoded.version, 4); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -178,13 +180,14 @@ fn all_entity_kind_variants_roundtrip() { #[test] fn snapshot_v2_fields_roundtrip() { let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 100, game_time: GameTime { day: 3, time_of_day: 720, day_phase: DayPhase::Evening, paused: true, + tick_rate: TickRate::Paused, }, player_facing: FacingDirection::Southeast, entities: vec![VisibleEntity { @@ -211,12 +214,13 @@ fn snapshot_v2_fields_roundtrip() { visibility: VisibilitySector::Peripheral, }, ], + nearby_interactions: 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, 3); + assert_eq!(decoded.version, 4); 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); @@ -244,17 +248,19 @@ fn all_facing_direction_variants_roundtrip() { for dir in directions { let snapshot = ObserverSnapshot { - version: 3, + version: 4, tick: 0, game_time: GameTime { day: 0, time_of_day: 0, day_phase: DayPhase::Morning, paused: false, + tick_rate: TickRate::Full, }, player_facing: dir, entities: vec![], visible_tiles: vec![], + nearby_interactions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");