feat(simulation): #523 add zone_id to ObserverSnapshot (D-077, protocol v11)
Server-tracked zone_id on VisibleTile for D-073 zone crossfade and D-059 deep fog temperature tint. ZoneMap resource backed by BTreeMap, observer enrichment in snapshot assembly. Backwards-compatible: v10 payloads deserialize with zone_id: None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
}],
|
||||
nearby_interactions: vec![NearbyInteraction {
|
||||
entity_id: 100,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 10;
|
||||
pub const PROTOCOL_VERSION: u8 = 11;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -30,10 +30,11 @@ pub const PROTOCOL_VERSION: u8 = 10;
|
||||
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
|
||||
/// v10 adds: sound_events (#124, D-038 server sound event pipeline),
|
||||
/// rng_seed (#527, deterministic replay — completes WRONG button loop).
|
||||
/// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 10.
|
||||
/// Protocol version for forward compatibility. Current: 11.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -209,6 +210,13 @@ pub struct VisibleTile {
|
||||
/// Tile type for client rendering (floor, wall, door, object)
|
||||
#[serde(default)]
|
||||
pub tile_kind: TileKind,
|
||||
/// Zone identifier for this tile (D-077 OQ-09, D-073 crossfade).
|
||||
/// Server-authoritative zone assignment. Client maps zone_id to:
|
||||
/// - Audio crossfade target (D-073)
|
||||
/// - Deep fog temperature tint (D-059 layer 3)
|
||||
/// None for tiles outside any defined zone (corridors, transition spaces).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub zone_id: Option<u16>,
|
||||
}
|
||||
|
||||
/// Tile type for rendering. Derived from WalkabilityMap on the server side.
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
/// Compute visibility geometry using the active perception mode.
|
||||
/// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry.
|
||||
@@ -61,6 +62,7 @@ pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
registry: Res<EntityRegistry>,
|
||||
zone_map: Option<Res<ZoneMap>>,
|
||||
sound_queue: Option<Res<SoundEventQueue>>,
|
||||
mut observer_query: Query<
|
||||
(
|
||||
@@ -221,6 +223,19 @@ pub fn compute_observer_snapshot(
|
||||
// Sort entities by entity_id for deterministic snapshot ordering (#457)
|
||||
entities.sort_by_key(|e| e.entity_id);
|
||||
|
||||
// Enrich tiles with zone_id from ZoneMap (D-077, D-073)
|
||||
let visible_tiles = match zone_map.as_deref() {
|
||||
Some(zm) => geometry
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.map(|t| VisibleTile {
|
||||
zone_id: zm.zone_at(t.x, t.y, t.z),
|
||||
..t.clone()
|
||||
})
|
||||
.collect(),
|
||||
None => geometry.visible_tiles.clone(),
|
||||
};
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
@@ -229,7 +244,7 @@ pub fn compute_observer_snapshot(
|
||||
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
|
||||
player_inventory,
|
||||
entities,
|
||||
visible_tiles: geometry.visible_tiles.clone(),
|
||||
visible_tiles,
|
||||
nearby_interactions,
|
||||
current_monologue,
|
||||
pending_recognitions,
|
||||
|
||||
@@ -83,6 +83,7 @@ impl PerceptionQuery for NaturalVision {
|
||||
z,
|
||||
visibility: sector,
|
||||
tile_kind,
|
||||
zone_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod sound;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
pub mod zone;
|
||||
|
||||
/// Core simulation plugin
|
||||
/// Manages simulation time, RNG, input processing, and tier transitions
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Zone map — spatial zone assignment for tiles (D-077, D-073).
|
||||
//!
|
||||
//! Maps tile coordinates to zone identifiers. Used by:
|
||||
//! - Observer snapshot: enriches VisibleTile with zone_id
|
||||
//! - Client AudioManager: zone crossfade triggers (D-073)
|
||||
//! - Client fog shader: deep fog temperature tint (D-059 layer 3)
|
||||
//!
|
||||
//! BTreeMap per D-010 principle 4 (deterministic iteration).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Server-authoritative zone assignment for tiles.
|
||||
///
|
||||
/// Each tile position maps to a zone ID. Tiles outside any defined zone
|
||||
/// (corridors, transition spaces) have no entry and return None.
|
||||
///
|
||||
/// Zone IDs are opaque u16 values — the client maintains its own
|
||||
/// `zone_id → zone_name / temperature_tint / ambient_layer` mapping.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ZoneMap {
|
||||
zones: BTreeMap<(i32, i32, i32), u16>,
|
||||
}
|
||||
|
||||
impl ZoneMap {
|
||||
/// Look up the zone for a tile position.
|
||||
pub fn zone_at(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
||||
self.zones.get(&(x, y, z)).copied()
|
||||
}
|
||||
|
||||
/// Assign a zone to a rectangular region of tiles.
|
||||
/// Used by map builders to define zone boundaries.
|
||||
pub fn set_rect(&mut self, ox: i32, oy: i32, w: i32, h: i32, z: i32, zone_id: u16) {
|
||||
for y in oy..(oy + h) {
|
||||
for x in ox..(ox + w) {
|
||||
self.zones.insert((x, y, z), zone_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a zone to a single tile.
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, zone_id: u16) {
|
||||
self.zones.insert((x, y, z), zone_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zone_at_returns_none_for_unset_tile() {
|
||||
let map = ZoneMap::default();
|
||||
assert_eq!(map.zone_at(10, 20, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rect_populates_zone() {
|
||||
let mut map = ZoneMap::default();
|
||||
map.set_rect(5, 10, 3, 2, 0, 42);
|
||||
|
||||
// Inside rect
|
||||
assert_eq!(map.zone_at(5, 10, 0), Some(42));
|
||||
assert_eq!(map.zone_at(7, 11, 0), Some(42));
|
||||
|
||||
// Outside rect
|
||||
assert_eq!(map.zone_at(4, 10, 0), None);
|
||||
assert_eq!(map.zone_at(8, 10, 0), None);
|
||||
assert_eq!(map.zone_at(5, 12, 0), None);
|
||||
|
||||
// Wrong z-level
|
||||
assert_eq!(map.zone_at(5, 10, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_single_tile() {
|
||||
let mut map = ZoneMap::default();
|
||||
map.set(3, 7, 0, 99);
|
||||
assert_eq!(map.zone_at(3, 7, 0), Some(99));
|
||||
assert_eq!(map.zone_at(3, 8, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: Some(1),
|
||||
},
|
||||
VisibleTile {
|
||||
x: 11,
|
||||
@@ -195,6 +196,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: Some(1),
|
||||
},
|
||||
VisibleTile {
|
||||
x: 10,
|
||||
@@ -202,6 +204,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
|
||||
@@ -241,6 +241,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
},
|
||||
VisibleTile {
|
||||
x: 6,
|
||||
@@ -248,6 +249,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
tile_kind: TileKind::Wall,
|
||||
zone_id: None,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
@@ -312,7 +314,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 10,
|
||||
PROTOCOL_VERSION, 11,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -862,6 +864,7 @@ fn boundary_value_in_tile_position() {
|
||||
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));
|
||||
@@ -1264,6 +1267,83 @@ fn v9_payload_deserializes_into_v10_struct() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<InventoryItem>,
|
||||
entities: Vec<VisibleEntity>,
|
||||
visible_tiles: Vec<VisibleTileV10>,
|
||||
nearby_interactions: Vec<NearbyInteraction>,
|
||||
current_monologue: Option<MonologueEvent>,
|
||||
pending_recognitions: Vec<PendingRecognitionWire>,
|
||||
dialogue_response: Option<DialogueResponseEvent>,
|
||||
blocked_entities: Vec<u64>,
|
||||
scan_events: Vec<settled_reach_server::simulation::contraband::ScanEvent>,
|
||||
sound_events: Vec<settled_reach_server::simulation::sound::SoundEvent>,
|
||||
rng_seed: Option<u64>,
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user