diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 5435b2917..548964aaa 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 658104fd8..55f1263d0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index f98303206..978a62085 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index b1d14171f..f1f8ae0ba 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack new file mode 100644 index 000000000..6dd91a8ed Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 1dd34690f..051a7e25a 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -1,21 +1,86 @@ -// Bridge type definitions -// ObserverSnapshot: data crossing the client-server boundary +// Bridge type definitions — v2 (Sprint 2: See) +// ObserverSnapshot: data crossing the client-server boundary (D-020) // PlayerInput: semantic actions from client use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +pub use crate::simulation::time::DayPhase; + /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. /// -/// TODO: Planned fields — fog/visibility data, ambient sound events, -/// internal monologue triggers, HUD state (D-020 expansion). +/// v2 adds: game_time, player_facing, visible_tiles, visibility sectors. +/// 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: 2. + pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, - /// All entities visible to the observer + /// Game time data for client HUD display (D-031) + pub game_time: GameTime, + /// Player character's facing direction for vision cone (D-015) + pub player_facing: FacingDirection, + /// All entities visible to the observer (filtered by LOS + vision cone) pub entities: Vec, + /// Tiles visible to the observer for fog rendering + pub visible_tiles: Vec, +} + +/// Game time data for client display (D-031) +/// 10 ticks = 1 game-minute, 4 day phases of 360 minutes each. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameTime { + /// Current day (0-indexed) + pub day: u64, + /// Time of day in game-minutes (0..1439) + pub time_of_day: u64, + /// Current day phase (Morning/Afternoon/Evening/Night) + pub day_phase: DayPhase, + /// Whether simulation is paused + pub paused: bool, +} + +/// 8-directional facing direction, matching movement system. +/// Used for vision cone computation (D-015) and snapshot wire format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FacingDirection { + North, + Northeast, + East, + Southeast, + South, + Southwest, + West, + Northwest, +} + +impl Default for FacingDirection { + fn default() -> Self { + FacingDirection::North + } +} + +/// A tile visible to the observer with its visibility quality +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VisibleTile { + pub x: i32, + pub y: i32, + pub z: i32, + /// Which vision cone sector this tile falls in (D-015) + pub visibility: VisibilitySector, +} + +/// Vision cone sectors per D-015. +/// Behind = not visible at all (tile absent from visible_tiles list). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum VisibilitySector { + /// Full LOS, full detail (forward arc) + Forward, + /// Reduced range, dimmer rendering (side arcs) + Peripheral, } /// A visible entity in the simulation @@ -28,10 +93,12 @@ pub struct VisibleEntity { pub y: f32, pub z: i32, pub kind: EntityKind, + /// Which vision cone sector this entity falls in (D-015) + pub visibility: VisibilitySector, } /// Category of visible entity -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EntityKind { Player, Npc, diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3914094dc..e8d9b045c 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,6 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; +use settled_reach_server::simulation::time::DayPhase; use std::fs; use std::path::Path; @@ -14,30 +15,45 @@ fn write_fixture(name: &str, bytes: &[u8]) { eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len()); } +/// Helper to create a minimal v2 snapshot for fixtures +fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { + ObserverSnapshot { + version: 2, + tick, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + paused: false, + }, + player_facing: FacingDirection::North, + entities, + visible_tiles: vec![], + } +} + #[test] #[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored fn generate_msgpack_fixtures() { // Snapshot with one NPC entity - let snapshot = ObserverSnapshot { - tick: 42, - entities: vec![VisibleEntity { + let snapshot = fixture_snapshot( + 42, + vec![VisibleEntity { entity_id: 1, x: 10.0, y: 20.0, z: 0, kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, }], - }; + ); write_fixture( "snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap(), ); // Empty snapshot - let empty = ObserverSnapshot { - tick: 0, - entities: vec![], - }; + let empty = fixture_snapshot(0, vec![]); write_fixture("snapshot_empty", &rmp_serde::to_vec_named(&empty).unwrap()); // PlayerInput: MoveNorth @@ -60,32 +76,34 @@ fn generate_msgpack_fixtures() { &rmp_serde::to_vec_named(&input_perception).unwrap(), ); - // Snapshot with Player entity (EntityKind::Player added by server team) - let snapshot_player = ObserverSnapshot { - tick: 1, - entities: vec![VisibleEntity { + // Snapshot with Player entity + let snapshot_player = fixture_snapshot( + 1, + vec![VisibleEntity { entity_id: 100, x: 16.5, y: 16.5, z: 0, kind: EntityKind::Player, + visibility: VisibilitySector::Forward, }], - }; + ); write_fixture( "snapshot_player", &rmp_serde::to_vec_named(&snapshot_player).unwrap(), ); // Snapshot with multiple entities and all EntityKind variants - let snapshot_multi = ObserverSnapshot { - tick: 999, - entities: vec![ + let snapshot_multi = fixture_snapshot( + 999, + vec![ VisibleEntity { entity_id: 1, x: 16.5, y: 16.5, z: 0, kind: EntityKind::Player, + visibility: VisibilitySector::Forward, }, VisibleEntity { entity_id: 2, @@ -93,6 +111,7 @@ fn generate_msgpack_fixtures() { y: 10.0, z: 0, kind: EntityKind::Npc, + visibility: VisibilitySector::Peripheral, }, VisibleEntity { entity_id: 3, @@ -100,6 +119,7 @@ fn generate_msgpack_fixtures() { y: 3.0, z: 1, kind: EntityKind::Object, + visibility: VisibilitySector::Forward, }, VisibleEntity { entity_id: 4, @@ -107,12 +127,58 @@ fn generate_msgpack_fixtures() { y: 0.0, z: -1, kind: EntityKind::Terrain, + visibility: VisibilitySector::Forward, + }, + ], + ); + write_fixture( + "snapshot_multi_entity", + &rmp_serde::to_vec_named(&snapshot_multi).unwrap(), + ); + + // v2 snapshot with visible_tiles and game_time populated + let snapshot_v2_full = ObserverSnapshot { + version: 2, + tick: 500, + game_time: GameTime { + day: 1, + time_of_day: 720, + day_phase: DayPhase::Evening, + paused: false, + }, + player_facing: FacingDirection::Southeast, + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.5, + y: 10.5, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + }], + visible_tiles: vec![ + VisibleTile { + x: 10, + y: 10, + z: 0, + visibility: VisibilitySector::Forward, + }, + VisibleTile { + x: 11, + y: 10, + z: 0, + visibility: VisibilitySector::Peripheral, + }, + VisibleTile { + x: 10, + y: 9, + z: 0, + visibility: VisibilitySector::Forward, }, ], }; write_fixture( - "snapshot_multi_entity", - &rmp_serde::to_vec_named(&snapshot_multi).unwrap(), + "snapshot_v2_full", + &rmp_serde::to_vec_named(&snapshot_v2_full).unwrap(), ); // Batch input: Vec with two actions (D-030 Layer 1 bidirectional symmetry) diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 3755c3d29..20cdcab27 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1,24 +1,44 @@ //! 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 std::fs; +/// Helper to create a minimal v2 snapshot for tests +fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { + ObserverSnapshot { + version: 2, + tick, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + paused: false, + }, + player_facing: FacingDirection::North, + entities, + visible_tiles: vec![], + } +} + #[test] fn observer_snapshot_roundtrip() { - let snapshot = ObserverSnapshot { - tick: 42, - entities: vec![VisibleEntity { + let snapshot = test_snapshot( + 42, + vec![VisibleEntity { entity_id: 1, x: 10.0, y: 20.0, z: 0, kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, }], - }; + ); let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded.version, 2); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); assert_eq!(decoded.entities[0].entity_id, 1); @@ -39,10 +59,7 @@ fn player_input_roundtrip() { #[test] fn empty_snapshot_roundtrip() { - let snapshot = ObserverSnapshot { - tick: 0, - entities: vec![], - }; + 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"); @@ -140,11 +157,9 @@ fn all_entity_kind_variants_roundtrip() { y: 0.0, z: 0, kind, + visibility: VisibilitySector::Forward, }; - let snapshot = ObserverSnapshot { - tick: 0, - entities: vec![entity], - }; + 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"); @@ -154,3 +169,89 @@ fn all_entity_kind_variants_roundtrip() { ); } } + +/// v2 snapshot fields round-trip correctly +#[test] +fn snapshot_v2_fields_roundtrip() { + let snapshot = ObserverSnapshot { + version: 2, + tick: 100, + game_time: GameTime { + day: 3, + time_of_day: 720, + day_phase: DayPhase::Evening, + paused: true, + }, + player_facing: FacingDirection::Southeast, + entities: vec![VisibleEntity { + entity_id: 1, + x: 5.5, + y: 10.5, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + }], + visible_tiles: vec![ + VisibleTile { + x: 5, + y: 10, + z: 0, + visibility: VisibilitySector::Forward, + }, + VisibleTile { + x: 6, + y: 10, + z: 0, + visibility: VisibilitySector::Peripheral, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.version, 2); + 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!(decoded.game_time.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); +} + +/// 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: 2, + tick: 0, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + paused: false, + }, + player_facing: dir, + entities: vec![], + visible_tiles: 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); + } +}