From bb6bc287e924c71c349af3626b5f1dee2548298b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:20:46 +0100 Subject: [PATCH] feat(simulation): add ObserverSnapshot v2 schema with visibility types (#358, #25) Expand wire protocol with version field, GameTime (day/phase/paused), FacingDirection (8-directional), VisibleTile, VisibilitySector (Forward/Peripheral), and visibility tag on VisibleEntity. Regenerate all msgpack fixtures for client compatibility. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/msgpack/snapshot_empty.msgpack | Bin 17 -> 116 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 181 -> 359 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 55 -> 173 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 58 -> 176 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 0 -> 274 bytes server/src/bridge/types.rs | 79 ++++++++++- server/tests/gen_fixtures.rs | 102 +++++++++++--- server/tests/serialization.rs | 125 ++++++++++++++++-- 8 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 client/tests/fixtures/msgpack/snapshot_v2_full.msgpack diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 5435b2917bd5e065ec0af149fc763eb6025eb1a4..548964aaa41147cb211e137d0172fdd013592651 100644 GIT binary patch literal 116 zcmWlR%MF7-3J literal 17 YcmZo#Qj(dR&9EXhuOzc1GqrdE07FCvZvX%Q diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 658104fd896a3c3d2fb0bbf11fdf79d3201c6bac..55f1263d0fdac4ac5903f81f99f71c2cc2aeb8fc 100644 GIT binary patch literal 359 zcmZwDu~Gst5C-4?K8TNUHg+O~eI{HMZY3m{O+-S?P+Rx_G9c&S@Di5J_#8flCOL{L zNRi!S=9_<$O}zkeXmo$?v7G%3ew(S71NS(G^-hYwK2yuhgr`FjH*PE8puRJtQBMz6 zJUNhGC#ysN;uBHE+xMX%Uwwe~=n>rOrpayKD0|!G_syEIZD`Ai?PsXvVU+!Gfey#0 z(1-fg(6b=fvHfU^x7J?Aa<;pnEj#cy0zz8>3DvQKl)KEfD3a~9x%cBoD1C=t$i0e| b#T94HW%ZYB{QyJ)^#y#&?@;L@6}Xpwwak~i delta 44 ycmaFPw3Sh$X-P?Da`su~=POe4N-|3_Q;VmxPR!6?Y@N776-Yb)vL?$gY61YRml5Fr diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index f98303206327ef637ef23d4bada61349a76e1511..978a620853864d996c5c82c3ed6f9cd139ae6e7c 100644 GIT binary patch literal 173 zcmW-bF%p6>6hxyZvGx`;c5;PGBp>)gNHR%8W9?Au4P-zO9WG(XsSGr`Z(p@_UxBfx zB;rVDI&?pr^=J&3TG8gfxDN9-%IzD^T(_*2$+riw6|we7{rX#0(!7CFVl{?ClR3 Cz)9r* delta 25 gcmZ3>XfD*Wq$D#rTWdvXUP)$2W@_=o)`@AV0DrLxG5`Po diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index b1d14171fdc09353b59a46baf35f948dc7e1d2ce..f1f8ae0ba9b19b25a7f5bba201a022b1599359c0 100644 GIT binary patch literal 176 zcmXAjO%8%E5QVEZbK_3q4VsY-Frl=ht!P;j)_MdYg4J*dm!3+%&HMS1m%L^u(OA-I z-8qV~RY)L5?kLClF2J+*9J!A9T*4&0NWtQ;(?*d>`T|ZC#b+!ecr-kQXrhn3HZFal ya^wi@tqDu?oP=mE-%ZU}>;G1IUP)$2W@_=o)`@8v0DavG5&!@I 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 0000000000000000000000000000000000000000..6dd91a8ed6731a599c8eed99c097e4e8dd4c3df8 GIT binary patch literal 274 zcmZurJr05}6b3i-B3{EmXA=+5Jn2JTtcA1%sk31>o}kf)Xt;!<35kdC03wb$eg7}7 zC^J-=V%a+x3RaE(s+O=uW@wGOBM0l>K9t2FYemz0RxPk30*&P)LrHRZP9W9D?{y-; zqGBNgty|6G)IS^gnD089kq8#P-MD6?X+eUFEvK>C nfZ}y$iO**O^, + /// 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); + } +}