From 51abc3e3c1faea960ddc661f262d6004a5b9ba9e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:10:06 +0100 Subject: [PATCH] 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 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 286 -> 286 bytes .../snapshot_boundary_tick_127.msgpack | Bin 286 -> 286 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 290 -> 290 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 294 -> 294 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 288 -> 288 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 286 -> 286 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 691 -> 691 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 384 -> 384 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 387 -> 387 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 533 -> 551 bytes decisions/perception.md | 17 +++- server/Cargo.lock | 2 +- server/src/bridge/text_renderer.rs | 1 + server/src/bridge/types.rs | 12 ++- server/src/perception/observer/mod.rs | 17 +++- server/src/perception/query.rs | 1 + server/src/simulation/mod.rs | 1 + server/src/simulation/zone.rs | 83 ++++++++++++++++++ server/tests/gen_fixtures.rs | 3 + server/tests/serialization.rs | 82 ++++++++++++++++- 20 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 server/src/simulation/zone.rs diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3b499939158c6ec9f928f44be8d417950cca09fb..77b0f56856350ba30c9a7b1c090f60833af29181 100644 GIT binary patch delta 19 acmbQoG>?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 56c001dcf5d20729c9b60d4f2be711b6c2e8224e..fdd4b9555fe53dbb0442a452d4216fc97e75e294 100644 GIT binary patch delta 19 acmbQoG>?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 6ae68786f089233a2719b365fbd67172e66f9d3b..63f1799d3983aa9edc6a15f3806ac8df2ce31f1c 100644 GIT binary patch delta 19 acmZ3)w1|nTe|cGIQE_H|9`{Br2}S@$j|Of4 delta 19 acmZ3)w1|nTe|cGIQE_H|9@j=L2}S@$iUw@} diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 0822dc9c3abad26fc1570e3af0ec4037aea295d2..eb7d57eb1cc8d5529c2dd182239fca171bfb0381 100644 GIT binary patch delta 19 acmZ3+w2X?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 673d30a77768530d7cc637cc893fff14d613f9fe..d22f7b288734bef3646a154290b674a971d9aea1 100644 GIT binary patch delta 19 acmdnYx|x-$e|cGIQE_H|9`{DBl}rFid$l}rFicLyf` diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 0d336a7046bb3253e46662498686c2eee1d7af98..3d5bc895f9bc80db9785ff3964da3b64e118a08c 100644 GIT binary patch delta 19 acmZo*ZeZr>UtX44RGgWg$GwrOj1d4rr3Q)s delta 19 acmZo*ZeZr>UtX44RGgWg$F-5Gj1d4rpazKm diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index d22a725327f22d8f79751972039fc47a8be13e9a..8443ecb77fd83529cf22f1a08f74775a6bb4c7f5 100644 GIT binary patch delta 19 acmZo>Zf54{UtX44RGgWg$GwrOk`Vwy7Y366 delta 19 acmZo>Zf54{UtX44RGgWg$F-5Gk`Vwy5(bh0 diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2aa4f71c82617df3935fe6857b7d5e8135970a0f..97ecb1922f75ca26dd16c316f4ae44b4c7c9a43a 100644 GIT binary patch delta 54 zcmbQrvYdshe|cGIQE_H|9`{BrW=6)g$$X4DT+6HS^HSq8Q-EA+Mtx2=cXAfvY5?o= B5()qS delta 35 pcmZ3^GL?m^e|cGIQE_H|9@j=LW=6)=$$X4DK+=U#e{v1uY5>l93h)2` diff --git a/decisions/perception.md b/decisions/perception.md index c4675ee4a..c352b9d12 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -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` 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` — `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)* diff --git a/server/Cargo.lock b/server/Cargo.lock index 21c88b2a2..ea1dcf2fe 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.11" +version = "0.1.12" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index baf887368..8abc99478 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -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, diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 93fd77650..5396fe634 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, } /// Tile type for rendering. Derived from WalkabilityMap on the server side. diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index aadd251df..089e688e7 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, geometry: Res, registry: Res, + zone_map: Option>, sound_queue: Option>, 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, diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index af6057345..b38500e98 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -83,6 +83,7 @@ impl PerceptionQuery for NaturalVision { z, visibility: sector, tile_kind, + zone_id: None, } }) .collect(); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b85bc6a63..af7e66716 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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 diff --git a/server/src/simulation/zone.rs b/server/src/simulation/zone.rs new file mode 100644 index 000000000..baccf096a --- /dev/null +++ b/server/src/simulation/zone.rs @@ -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 { + 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); + } +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index f82126f3c..fb0d6f0a0 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -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![], diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 5b9ecf367..ff9362f09 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -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, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + pending_recognitions: Vec, + dialogue_response: Option, + blocked_entities: Vec, + scan_events: Vec, + sound_events: Vec, + rng_seed: Option, + } + + 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]