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:
2026-02-19 18:10:06 +01:00
co-authored by Claude Opus 4.6
parent c2de509c01
commit 51abc3e3c1
20 changed files with 213 additions and 6 deletions
+1
View File
@@ -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,
+10 -2
View File
@@ -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.
+16 -1
View File
@@ -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,
+1
View File
@@ -83,6 +83,7 @@ impl PerceptionQuery for NaturalVision {
z,
visibility: sector,
tile_kind,
zone_id: None,
}
})
.collect();
+1
View File
@@ -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
+83
View File
@@ -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);
}
}