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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
-1
@@ -356,6 +356,21 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width)
|
||||
- **Raised by:** Stig (OQ-29), revised per Tyre architecture review
|
||||
|
||||
### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution)
|
||||
- **Date:** 2026-02-19
|
||||
- **Decision:** Zone temperature memory is **server-tracked** via `zone_id: Option<u16>` on `VisibleTile` in `ObserverSnapshot`. The server assigns a zone ID to each tile based on the `ZoneMap` resource (spatial zone assignment). The client maps `zone_id` to temperature tint from a local lookup table for deep fog rendering ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven) layer 3: ~10% zone temperature tint — bar=warm dark, hub=cool dark, corridor=neutral dark).
|
||||
- **Resolves:** OQ-09
|
||||
- **Rationale:** D-073 already mandates `zone_id` per tile in `ObserverSnapshot` for audio zone crossfade. With `zone_id` already on the wire for crossfade, zone temperature memory is essentially free — no additional protocol field needed, no additional server computation beyond the zone lookup. Client-only tracking was rejected because: (1) the server is the authoritative source of zone geometry, (2) client heuristics (remembering last-visited zone) would diverge from server truth at zone boundaries, and (3) the data is already crossing the wire for D-073.
|
||||
- **Implementation:**
|
||||
- `VisibleTile.zone_id: Option<u16>` — `None` for tiles outside any defined zone (corridors, transition spaces). Uses `#[serde(default, skip_serializing_if)]` for backwards compatibility with v10 clients.
|
||||
- `ZoneMap` resource (`simulation/zone.rs`): `BTreeMap<(i32, i32, i32), u16>` mapping tile coordinates to zone IDs. BTreeMap per [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 4 (deterministic iteration). Populated by map builders at setup time.
|
||||
- Zone enrichment runs in `compute_observer_snapshot` — the observer pipeline's final assembly stage. `PerceptionQuery` trait remains zone-unaware (zone assignment is map data, not perception geometry).
|
||||
- Protocol version bumped to 11.
|
||||
- **Client contract:** Client maintains a `zone_id → { name, temperature_tint, ambient_layer }` lookup table. Deep fog shader (layer 3) reads `zone_id` from the last-seen `VisibleTile` data to apply the ~10% temperature tint. AudioManager reads `zone_id` to trigger crossfade between ambient layers ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)).
|
||||
- **Cross-reference:** Fog layers ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), zone crossfade ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)), ObserverSnapshot ([D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)), deterministic simulation ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline))
|
||||
- **Raised by:** Tyre (architecture review, #523)
|
||||
- **Dissent:** None
|
||||
|
||||
---
|
||||
|
||||
*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 640px)*
|
||||
*31 decisions. Last updated: 2026-02-19 (D-077: OQ-09 resolved — zone temperature memory server-tracked)*
|
||||
|
||||
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