Merge remote-tracking branch 'origin/server'
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,
|
||||
|
||||
@@ -2466,3 +2466,116 @@ fn blocked_entities_sorted_ascending() {
|
||||
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Zone enrichment tests (#523, D-077)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn zone_map_enriches_visible_tiles_with_zone_id() {
|
||||
// Tile inside a zone should get zone_id = Some(zone_id)
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut zone_map = ZoneMap::default();
|
||||
// Zone 42 covers (14..18, 14..18) — includes the observer's tile at (16,16)
|
||||
zone_map.set_rect(14, 14, 4, 4, 0, 42);
|
||||
world.insert_resource(zone_map);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Observer's tile (16, 16) is inside zone 42
|
||||
let observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
|
||||
.expect("observer tile should be visible");
|
||||
assert_eq!(
|
||||
observer_tile.zone_id,
|
||||
Some(42),
|
||||
"tile inside zone should have zone_id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_map_tiles_outside_zone_get_none() {
|
||||
// Tile outside any zone should get zone_id = None
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut zone_map = ZoneMap::default();
|
||||
// Zone only covers (0..2, 0..2) — far from observer at (16,16)
|
||||
zone_map.set_rect(0, 0, 2, 2, 0, 7);
|
||||
world.insert_resource(zone_map);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Observer's tile (16, 16) is outside any zone
|
||||
let observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
|
||||
.expect("observer tile should be visible");
|
||||
assert_eq!(
|
||||
observer_tile.zone_id, None,
|
||||
"tile outside any zone should have zone_id = None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_zone_map_resource_tiles_have_no_zone_id() {
|
||||
// When ZoneMap resource is absent, all tiles should have zone_id = None
|
||||
let mut world = setup_world(32, 32);
|
||||
// Do NOT insert ZoneMap resource
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
// All tiles should have zone_id = None when no ZoneMap exists
|
||||
for tile in &snapshot.visible_tiles {
|
||||
assert_eq!(
|
||||
tile.zone_id, None,
|
||||
"tile ({},{}) should have zone_id = None without ZoneMap resource",
|
||||
tile.x, tile.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,86 @@
|
||||
//! 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.
|
||||
///
|
||||
/// Production population path is TBD — currently populated only by
|
||||
/// Gauntlet room builders via `set_rect` / `set`.
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,61 @@ pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom {
|
||||
}),
|
||||
};
|
||||
|
||||
/// Sound Lab — Room 12 (34x20)
|
||||
/// Tests D-018 (three-range sound model). Sound-emitting NPCs at calibrated
|
||||
/// distances from the observer: Close (2 tiles), Medium (6 tiles), Long (12 tiles).
|
||||
pub const SOUND_LAB: GauntletRoom = GauntletRoom {
|
||||
name: "sound_lab",
|
||||
origin: TilePosition {
|
||||
x: 0,
|
||||
y: 104,
|
||||
z: 0,
|
||||
},
|
||||
size: (34, 20),
|
||||
spawn: TilePosition {
|
||||
x: 10,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer: TilePosition {
|
||||
x: 10,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition {
|
||||
x: 16,
|
||||
y: 103,
|
||||
z: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
/// Decay Observatory — Room 13 (24x14)
|
||||
/// Tests D-041 knowledge graph decay. NPC observed in LOS then LOS broken;
|
||||
/// confidence degrades Direct → KnowsDetails → KnowsOf → Suspects over ticks.
|
||||
pub const DECAY_OBSERVATORY: GauntletRoom = GauntletRoom {
|
||||
name: "decay_observatory",
|
||||
origin: TilePosition { x: 0, y: 24, z: 0 },
|
||||
size: (24, 14),
|
||||
spawn: TilePosition { x: 10, y: 30, z: 0 },
|
||||
observer: TilePosition { x: 10, y: 30, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 15, y: 23, z: 0 }),
|
||||
};
|
||||
|
||||
/// Shift Change — Room 14 (16x24)
|
||||
/// Tests D-031 day-phase transitions. Two NPCs with DailyRoutine components
|
||||
/// receive PathRequests when game clock crosses a phase boundary.
|
||||
pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom {
|
||||
name: "shift_change",
|
||||
origin: TilePosition { x: 64, y: 78, z: 0 },
|
||||
size: (16, 24),
|
||||
spawn: TilePosition { x: 72, y: 90, z: 0 },
|
||||
observer: TilePosition { x: 72, y: 90, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition { x: 72, y: 78, z: 0 }),
|
||||
};
|
||||
|
||||
/// All rooms in canonical spawn order.
|
||||
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
|
||||
/// Do not reorder existing entries. Append new rooms at the end.
|
||||
@@ -215,6 +270,9 @@ pub const ROOMS: &[GauntletRoom] = &[
|
||||
SPRINT_GAUNTLET,
|
||||
EAVESDROP_ALCOVE,
|
||||
CONFRONTATION_STAGE,
|
||||
SOUND_LAB,
|
||||
DECAY_OBSERVATORY,
|
||||
SHIFT_CHANGE,
|
||||
];
|
||||
|
||||
/// Look up which room a position falls in.
|
||||
@@ -259,6 +317,12 @@ pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60);
|
||||
pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62);
|
||||
/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage).
|
||||
pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65);
|
||||
// Sprint 13 rooms — appended after SPRINT11_RESET_PLATE_STABLE_IDS per additive-only rule.
|
||||
pub const SOUND_LAB_STABLE_IDS: (u64, u64) = (66, 68);
|
||||
pub const DECAY_OBSERVATORY_STABLE_IDS: (u64, u64) = (69, 69);
|
||||
pub const SHIFT_CHANGE_STABLE_IDS: (u64, u64) = (70, 71);
|
||||
/// Reset plates for Sprint 13 rooms (sound_lab, decay_observatory, shift_change).
|
||||
pub const SPRINT13_RESET_PLATE_STABLE_IDS: (u64, u64) = (72, 74);
|
||||
|
||||
/// Number of actively-spawned entities in the current Gauntlet build.
|
||||
/// Derived from StableId ranges of all rooms + player + reset plates.
|
||||
@@ -275,7 +339,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
|
||||
+ (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize
|
||||
+ (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize
|
||||
+ (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SOUND_LAB_STABLE_IDS.1 - SOUND_LAB_STABLE_IDS.0 + 1) as usize
|
||||
+ (DECAY_OBSERVATORY_STABLE_IDS.1 - DECAY_OBSERVATORY_STABLE_IDS.0 + 1) as usize
|
||||
+ (SHIFT_CHANGE_STABLE_IDS.1 - SHIFT_CHANGE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -384,7 +452,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_rooms_in_correct_order() {
|
||||
assert_eq!(ROOMS.len(), 11);
|
||||
assert_eq!(ROOMS.len(), 14);
|
||||
assert_eq!(ROOMS[0].name, "central_hub");
|
||||
assert_eq!(ROOMS[1].name, "fog_theater");
|
||||
assert_eq!(ROOMS[2].name, "occlusion_corridor");
|
||||
@@ -396,6 +464,9 @@ mod tests {
|
||||
assert_eq!(ROOMS[8].name, "sprint_gauntlet");
|
||||
assert_eq!(ROOMS[9].name, "eavesdrop_alcove");
|
||||
assert_eq!(ROOMS[10].name, "confrontation_stage");
|
||||
assert_eq!(ROOMS[11].name, "sound_lab");
|
||||
assert_eq!(ROOMS[12].name, "decay_observatory");
|
||||
assert_eq!(ROOMS[13].name, "shift_change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -458,6 +529,10 @@ mod tests {
|
||||
EAVESDROP_ALCOVE_STABLE_IDS,
|
||||
CONFRONTATION_STAGE_STABLE_IDS,
|
||||
SPRINT11_RESET_PLATE_STABLE_IDS,
|
||||
SOUND_LAB_STABLE_IDS,
|
||||
DECAY_OBSERVATORY_STABLE_IDS,
|
||||
SHIFT_CHANGE_STABLE_IDS,
|
||||
SPRINT13_RESET_PLATE_STABLE_IDS,
|
||||
];
|
||||
for (i, a) in ranges.iter().enumerate() {
|
||||
for (j, b) in ranges.iter().enumerate() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - Entities spawned in canonical order → StableId assignment is deterministic
|
||||
//! - Additive-only: existing rooms/entities never reordered
|
||||
//!
|
||||
//! StableId ranges (from gestalt-round3.md + Sprint 11):
|
||||
//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8
|
||||
@@ -31,6 +31,10 @@
|
||||
//! Eavesdrop Alcove: 58-60
|
||||
//! Confrontation Stage: 61-62
|
||||
//! Reset plates (Sprint 11 rooms): 63-65
|
||||
//! Sound Lab: 66-68
|
||||
//! Decay Observatory: 69
|
||||
//! Shift Change: 70-71
|
||||
//! Reset plates (Sprint 13 rooms): 72-74
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
@@ -64,6 +68,8 @@ use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomal
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use reset::{RoomResetTrigger, RoomSnapshots};
|
||||
@@ -101,6 +107,10 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
|
||||
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
|
||||
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
|
||||
// Sprint 13 rooms
|
||||
carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab
|
||||
carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory
|
||||
carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
|
||||
@@ -130,6 +140,22 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
|
||||
app.insert_resource(walkability);
|
||||
|
||||
// Zone map: assign zone IDs per room bounding box (D-077, D-073).
|
||||
// Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None).
|
||||
let mut zone_map = ZoneMap::default();
|
||||
for (i, room) in constants::ROOMS.iter().enumerate() {
|
||||
let zone_id = i as u16;
|
||||
zone_map.set_rect(
|
||||
room.origin.x,
|
||||
room.origin.y,
|
||||
room.size.0,
|
||||
room.size.1,
|
||||
room.origin.z,
|
||||
zone_id,
|
||||
);
|
||||
}
|
||||
app.insert_resource(zone_map);
|
||||
|
||||
// Entity spawning in canonical StableId order.
|
||||
// Player gets StableId 0, then entities by room in workshop order.
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
@@ -256,6 +282,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Confrontation Stage (StableId 61-62) ---
|
||||
rooms::confrontation_stage::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Sound Lab (StableId 66-68) ---
|
||||
rooms::sound_lab::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Decay Observatory (StableId 69) ---
|
||||
rooms::decay_observatory::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Shift Change (StableId 70-71) ---
|
||||
rooms::shift_change::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Sprint 11 reset plates (StableId 63-65) ---
|
||||
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
|
||||
(
|
||||
@@ -294,6 +329,44 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Sprint 13 reset plates (StableId 72-74) ---
|
||||
let sprint13_reset_plates: &[(&str, TilePosition)] = &[
|
||||
(
|
||||
"sound_lab",
|
||||
constants::SOUND_LAB
|
||||
.reset_plate
|
||||
.expect("sound_lab should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"decay_observatory",
|
||||
constants::DECAY_OBSERVATORY
|
||||
.reset_plate
|
||||
.expect("decay_observatory should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"shift_change",
|
||||
constants::SHIFT_CHANGE
|
||||
.reset_plate
|
||||
.expect("shift_change should have a reset_plate"),
|
||||
),
|
||||
];
|
||||
for &(room_name, pos) in sprint13_reset_plates {
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: room_name.to_string(),
|
||||
},
|
||||
pos,
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
@@ -390,6 +463,35 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sound Lab entities (StableId 66-68): NPCs only, no floor items
|
||||
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("sound_lab", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decay Observatory entities (StableId 69): NPC only, no floor items
|
||||
for id in
|
||||
constants::DECAY_OBSERVATORY_STABLE_IDS.0..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
|
||||
{
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("decay_observatory", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shift Change entities (StableId 70-71): NPCs only, no floor items
|
||||
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("shift_change", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
@@ -624,5 +726,45 @@ mod tests {
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sound Lab at 66-68
|
||||
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sound Lab at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Decay Observatory at 69
|
||||
for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0
|
||||
..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Decay Observatory at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Shift Change at 70-71
|
||||
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Shift Change at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sprint 13 reset plates at 72-74
|
||||
for id in constants::SPRINT13_RESET_PLATE_STABLE_IDS.0
|
||||
..=constants::SPRINT13_RESET_PLATE_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sprint 13 reset plate at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Decay Observatory — Room 13 (24x14)
|
||||
//!
|
||||
//! Tests D-041 (knowledge graph decay). Observer sees an NPC (Direct), then
|
||||
//! LOS is broken (drops to KnowsDetails). Tick advances verify confidence
|
||||
//! degrades: KnowsDetails → KnowsOf → Suspects (floor). Separate test
|
||||
//! confirms Stale state triggers when age exceeds stale_after threshold.
|
||||
//!
|
||||
//! Decay runs once per game-minute (every 10 ticks, D-031). Confidence floor
|
||||
//! is Suspects — decay never removes an entity from the knowledge graph.
|
||||
//!
|
||||
//! Layout: Open room. Single observable NPC east of observer.
|
||||
//! This room sits in the corridor-NW path (x=12-18, y=22-40); the corridor
|
||||
//! creates natural north/south doorways through the room walls.
|
||||
//!
|
||||
//! Observer position: (10, 30) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 69):
|
||||
//! npc_observe_target (16, 30) — 6 tiles east of observer, starts in LOS
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
const ORIGIN_Y: i32 = 24;
|
||||
|
||||
/// Observer position for Decay Observatory tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 30, z: 0 };
|
||||
|
||||
/// NPC position — 6 tiles east of observer, starts in direct LOS.
|
||||
pub const NPC_OBSERVE_POS: TilePosition = TilePosition { x: 16, y: 30, z: 0 };
|
||||
|
||||
/// Spawn Decay Observatory entities in canonical order (StableId 69).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
let pos = TilePosition::new(ORIGIN_X + 16, ORIGIN_Y + 6, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Decay Observatory: observable NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{DecayThresholds, KnowledgeConfidence, KnowledgeState, StableId};
|
||||
|
||||
const TARGET: StableId = StableId(1);
|
||||
|
||||
fn observe_then_leave(tick_observe: u64, tick_leave: u64) -> KnowledgeGraph {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
g.observe_entity(TARGET, NPC_OBSERVE_POS, tick_observe);
|
||||
g.observe_entity_leaving_los(&TARGET, tick_leave);
|
||||
g
|
||||
}
|
||||
|
||||
/// After LOS loss, confidence degrades one level per decay pass:
|
||||
/// KnowsDetails → KnowsOf → Suspects (floor).
|
||||
#[test]
|
||||
fn decay_observatory_confidence_degrades_after_los_loss() {
|
||||
let mut g = observe_then_leave(100, 110);
|
||||
|
||||
// Verify starting state: KnowsDetails after LOS loss.
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::KnowsDetails),
|
||||
"confidence must be KnowsDetails immediately after LOS loss"
|
||||
);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 10_000,
|
||||
};
|
||||
|
||||
// Tick 200: age = 200 - 100 = 100 > decay_after=10 → KnowsOf
|
||||
g.decay(200, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::KnowsOf),
|
||||
"KnowsDetails must decay to KnowsOf after one decay pass"
|
||||
);
|
||||
|
||||
// Tick 300: age still > decay_after → Suspects
|
||||
g.decay(300, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::Suspects),
|
||||
"KnowsOf must decay to Suspects after second decay pass"
|
||||
);
|
||||
|
||||
// Tick 400: Suspects is the floor — no further degradation.
|
||||
g.decay(400, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::Suspects),
|
||||
"Suspects is the confidence floor — decay must not go below Suspects"
|
||||
);
|
||||
}
|
||||
|
||||
/// After exceeding the stale threshold the entry state becomes Stale.
|
||||
#[test]
|
||||
fn decay_observatory_stale_after_threshold() {
|
||||
let mut g = observe_then_leave(100, 110);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 50,
|
||||
};
|
||||
|
||||
// Tick 200: age = 200 - 100 = 100 > stale_after=50 → Stale
|
||||
g.decay(200, &thresholds);
|
||||
|
||||
let entry = g.entity_knowledge(&TARGET).unwrap();
|
||||
assert_eq!(
|
||||
entry.state,
|
||||
KnowledgeState::Stale,
|
||||
"entry state must become Stale when age exceeds stale_after threshold"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod confrontation_stage;
|
||||
pub mod crowd_plaza;
|
||||
pub mod decay_observatory;
|
||||
pub mod dialogue_room;
|
||||
pub mod eavesdrop_alcove;
|
||||
pub mod fog_theater;
|
||||
@@ -13,4 +14,6 @@ pub mod interaction_gallery;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
pub mod shift_change;
|
||||
pub mod sound_lab;
|
||||
pub mod sprint_gauntlet;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Shift Change — Room 14 (16x24)
|
||||
//!
|
||||
//! Tests D-031 (day phases) and NPC routine transitions at phase boundaries.
|
||||
//! Two NPCs have DailyRoutine components with entries for Morning and Afternoon.
|
||||
//! Advancing game clock to the Afternoon boundary triggers PathRequests for
|
||||
//! both NPCs (they must move to their Afternoon locations).
|
||||
//!
|
||||
//! This room sits adjacent to the corridor-E2 path (x=58-80, y=84-90), which
|
||||
//! provides natural east-west access through the room interior.
|
||||
//!
|
||||
//! Observer position: (72, 90) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 70-71):
|
||||
//! npc_shift_morning (70, 84) — Morning location: 6 tiles north-west of observer
|
||||
//! npc_shift_afternoon (76, 96) — Afternoon location: 4 tiles east, 6 south of observer
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, DailyRoutine, Npc, RoutineEntry, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 64;
|
||||
const ORIGIN_Y: i32 = 78;
|
||||
|
||||
/// Observer position for Shift Change tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 72, y: 90, z: 0 };
|
||||
|
||||
/// Morning routine location for both NPCs — where they stand during Morning phase.
|
||||
pub const MORNING_LOCATION: TilePosition = TilePosition { x: 70, y: 84, z: 0 };
|
||||
|
||||
/// Afternoon routine location — where NPCs should walk to at Afternoon boundary.
|
||||
pub const AFTERNOON_LOCATION: TilePosition = TilePosition { x: 76, y: 96, z: 0 };
|
||||
|
||||
/// Spawn Shift Change entities in canonical order (StableId 70-71).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
// npc_shift_morning (StableId 70): starts at Morning location, has both Morning + Afternoon entries.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
MORNING_LOCATION,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Shift Change: morning shift NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 5,
|
||||
threshold: 45,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning station".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon patrol".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Shifts between morning station and afternoon patrol".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
|
||||
// npc_shift_afternoon (StableId 71): starts at Afternoon location, same routine.
|
||||
// Starting position differs from Morning location so a transition always triggers a PathRequest.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
AFTERNOON_LOCATION,
|
||||
Want {
|
||||
primary: WantKind::Connection,
|
||||
intensity: 3,
|
||||
description: "Shift Change: afternoon shift NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 2 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 55,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning briefing".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon post".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Covers afternoon post, returns to morning briefing".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::routine::{check_phase_transition, PreviousDayPhase};
|
||||
use crate::simulation::pathfinding::PathRequest;
|
||||
use crate::simulation::time::{SimulationTime, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
/// At the Morning→Afternoon boundary, NPCs receive PathRequests for their Afternoon location.
|
||||
#[test]
|
||||
fn shift_change_npc_transitions_routine_at_phase_boundary() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<PreviousDayPhase>();
|
||||
|
||||
// Spawn npc_shift_morning: currently at MORNING_LOCATION.
|
||||
// On Afternoon transition it should receive a PathRequest to AFTERNOON_LOCATION.
|
||||
let npc_morning = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MORNING_LOCATION,
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning station".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon patrol".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Test routine".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Spawn npc_shift_afternoon: currently at AFTERNOON_LOCATION.
|
||||
// Already at Afternoon location — PathRequest should NOT fire for it.
|
||||
let npc_afternoon = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
AFTERNOON_LOCATION,
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon post".to_string(),
|
||||
}],
|
||||
description: "Test routine".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Advance time to Afternoon boundary (Morning→Afternoon).
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// npc_shift_morning must have received a PathRequest to AFTERNOON_LOCATION.
|
||||
let req = world
|
||||
.get::<PathRequest>(npc_morning)
|
||||
.expect("npc_shift_morning must receive PathRequest at Afternoon boundary");
|
||||
assert_eq!(
|
||||
req.goal, AFTERNOON_LOCATION,
|
||||
"PathRequest goal must be the Afternoon routine location"
|
||||
);
|
||||
|
||||
// npc_shift_afternoon is already at AFTERNOON_LOCATION — no PathRequest.
|
||||
assert!(
|
||||
world.get::<PathRequest>(npc_afternoon).is_none(),
|
||||
"npc_shift_afternoon already at Afternoon location — must NOT receive PathRequest"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Sound Lab — Room 12 (34x20)
|
||||
//!
|
||||
//! Tests D-018 (three-range sound model). Sound-emitting entities placed at
|
||||
//! calibrated Manhattan distances from the observer. Verifies that:
|
||||
//! - Close (≤3 tiles): SoundEvent is audible at observer
|
||||
//! - Medium (≤8 tiles): SoundEvent is audible with Medium range but NOT Close
|
||||
//! - Long (≤20 tiles): SoundEvent is audible with Long range but NOT Medium
|
||||
//!
|
||||
//! Layout: Observer at west interior, emitters placed east at increasing
|
||||
//! distances along the same row.
|
||||
//!
|
||||
//! Observer position: (10, 114) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 66-68):
|
||||
//! npc_sound_close (12, 114) — 2 tiles east of observer (Close range, dist ≤3)
|
||||
//! npc_sound_medium (16, 114) — 6 tiles east of observer (Medium range, dist ≤8)
|
||||
//! npc_sound_long (22, 114) — 12 tiles east of observer (Long range, dist ≤20)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
const ORIGIN_Y: i32 = 104;
|
||||
|
||||
/// Observer position for Sound Lab tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 114, z: 0 };
|
||||
|
||||
/// Close-range emitter position — 2 tiles east of observer (dist=2, Close ≤3).
|
||||
pub const CLOSE_EMITTER_POS: TilePosition = TilePosition { x: 12, y: 114, z: 0 };
|
||||
|
||||
/// Medium-range emitter position — 6 tiles east of observer (dist=6, Medium ≤8, outside Close).
|
||||
pub const MEDIUM_EMITTER_POS: TilePosition = TilePosition { x: 16, y: 114, z: 0 };
|
||||
|
||||
/// Long-range emitter position — 12 tiles east of observer (dist=12, Long ≤20, outside Medium).
|
||||
pub const LONG_EMITTER_POS: TilePosition = TilePosition { x: 22, y: 114, z: 0 };
|
||||
|
||||
/// NPC definitions: (relative_x, relative_y, want_kind, intensity).
|
||||
const NPCS: &[(i32, i32, WantKind, u8)] = &[
|
||||
(12, 10, WantKind::Safety, 3), // npc_sound_close — StableId 66
|
||||
(16, 10, WantKind::Knowledge, 4), // npc_sound_medium — StableId 67
|
||||
(22, 10, WantKind::Freedom, 5), // npc_sound_long — StableId 68
|
||||
];
|
||||
|
||||
/// Spawn Sound Lab entities in canonical order (StableId 66-68).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(rx, ry, want_kind, intensity) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: "Sound Lab test emitter".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventKind};
|
||||
|
||||
/// Close emitter is 2 tiles away — must be audible with SoundRange::Close (≤3 tiles, D-018).
|
||||
#[test]
|
||||
fn sound_lab_close_range_in_snapshot() {
|
||||
let event = SoundEvent::at(
|
||||
&CLOSE_EMITTER_POS,
|
||||
SoundEventKind::Footstep,
|
||||
0.8,
|
||||
SoundRange::Close,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
event.audible_at(&OBSERVER_POS),
|
||||
"Close emitter at dist=2 must be audible with SoundRange::Close (max 3 tiles)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Medium emitter is 6 tiles away — audible with Medium (≤8) but NOT with Close (≤3).
|
||||
#[test]
|
||||
fn sound_lab_medium_range_indicator_present() {
|
||||
let medium_event = SoundEvent::at(
|
||||
&MEDIUM_EMITTER_POS,
|
||||
SoundEventKind::Voice,
|
||||
0.6,
|
||||
SoundRange::Medium,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
medium_event.audible_at(&OBSERVER_POS),
|
||||
"Medium emitter at dist=6 must be audible with SoundRange::Medium (max 8 tiles)"
|
||||
);
|
||||
|
||||
// Negative: Close range cannot reach 6 tiles.
|
||||
let close_event = SoundEvent::at(
|
||||
&MEDIUM_EMITTER_POS,
|
||||
SoundEventKind::Voice,
|
||||
0.6,
|
||||
SoundRange::Close,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!close_event.audible_at(&OBSERVER_POS),
|
||||
"Medium-position emitter (dist=6) must NOT be audible with SoundRange::Close (max 3 tiles)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Long emitter is 12 tiles away — audible with Long (≤20) but NOT with Medium (≤8).
|
||||
#[test]
|
||||
fn sound_lab_long_range_insert_only() {
|
||||
let long_event = SoundEvent::at(
|
||||
&LONG_EMITTER_POS,
|
||||
SoundEventKind::Alert,
|
||||
0.9,
|
||||
SoundRange::Long,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
long_event.audible_at(&OBSERVER_POS),
|
||||
"Long emitter at dist=12 must be audible with SoundRange::Long (max 20 tiles)"
|
||||
);
|
||||
|
||||
// Negative: Medium range cannot reach 12 tiles.
|
||||
let medium_event = SoundEvent::at(
|
||||
&LONG_EMITTER_POS,
|
||||
SoundEventKind::Alert,
|
||||
0.9,
|
||||
SoundRange::Medium,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!medium_event.audible_at(&OBSERVER_POS),
|
||||
"Long-position emitter (dist=12) must NOT be audible with SoundRange::Medium (max 8 tiles)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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