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 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:20:46 +01:00
co-authored by Claude Opus 4.6
parent c065d98325
commit bb6bc287e9
8 changed files with 270 additions and 36 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+73 -6
View File
@@ -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<VisibleEntity>,
/// Tiles visible to the observer for fog rendering
pub visible_tiles: Vec<VisibleTile>,
}
/// 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,
+84 -18
View File
@@ -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<VisibleEntity>) -> 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<PlayerInput> with two actions (D-030 Layer 1 bidirectional symmetry)
+113 -12
View File
@@ -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<VisibleEntity>) -> 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);
}
}