diff --git a/CHANGELOG.md b/CHANGELOG.md index 02aacea94..875781c98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094) +- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577) +- Chunk streaming system — ChunkLoadRadius and cadence-gated load/unload around player position, v0.1 covers full district (#578, D-012) +- EngagementRecord component — per-NPC observation time, conversation count, and monologue trigger count tracked by perception/dialogue/monologue systems (#570) +- MovementHistoryBuffer resource — 3000-tick ring buffer of player positions with co-presence proximity query (#571) +- Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572) +- Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579) +- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580) + +### Fixed +- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584) + +### Changed +- PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant) + ## [v0.1.22] — 2026-03-03 ### Added diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 1615131dc..ac38bc05a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 2d4321df5..f1662c717 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 4a8702760..08d570faa 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index a1c311896..fab1ca162 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 47762a0df..c57e8bab3 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 1615131dc..ac38bc05a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index d2f5bbd60..d9685c443 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index fba288bb8..51ec3a8d0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index dc2acf96a..1c4e1bed3 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 107389317..759b09da2 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 8166124b0..21707cd91 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index f83ad4838..6c932af3d 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/Cargo.lock b/server/Cargo.lock index d469ae4e2..e6cdd0d5b 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.20" +version = "0.1.22" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs new file mode 100644 index 000000000..18b7bee8f --- /dev/null +++ b/server/src/bridge/debug.rs @@ -0,0 +1,505 @@ +//! Debug console command handler (#580). +//! +//! Processes `DebugCommandKind` variants buffered by `process_player_input` +//! and writes `DebugResponsePayload` to `SnapshotBuffer.pending_debug_response`. +//! +//! Security: only executes when `DebugEnabled` resource is true. +//! v0.1: enabled by default. Cannot be toggled mid-session. + +use bevy_ecs::prelude::*; + +use crate::bridge::types::{ + DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer, +}; +use crate::content::template::TriangleState; +use crate::knowledge::EntityRegistry; +use crate::npc::Npc; +use crate::simulation::conversation::NpcName; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; +use crate::storyteller::{ContaminationActive, ContaminationEventQueue, CONTAMINATION_DELAY_TICKS}; + +// --------------------------------------------------------------------------- +// Buffer resource +// --------------------------------------------------------------------------- + +/// Buffer for debug commands forwarded from `process_player_input`. +/// +/// Drained by `handle_debug_commands` each tick. Only the last command's +/// response is delivered (debug console is request-response, not batched). +#[derive(Resource, Default, Debug)] +pub struct DebugCommandBuffer { + commands: Vec, +} + +impl DebugCommandBuffer { + pub fn push(&mut self, cmd: DebugCommandKind) { + self.commands.push(cmd); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.commands) + } + + pub fn is_empty(&self) -> bool { + self.commands.is_empty() + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Processes buffered debug commands and writes responses to the snapshot buffer. +/// +/// Runs after `process_player_input`, before `compute_observer_snapshot`. +/// Gated by `DebugEnabled` — if false, all commands are silently dropped. +#[allow(clippy::too_many_arguments)] +pub fn handle_debug_commands( + debug_enabled: Option>, + mut cmd_buffer: ResMut, + mut buffer: ResMut, + mut time: ResMut, + mut contamination: Option>, + mut contamination_queue: Option>, + walkability: Option>, + registry: Res, + mut player_query: Query<(Entity, &mut TilePosition), With>, + triangles: Query<(Entity, &TriangleState), With>, + npcs: Query<(Entity, &TilePosition, Option<&NpcName>), (With, With, Without)>, +) { + // Gate: debug must be enabled + let enabled = debug_enabled.as_ref().map_or(false, |d| d.0); + let commands = cmd_buffer.drain(); + if commands.is_empty() { + return; + } + + // Process each command; last response wins (single debug_response per tick) + for cmd in commands { + let response = if !enabled { + DebugResponsePayload { + command: format!("{:?}", cmd), + text: "Debug console is disabled.".to_string(), + success: false, + } + } else { + match cmd { + DebugCommandKind::AdvanceTicks(n) => { + let old_tick = time.tick; + time.tick = time.tick.saturating_add(n); + DebugResponsePayload { + command: format!("AdvanceTicks({})", n), + text: format!( + "Advanced {} ticks: {} -> {}", + n, old_tick, time.tick + ), + success: true, + } + } + DebugCommandKind::SkipToContamination => { + let is_active = contamination.as_ref().map(|c| c.0); + match is_active { + None => DebugResponsePayload { + command: "SkipToContamination".to_string(), + text: "Contamination system not available.".to_string(), + success: false, + }, + Some(true) => DebugResponsePayload { + command: "SkipToContamination".to_string(), + text: format!( + "Contamination already active (fired at or before tick {}). Current tick: {}", + time.tick, time.tick + ), + success: true, + }, + Some(false) if time.tick >= CONTAMINATION_DELAY_TICKS => { + // Tick is already past the delay — advancing would be a no-op + // or rewinding would break cooldowns. Report error instead. + DebugResponsePayload { + command: "SkipToContamination".to_string(), + text: format!( + "Current tick ({}) already past contamination delay ({}). Contamination should fire on next system run — use ForceContaminationActivate if it hasn't.", + time.tick, CONTAMINATION_DELAY_TICKS + ), + success: false, + } + } + Some(false) => { + let old_tick = time.tick; + time.tick = CONTAMINATION_DELAY_TICKS; + DebugResponsePayload { + command: "SkipToContamination".to_string(), + text: format!( + "Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.", + old_tick, time.tick + ), + success: true, + } + } + } + } + DebugCommandKind::TeleportToPosition { x, y, z } => { + let target = TilePosition::new(x, y, z); + // Validate target is walkable (if WalkabilityMap available) + let walkable = walkability + .as_ref() + .map_or(true, |wm| wm.can_move_to(&target)); + if !walkable { + DebugResponsePayload { + command: format!("TeleportToPosition({}, {}, {})", x, y, z), + text: format!( + "Target ({}, {}, {}) is not walkable. Player would be stuck in wall/void.", + x, y, z + ), + success: false, + } + } else if let Ok((_, mut pos)) = player_query.single_mut() { + let old = (pos.x, pos.y, pos.z); + pos.x = x; + pos.y = y; + pos.z = z; + DebugResponsePayload { + command: format!("TeleportToPosition({}, {}, {})", x, y, z), + text: format!( + "Teleported player: ({}, {}, {}) -> ({}, {}, {})", + old.0, old.1, old.2, x, y, z + ), + success: true, + } + } else { + DebugResponsePayload { + command: format!("TeleportToPosition({}, {}, {})", x, y, z), + text: "No player entity found.".to_string(), + success: false, + } + } + } + DebugCommandKind::TeleportToLocation(ref name) => { + // Location-based teleport requires ContentStore (future: resolve location + // center from tile_bounds). For now, report unimplemented. + DebugResponsePayload { + command: format!("TeleportToLocation({})", name), + text: format!( + "TeleportToLocation not yet implemented (needs location tile_bounds from ContentStore). Use TeleportToPosition instead." + ), + success: false, + } + } + DebugCommandKind::ForceContaminationActivate => { + if contamination.is_none() { + DebugResponsePayload { + command: "ForceContaminationActivate".to_string(), + text: "Contamination system not available.".to_string(), + success: false, + } + } else if contamination.as_ref().unwrap().0 { + DebugResponsePayload { + command: "ForceContaminationActivate".to_string(), + text: "Contamination already active.".to_string(), + success: true, + } + } else { + contamination.as_mut().unwrap().0 = true; + // Also push an event so downstream systems react + if let Some(ref mut queue) = contamination_queue { + queue.push(crate::storyteller::ContaminationEvent { + tick: time.tick, + triangles_affected: 0, // no pressure delta applied — use SkipToContamination for that + }); + } + DebugResponsePayload { + command: "ForceContaminationActivate".to_string(), + text: format!( + "Contamination force-activated at tick {}. Note: no tension delta applied (use SkipToContamination for full effect).", + time.tick + ), + success: true, + } + } + } + DebugCommandKind::ForceTriangleActivation(ref slug) => { + // Future: match triangle by slug and force-activate. + // Requires TriangleId slug lookup which isn't indexed yet. + DebugResponsePayload { + command: format!("ForceTriangleActivation({})", slug), + text: "ForceTriangleActivation not yet implemented (needs TriangleId slug index).".to_string(), + success: false, + } + } + DebugCommandKind::InspectNpc(wire_id) => { + let stable_id = crate::knowledge::types::StableId(wire_id); + if let Some(entity) = registry.to_entity(&stable_id) { + if let Ok((_, pos, name)) = npcs.get(entity) { + let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)"); + DebugResponsePayload { + command: format!("InspectNpc({})", wire_id), + text: format!( + "NPC {} (stable_id={})\n Position: ({}, {}, {})\n Name: {}", + entity, wire_id, pos.x, pos.y, pos.z, name_str + ), + success: true, + } + } else { + DebugResponsePayload { + command: format!("InspectNpc({})", wire_id), + text: format!("Entity {} exists but is not an Active-tier NPC.", wire_id), + success: false, + } + } + } else { + DebugResponsePayload { + command: format!("InspectNpc({})", wire_id), + text: format!("No entity found for stable_id {}.", wire_id), + success: false, + } + } + } + DebugCommandKind::ListTriangles => { + let mut lines = Vec::new(); + lines.push("=== Triangles ===".to_string()); + let mut count = 0u32; + for (entity, state) in &triangles { + count += 1; + lines.push(format!( + " {} | id={} | phase={:?} | tension={} | class={:?} | template={}", + entity, + state.triangle_id.0, + state.phase, + state.tension, + state.classification, + state.template_id.0, + )); + } + lines.push(format!("Total: {}", count)); + DebugResponsePayload { + command: "ListTriangles".to_string(), + text: lines.join("\n"), + success: true, + } + } + DebugCommandKind::ListPopulation => { + let mut lines = Vec::new(); + lines.push("=== Active-Tier NPCs ===".to_string()); + let mut count = 0u32; + for (entity, pos, name) in &npcs { + count += 1; + let stable = registry.to_stable(entity); + let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)"); + lines.push(format!( + " {} | sid={} | pos=({},{},{}) | {}", + entity, + stable.map(|s| s.0.to_string()).unwrap_or_else(|| "?".to_string()), + pos.x, pos.y, pos.z, + name_str, + )); + } + lines.push(format!("Total: {}", count)); + DebugResponsePayload { + command: "ListPopulation".to_string(), + text: lines.join("\n"), + success: true, + } + } + DebugCommandKind::GetContaminationStatus => { + if let Some(ref cont) = contamination { + DebugResponsePayload { + command: "GetContaminationStatus".to_string(), + text: format!( + "Contamination active: {}\nCurrent tick: {}\nContamination delay: {} ticks", + cont.0, time.tick, CONTAMINATION_DELAY_TICKS + ), + success: true, + } + } else { + DebugResponsePayload { + command: "GetContaminationStatus".to_string(), + text: "Contamination system not available.".to_string(), + success: false, + } + } + } + } + }; + + tracing::debug!(command = %response.command, success = response.success, "Debug command processed"); + if buffer.pending_debug_response.is_some() { + tracing::trace!( + "Debug response overwritten by '{}' — earlier response dropped (last-wins per tick)", + response.command + ); + } + buffer.pending_debug_response = Some(response); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::movement::TilePosition; + use bevy_ecs::schedule::Schedule; + + fn setup_debug_world() -> (World, Schedule) { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(DebugEnabled(true)); + + // Spawn a player entity + world.spawn((PlayerCharacter, TilePosition::new(10, 20, 0))); + + let mut schedule = Schedule::default(); + schedule.add_systems(handle_debug_commands); + (world, schedule) + } + + #[test] + fn advance_ticks_updates_simulation_time() { + let (mut world, mut schedule) = setup_debug_world(); + world.resource_mut::().tick = 100; + world.resource_mut::().push(DebugCommandKind::AdvanceTicks(50)); + + schedule.run(&mut world); + + assert_eq!(world.resource::().tick, 150); + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(resp.success); + assert!(resp.text.contains("150")); + } + + #[test] + fn skip_to_contamination_sets_tick() { + let (mut world, mut schedule) = setup_debug_world(); + world.resource_mut::().tick = 10; + world.resource_mut::().push(DebugCommandKind::SkipToContamination); + + schedule.run(&mut world); + + assert_eq!(world.resource::().tick, CONTAMINATION_DELAY_TICKS); + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(resp.success); + } + + #[test] + fn teleport_moves_player() { + let (mut world, mut schedule) = setup_debug_world(); + world.resource_mut::().push( + DebugCommandKind::TeleportToPosition { x: 50, y: 60, z: 1 }, + ); + + schedule.run(&mut world); + + let mut q = world.query_filtered::<&TilePosition, With>(); + let pos = q.single(&world).unwrap(); + assert_eq!((pos.x, pos.y, pos.z), (50, 60, 1)); + + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(resp.success); + } + + #[test] + fn force_contamination_activates() { + let (mut world, mut schedule) = setup_debug_world(); + assert!(!world.resource::().0); + + world.resource_mut::().push(DebugCommandKind::ForceContaminationActivate); + schedule.run(&mut world); + + assert!(world.resource::().0); + assert!(!world.resource::().is_empty()); + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(resp.success); + } + + #[test] + fn debug_disabled_rejects_commands() { + let (mut world, mut schedule) = setup_debug_world(); + world.insert_resource(DebugEnabled(false)); + world.resource_mut::().push(DebugCommandKind::GetContaminationStatus); + + schedule.run(&mut world); + + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(!resp.success); + assert!(resp.text.contains("disabled")); + } + + #[test] + fn get_contamination_status_reports_state() { + let (mut world, mut schedule) = setup_debug_world(); + world.resource_mut::().tick = 42; + world.resource_mut::().push(DebugCommandKind::GetContaminationStatus); + + schedule.run(&mut world); + + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(resp.success); + assert!(resp.text.contains("false")); // not yet active + assert!(resp.text.contains("42")); // current tick + } + + #[test] + fn no_commands_produces_no_response() { + let (mut world, mut schedule) = setup_debug_world(); + schedule.run(&mut world); + assert!(world.resource::().pending_debug_response.is_none()); + } + + #[test] + fn teleport_rejects_unwalkable_target() { + let (mut world, mut schedule) = setup_debug_world(); + let mut map = WalkabilityMap::new(10, 10, 1); + map.set_walkable(&TilePosition::new(5, 5, 0), false); + world.insert_resource(map); + + world.resource_mut::().push( + DebugCommandKind::TeleportToPosition { x: 5, y: 5, z: 0 }, + ); + schedule.run(&mut world); + + // Player should NOT have moved + let mut q = world.query_filtered::<&TilePosition, With>(); + let pos = q.single(&world).unwrap(); + assert_eq!((pos.x, pos.y, pos.z), (10, 20, 0), "player must not teleport to unwalkable tile"); + + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(!resp.success); + assert!(resp.text.contains("not walkable")); + } + + #[test] + fn skip_to_contamination_rejects_when_past_delay() { + let (mut world, mut schedule) = setup_debug_world(); + // Set tick past the delay but contamination not yet active + world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS + 100; + + world.resource_mut::().push(DebugCommandKind::SkipToContamination); + schedule.run(&mut world); + + // Tick should NOT have changed (no rewind) + assert_eq!( + world.resource::().tick, + CONTAMINATION_DELAY_TICKS + 100, + "tick must not rewind" + ); + + let resp = world.resource::().pending_debug_response.as_ref().unwrap(); + assert!(!resp.success); + assert!(resp.text.contains("already past")); + } + + #[test] + fn debug_enabled_defaults_to_debug_assertions() { + let d = DebugEnabled::default(); + assert_eq!(d.0, cfg!(debug_assertions)); + } +} diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index ac1f27f96..8f34db088 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -6,6 +6,7 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod debug; pub mod framing; pub mod local; pub mod tcp; @@ -221,12 +222,17 @@ impl Plugin for BridgePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( receive_bridge_inputs.before(crate::simulation::input::process_player_input), + debug::handle_debug_commands + .after(crate::simulation::input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), crate::perception::observer::compute_visibility_geometry .after(crate::simulation::movement::validate_movement), crate::simulation::interaction::compute_nearby_interactions diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 1339302b5..4b45d7425 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -192,6 +192,7 @@ fn sector_label(sector: VisibilitySector) -> &'static str { match sector { VisibilitySector::Forward => "Forward", VisibilitySector::Peripheral => "Periph", + VisibilitySector::BoundaryWall => "BndWall", } } @@ -316,6 +317,7 @@ mod tests { triangle_crisis_events: vec![], state_hash: None, sim_errors: vec![], + debug_response: None, } } @@ -454,6 +456,7 @@ mod tests { triangle_crisis_events: vec![], state_hash: None, sim_errors: vec![], + debug_response: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index a2a14f177..4d86e2076 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,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 = 17; +pub const PROTOCOL_VERSION: u8 = 18; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -72,10 +72,11 @@ pub struct StartupMessage { /// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering). /// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), /// sim_errors (#85, structured error reporting to client). +/// v18 adds: debug_response (#580, debug console server — command/response wire). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 17. + /// Protocol version for forward compatibility. Current: 18. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -191,6 +192,11 @@ pub struct ObserverSnapshot { /// Empty in normal operation. Client may display a warning toast. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub sim_errors: Vec, + /// Debug console response for this tick (#580). + /// Present when a debug command was processed. Client renders in + /// the tilde console overlay. None in normal gameplay. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub debug_response: Option, } /// Game time data for client display (D-031) @@ -336,6 +342,10 @@ pub enum VisibilitySector { Forward, /// Reduced range, dimmer rendering (side arcs) Peripheral, + /// Wall tile 1 beyond the LOS boundary (#584). + /// Gives the client wall data at the fog edge so fog composites over + /// real geometry rather than empty space. Not stored in exploration/memory. + BoundaryWall, } /// A visible entity in the simulation @@ -473,6 +483,9 @@ pub enum PlayerAction { /// Client sends this when the player selects a save file to load. /// Server executes load_from_file and sends SaveLoadResultWire confirmation. LoadGame { path: String }, + /// Debug console command (#580). Only processed when `DebugEnabled` is true. + /// Response delivered via `ObserverSnapshot.debug_response`. + DebugCommand(DebugCommandKind), } impl PlayerAction { @@ -492,6 +505,62 @@ impl PlayerAction { } } +/// Debug command variants for the in-game console (#580). +/// +/// Sent via `PlayerAction::DebugCommand`. Only processed when `DebugEnabled` +/// resource is true. Response returned in `ObserverSnapshot.debug_response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DebugCommandKind { + /// Fast-forward simulation by N ticks. + AdvanceTicks(u64), + /// Advance simulation to `CONTAMINATION_DELAY_TICKS` (skip early game). + SkipToContamination, + /// Move player to absolute tile position. + TeleportToPosition { x: i32, y: i32, z: i32 }, + /// Move player to a named location's origin (e.g. "the-terminal"). + TeleportToLocation(String), + /// Bypass contamination timer — fire `ContaminationActive` immediately. + ForceContaminationActivate, + /// Force-activate a triangle by its string slug identifier. + ForceTriangleActivation(String), + /// Dump NPC state: current routine, knowledge graph summary, relationships. + InspectNpc(u64), + /// Return all `TriangleState` entities with phase/tension/classification. + ListTriangles, + /// Return all Active-tier NPCs with tier, position, and name. + ListPopulation, + /// Return `ContaminationActive` status and current tick. + GetContaminationStatus, +} + +/// Debug response payload included in `ObserverSnapshot` (#580). +/// +/// Carries the result of a debug command as a human-readable text block +/// plus structured data where useful. The client's debug console displays +/// the `text` field directly. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugResponsePayload { + /// The command that produced this response (for client-side echo). + pub command: String, + /// Human-readable response text (multi-line, displayed in console). + pub text: String, + /// Whether the command succeeded. + pub success: bool, +} + +/// Whether the debug console is enabled (#580). +/// +/// Set at server startup. Cannot be toggled mid-session via IPC. +/// Defaults to true in debug builds, false in release builds. +#[derive(Resource, Debug, Clone)] +pub struct DebugEnabled(pub bool); + +impl Default for DebugEnabled { + fn default() -> Self { + Self(cfg!(debug_assertions)) + } +} + /// Available interaction verbs for a nearby entity (D-060, #404) /// Embedded in ObserverSnapshot.nearby_interactions. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -804,6 +873,8 @@ pub struct SnapshotBuffer { pub snapshot: Option, /// Pending save/load result, consumed once by `compute_observer_snapshot` (#553). pub pending_save_result: Option, + /// Pending debug response, consumed once by `compute_observer_snapshot` (#580). + pub pending_debug_response: Option, } #[cfg(test)] diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index aba68da37..0ca061bd4 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -327,6 +327,131 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) { } /// Check if a YAML file contains only comments and whitespace (stub file). +// --------------------------------------------------------------------------- +// Tile loading (#577) +// --------------------------------------------------------------------------- + +use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap}; + +/// Parse a tile character into a TileKind. +/// Returns `None` for unrecognized characters. +fn parse_tile_char(ch: char) -> Option { + match ch { + 'F' => Some(TileKind::Floor), + 'W' => Some(TileKind::Wall), + 'V' => Some(TileKind::Void), + 'R' => Some(TileKind::Restricted), + _ => None, + } +} + +/// Load tile data from all locations in a ContentStore into a WalkabilityMap. +/// +/// For each location that has both `tile_bounds` and `tiles`, parses the tile +/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap. +/// +/// Logs warnings for: +/// - Row count mismatch vs tile_bounds height +/// - Column count mismatch vs tile_bounds width +/// - Unrecognized tile characters +/// +/// Returns the number of locations that had tile data applied. +pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 { + let mut locations_loaded = 0u32; + + for (_district_id, district) in &store.districts { + for location in &district.locations { + if apply_location_tiles(location, walkability) { + locations_loaded += 1; + } + } + } + + locations_loaded +} + +/// Apply tile data from a single Location to the WalkabilityMap. +/// Returns true if tiles were applied, false if skipped. +fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool { + let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else { + return false; + }; + + // Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to + // ~18 quintillion when cast to usize, silently writing tiles at garbage positions. + if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max { + tracing::error!( + "Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping", + location.canonical_id, + bounds.x_min, bounds.x_max, + bounds.y_min, bounds.y_max, + ); + return false; + } + + let expected_height = (bounds.y_max - bounds.y_min + 1) as usize; + let expected_width = (bounds.x_max - bounds.x_min + 1) as usize; + + if tiles.len() != expected_height { + tracing::error!( + "Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes", + location.canonical_id, + tiles.len(), + expected_height, + ); + return false; + } + + for (row_idx, row) in tiles.iter().enumerate() { + let y = bounds.y_min + row_idx as i32; + + if row.len() != expected_width { + tracing::error!( + "Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes", + location.canonical_id, + row_idx, + row.len(), + expected_width, + ); + continue; + } + + for (col_idx, ch) in row.chars().enumerate() { + let x = bounds.x_min + col_idx as i32; + let pos = TilePosition::new(x, y, bounds.z); + + match parse_tile_char(ch) { + Some(kind) => { + let walkable = matches!(kind, TileKind::Floor); + walkability.set_walkable(&pos, walkable); + walkability.set_tile_kind(&pos, kind); + } + None => { + tracing::warn!( + "Location '{}' row {} col {}: unrecognized tile char '{}'", + location.canonical_id, + row_idx, + col_idx, + ch, + ); + } + } + } + } + + tracing::info!( + "Loaded tiles for location '{}': {}x{} at ({},{}) z={}", + location.canonical_id, + expected_width, + expected_height, + bounds.x_min, + bounds.y_min, + bounds.z, + ); + + true +} + fn is_comment_only_file(path: &Path) -> bool { let Ok(text) = std::fs::read_to_string(path) else { return false; @@ -618,4 +743,193 @@ pools: } } } + + // ----------------------------------------------------------------------- + // Tile loading tests (#577) + // ----------------------------------------------------------------------- + + fn make_location_with_tiles(tiles: Vec<&str>) -> Location { + Location { + canonical_id: "test-loc".to_string(), + display_name: "Test Location".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 0, + y_min: 0, + x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1), + y_max: tiles.len() as i32 - 1, + z: 0, + }), + tiles: Some(tiles.iter().map(|s| s.to_string()).collect()), + sightlines: None, + ambient_sound: None, + social_site: None, + } + } + + #[test] + fn parse_tile_char_all_kinds() { + assert_eq!(parse_tile_char('F'), Some(TileKind::Floor)); + assert_eq!(parse_tile_char('W'), Some(TileKind::Wall)); + assert_eq!(parse_tile_char('V'), Some(TileKind::Void)); + assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted)); + assert_eq!(parse_tile_char('X'), None); + assert_eq!(parse_tile_char(' '), None); + } + + #[test] + fn apply_location_tiles_stamps_walkability() { + let loc = make_location_with_tiles(vec![ + "FWF", + "FFF", + "WFW", + ]); + let mut map = WalkabilityMap::new(4, 4, 1); + + let applied = apply_location_tiles(&loc, &mut map); + assert!(applied); + + // Row 0: F W F + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(1, 0, 0))); + assert!(map.can_move_to(&TilePosition::new(2, 0, 0))); + + // Row 1: F F F + assert!(map.can_move_to(&TilePosition::new(0, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(2, 1, 0))); + + // Row 2: W F W + assert!(!map.can_move_to(&TilePosition::new(0, 2, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 2, 0))); + assert!(!map.can_move_to(&TilePosition::new(2, 2, 0))); + } + + #[test] + fn apply_location_tiles_stamps_tile_kind() { + let loc = make_location_with_tiles(vec![ + "FWVR", + ]); + let mut map = WalkabilityMap::new(4, 1, 1); + + apply_location_tiles(&loc, &mut map); + + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall); + assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void); + assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted); + } + + #[test] + fn apply_location_tiles_with_offset() { + let loc = Location { + canonical_id: "offset-loc".to_string(), + display_name: "Offset".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 10, + y_min: 20, + x_max: 12, + y_max: 21, + z: 0, + }), + tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]), + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(32, 32, 1); + + apply_location_tiles(&loc, &mut map); + + // (10,20) = F, (11,20) = W, (12,20) = F + assert!(map.can_move_to(&TilePosition::new(10, 20, 0))); + assert!(!map.can_move_to(&TilePosition::new(11, 20, 0))); + assert!(map.can_move_to(&TilePosition::new(12, 20, 0))); + + // (10,21) = W, (11,21) = F, (12,21) = W + assert!(!map.can_move_to(&TilePosition::new(10, 21, 0))); + assert!(map.can_move_to(&TilePosition::new(11, 21, 0))); + assert!(!map.can_move_to(&TilePosition::new(12, 21, 0))); + } + + #[test] + fn apply_location_tiles_skips_without_tiles() { + let loc = Location { + canonical_id: "no-tiles".to_string(), + display_name: "No Tiles".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0, + }), + tiles: None, + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(5, 5, 1); + assert!(!apply_location_tiles(&loc, &mut map)); + } + + #[test] + fn apply_location_tiles_skips_without_bounds() { + let loc = Location { + canonical_id: "no-bounds".to_string(), + display_name: "No Bounds".to_string(), + description: None, + tile_bounds: None, + tiles: Some(vec!["FFF".to_string()]), + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(5, 5, 1); + assert!(!apply_location_tiles(&loc, &mut map)); + } + + #[test] + fn load_location_tiles_from_store() { + let mut store = ContentStore::default(); + let mut district = DistrictContent::default(); + district.locations.push(make_location_with_tiles(vec![ + "FW", + "WF", + ])); + store.districts.insert("test".to_string(), district); + + let mut map = WalkabilityMap::new(4, 4, 1); + let count = load_location_tiles(&store, &mut map); + + assert_eq!(count, 1); + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(1, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 1, 0))); + } + + #[test] + fn location_yaml_with_tiles_deserializes() { + let yaml = r#" +canonical_id: test-room +display_name: "Test Room" +tile_bounds: + x_min: 5 + y_min: 10 + x_max: 9 + y_max: 12 + z: 0 +tiles: + - "FFFFF" + - "FWWWF" + - "FFFFF" +"#; + let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse"); + assert_eq!(loc.canonical_id, "test-room"); + assert!(loc.tiles.is_some()); + let tiles = loc.tiles.unwrap(); + assert_eq!(tiles.len(), 3); + assert_eq!(tiles[0], "FFFFF"); + assert_eq!(tiles[1], "FWWWF"); + assert_eq!(tiles[2], "FFFFF"); + } } diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index a30221592..49ef8d9ca 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -79,6 +79,15 @@ fn load_and_spawn_content(world: &mut World) { let result = spawn::spawn_content(world, &store); tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned); + // Stamp location tile data onto WalkabilityMap (#577) + if world.contains_resource::() { + let mut walkability = world.resource_mut::(); + let tiles_loaded = loader::load_location_tiles(&store, &mut walkability); + if tiles_loaded > 0 { + tracing::info!("Loaded tile data for {} locations", tiles_loaded); + } + } + // Build line pool index let index = line_pool::LinePoolIndex::build(&store); tracing::info!( diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index bb9fd7bed..2873b1337 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -33,6 +33,7 @@ use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::tier::ActiveSim; use crate::simulation::time::DayPhase; +use crate::storyteller::EngagementRecord; // #166 — Template-to-instance mapping use rand::Rng as _; @@ -133,6 +134,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Mood state — drives Layer 4 dialogue selection and monologue tone (#323) entity_commands.insert(npc::mood::MoodState::default()); + // Engagement metrics — storyteller activation scoring (#570, #162) + entity_commands.insert(EngagementRecord::default()); + // Axis 1: Want if let Some(want) = &profile.want { if let Some(kind) = parse_want_kind(&want.primary) { diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 695ced368..ccfcb786d 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -42,6 +42,7 @@ pub struct Discovery { #[derive(Debug, Deserialize)] pub struct DistrictMeta { pub display_name: String, + #[serde(default)] pub description: String, #[serde(default)] pub locations: Vec, @@ -394,6 +395,20 @@ pub struct Location { pub description: Option, #[serde(default)] pub tile_bounds: Option, + /// Tile layout for this location (#577). + /// + /// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y. + /// Each character maps to a server-side TileKind: + /// `F` = Floor (walkable, open space) + /// `W` = Wall (solid obstacle, blocks movement and LOS) + /// `V` = Void (out-of-bounds / unloaded) + /// `R` = Restricted (blocked but traversable by specific entities) + /// + /// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`. + /// Requires `tile_bounds` to be set. Row count must equal + /// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`. + #[serde(default)] + pub tiles: Option>, #[serde(default)] pub sightlines: Option, #[serde(default)] diff --git a/server/src/main.rs b/server/src/main.rs index 91e97c2b0..30c76e037 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -300,6 +300,7 @@ fn send_panic_error(app: &App, panic_msg: &str) { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![SimError { kind: SimErrorKind::Panic, message: format!("Simulation panic: {}", panic_msg), diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index e00c62262..69183666d 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -12,6 +12,7 @@ use crate::knowledge::{ use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; +use crate::storyteller::EngagementRecord; /// Emit knowledge events for entities entering/leaving the observer's LOS. /// @@ -29,6 +30,7 @@ pub fn emit_observation_events( mut event_queue: ResMut, entity_positions: Query<&TilePosition>, anomaly_markers: Query<(), With>, + mut engagement_query: Query<&mut EngagementRecord>, ) { let Some(snapshot) = &buffer.snapshot else { return; @@ -61,6 +63,11 @@ pub fn emit_observation_events( continue; }; + // Increment observation time for engagement tracking (#570) + if let Ok(mut record) = engagement_query.get_mut(entity) { + record.observation_time_ticks += 1; + } + // Get tile position for knowledge tracking let Ok(pos) = entity_positions.get(entity) else { continue; diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 011e65591..7586075e3 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -392,6 +392,9 @@ pub fn compute_observer_snapshot( // Consume pending save/load result for this tick (#553). let save_result = buffer.pending_save_result.take(); + // Consume pending debug response for this tick (#580). + let debug_response = buffer.pending_debug_response.take(); + // Drain triangle crisis events (#250) and convert to wire format. // Drain triangle crisis events (#250) and filter role_assignments against // observer KG (D-010 principle 2: information boundaries are universal). @@ -464,6 +467,7 @@ pub fn compute_observer_snapshot( triangle_crisis_events, state_hash, sim_errors, + debug_response, }); } diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index b38500e98..ed0b7b0fd 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -96,6 +96,23 @@ impl PerceptionQuery for NaturalVision { .map(|&(x, y, sector)| ((x, y), sector)) .collect(); + // --- Boundary wall margin pass (#584) --- + // Walk the LOS boundary and include non-walkable tiles 1 tile beyond. + // This gives the client wall geometry at the fog edge. + let boundary_walls = compute_boundary_walls(&visible_positions, walkability, z); + for (bx, by) in &boundary_walls { + visible_tiles.push(VisibleTile { + x: *bx, + y: *by, + z, + visibility: VisibilitySector::BoundaryWall, + tile_kind: TileKind::Wall, + zone_id: None, + }); + } + // Re-sort after adding boundary walls + visible_tiles.sort_by_key(|t| (t.x, t.y)); + VisibilityGeometry { visible_tiles, visible_positions, @@ -105,6 +122,37 @@ impl PerceptionQuery for NaturalVision { } } +/// Compute wall tiles 1 tile beyond the LOS boundary (#584). +/// +/// For each tile on the boundary of the visible set (has at least one +/// 4-neighbor outside the set), check each non-visible neighbor. +/// If that neighbor is not walkable, include it as a boundary wall. +/// +/// Returns deduplicated (x, y) positions of wall tiles to add. +fn compute_boundary_walls( + visible_positions: &BTreeSet<(i32, i32)>, + walkability: &WalkabilityMap, + z: i32, +) -> Vec<(i32, i32)> { + const NEIGHBORS: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + let mut walls = BTreeSet::new(); + + for &(x, y) in visible_positions { + for (dx, dy) in NEIGHBORS { + let nx = x + dx; + let ny = y + dy; + if !visible_positions.contains(&(nx, ny)) { + let pos = TilePosition::new(nx, ny, z); + if !walkability.can_move_to(&pos) { + walls.insert((nx, ny)); + } + } + } + } + + walls.into_iter().collect() +} + /// Resource wrapping the active perception mode (D-017). /// Defaults to NaturalVision. Swap this resource to change perception modes. #[derive(Resource)] @@ -115,3 +163,103 @@ impl Default for ActivePerceptionMode { Self(Box::new(NaturalVision)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a small walkability map with walls around the edges. + /// Layout (5x5, z=0): + /// W W W W W + /// W F F F W + /// W F F F W + /// W F F F W + /// W W W W W + fn make_walled_map() -> WalkabilityMap { + let mut map = WalkabilityMap::new(5, 5, 1); + // All tiles start walkable (floor). Set border to non-walkable (wall). + for x in 0..5 { + map.set_walkable(&TilePosition::new(x, 0, 0), false); + map.set_walkable(&TilePosition::new(x, 4, 0), false); + } + for y in 0..5 { + map.set_walkable(&TilePosition::new(0, y, 0), false); + map.set_walkable(&TilePosition::new(4, y, 0), false); + } + map + } + + #[test] + fn boundary_walls_include_adjacent_walls() { + // Visible set: just the center tile (2,2) + let visible: BTreeSet<(i32, i32)> = [(2, 2)].into_iter().collect(); + let map = make_walled_map(); + let walls = compute_boundary_walls(&visible, &map, 0); + + // All 4 neighbors of (2,2) are floor tiles (walkable), so no walls. + // This verifies we don't add walkable tiles as boundary walls. + assert!(walls.is_empty()); + } + + #[test] + fn boundary_walls_found_at_edge() { + // Visible set: tiles along the north interior edge (y=1) + let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1), (3, 1)].into_iter().collect(); + let map = make_walled_map(); + let walls = compute_boundary_walls(&visible, &map, 0); + + // North neighbors (y=0) are all walls: (1,0), (2,0), (3,0) + // Also (0,1) is a wall (west of (1,1)) and (4,1) (east of (3,1)) + assert!(walls.contains(&(1, 0))); + assert!(walls.contains(&(2, 0))); + assert!(walls.contains(&(3, 0))); + assert!(walls.contains(&(0, 1))); + assert!(walls.contains(&(4, 1))); + } + + #[test] + fn boundary_walls_deduplicated() { + // Two adjacent visible tiles share a wall neighbor + let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1)].into_iter().collect(); + let map = make_walled_map(); + let walls = compute_boundary_walls(&visible, &map, 0); + + // Count how many times (1,0) appears — should be exactly 1 (deduplicated) + let count = walls.iter().filter(|&&(x, y)| x == 1 && y == 0).count(); + assert_eq!(count, 1, "boundary walls should be deduplicated"); + } + + #[test] + fn boundary_walls_not_in_visible_positions() { + // Verify the full NaturalVision pipeline produces BoundaryWall tiles + // that are NOT in visible_positions. + let map = make_walled_map(); + let nv = NaturalVision; + let pos = TilePosition::new(2, 2, 0); + let geometry = nv.compute_geometry(&pos, FacingDirection::North, &map); + + let boundary_tiles: Vec<_> = geometry + .visible_tiles + .iter() + .filter(|t| t.visibility == VisibilitySector::BoundaryWall) + .collect(); + + // There should be boundary wall tiles (the 5x5 map has walls at edges) + assert!(!boundary_tiles.is_empty(), "expected boundary wall tiles"); + + // None of the boundary wall tiles should be in visible_positions + for tile in &boundary_tiles { + assert!( + !geometry.visible_positions.contains(&(tile.x, tile.y)), + "BoundaryWall tile ({}, {}) should NOT be in visible_positions", + tile.x, + tile.y + ); + } + + // All boundary wall tiles should have TileKind::Wall + for tile in &boundary_tiles { + assert_eq!(tile.tile_kind, TileKind::Wall); + } + } +} diff --git a/server/src/simulation/chunk_streaming.rs b/server/src/simulation/chunk_streaming.rs new file mode 100644 index 000000000..a0282c302 --- /dev/null +++ b/server/src/simulation/chunk_streaming.rs @@ -0,0 +1,337 @@ +//! Chunk streaming system (#578, D-012). +//! +//! Loads chunks near the player and unloads distant chunks based on a +//! configurable radius. For v0.1 the radius covers the entire hand-authored +//! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks +//! remain loaded. The architecture supports future per-demand loading (v0.3+). +//! +//! The system runs on a configurable tick cadence (default: every 10 ticks). +//! It queries the player's TilePosition, computes which chunks should be +//! loaded (Chebyshev distance ≤ radius from the player's chunk), and +//! loads/unloads accordingly. + +use bevy_ecs::prelude::*; + +use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::time::SimulationTime; + +/// How many chunks around the player to keep loaded (Chebyshev distance). +/// +/// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks). +/// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming. +#[derive(Resource, Debug, Clone)] +pub struct ChunkLoadRadius { + pub radius: i32, +} + +impl Default for ChunkLoadRadius { + fn default() -> Self { + Self { radius: 8 } + } +} + +/// How often the streaming system runs, in simulation ticks. +/// +/// Default: 10 ticks (one game-minute at D-031 cadence). +/// Lower values increase responsiveness but add per-tick overhead. +#[derive(Resource, Debug, Clone)] +pub struct ChunkStreamingCadence { + pub ticks: u64, +} + +impl Default for ChunkStreamingCadence { + fn default() -> Self { + Self { ticks: 10 } + } +} + +/// Chunk streaming system — loads/unloads chunks around the player position. +/// +/// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks). +/// Computes the set of chunks within `ChunkLoadRadius` of the player's +/// current chunk (Chebyshev distance), loads missing chunks, and unloads +/// chunks that are now out of range. +/// +/// For v0.1, radius=8 covers the entire district so nothing ever unloads. +/// For v0.3+, the generator fills newly loaded chunks with terrain data. +pub fn chunk_streaming( + time: Res, + cadence: Res, + radius: Res, + walkability: Option>, + player_query: Query<&TilePosition, With>, +) { + let Some(mut walkability) = walkability else { + return; + }; + + // Cadence gate — only run every N ticks + if cadence.ticks > 0 && time.tick % cadence.ticks != 0 { + return; + } + + let Ok(player_pos) = player_query.single() else { + return; + }; + + let player_chunk = player_pos.chunk_coord(); + let r = radius.radius; + + // Load chunks within radius that aren't already loaded + let mut loaded = 0u32; + for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) { + for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) { + let coord = ChunkCoord { + cx, + cy, + z: player_chunk.z, + }; + if walkability.load_chunk(coord) { + loaded += 1; + } + } + } + + // Unload chunks outside radius + let mut unloaded = 0u32; + let to_check = walkability.loaded_chunk_coords(); + for coord in to_check { + // Only manage chunks on the player's z-level + if coord.z != player_chunk.z { + continue; + } + let dx = (coord.cx - player_chunk.cx).abs(); + let dy = (coord.cy - player_chunk.cy).abs(); + if dx > r || dy > r { + walkability.unload_chunk(&coord); + unloaded += 1; + } + } + + if loaded > 0 || unloaded > 0 { + tracing::debug!( + "Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})", + loaded, + unloaded, + player_chunk.cx, + player_chunk.cy, + player_chunk.z, + r, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::schedule::Schedule; + + fn setup_streaming_world( + radius: i32, + cadence: u64, + player_pos: TilePosition, + map_width: i32, + map_height: i32, + ) -> (World, Schedule) { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(ChunkLoadRadius { radius }); + world.insert_resource(ChunkStreamingCadence { ticks: cadence }); + world.insert_resource(WalkabilityMap::new(map_width, map_height, 1)); + world.spawn((PlayerCharacter, player_pos)); + + let mut schedule = Schedule::default(); + schedule.add_systems(chunk_streaming); + (world, schedule) + } + + #[test] + fn default_radius_covers_v01_district() { + // 256×256 visual district = 8×8 chunks of 32 tiles each. + // Player at center (128, 128). Default radius=8. + // All original 64 chunks should still be loaded after streaming runs + // (streaming may also create empty chunks beyond the district boundary). + let (mut world, mut schedule) = setup_streaming_world( + 8, + 1, // run every tick + TilePosition::new(128, 128, 0), + 256, + 256, + ); + + // Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks + assert_eq!(world.resource::().chunk_count(), 64); + + schedule.run(&mut world); + + // All original chunks (0..8, 0..8) must still be loaded — nothing unloaded + let wm = world.resource::(); + for cx in 0..8 { + for cy in 0..8 { + assert!( + wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }), + "chunk ({},{}) should still be loaded", + cx, + cy, + ); + } + } + // Total count ≥ 64 (streaming may also load chunks beyond district boundary) + assert!(wm.chunk_count() >= 64); + } + + #[test] + fn small_radius_unloads_distant_chunks() { + // Start with a 5×5 chunk map (160×160 tiles), player at center. + // Use radius=1 so only 3×3=9 chunks around the player are kept. + let (mut world, mut schedule) = setup_streaming_world( + 1, + 1, + TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5 + 160, + 160, + ); + + // Initial: 5×5 = 25 chunks + let initial_count = world.resource::().chunk_count(); + assert_eq!(initial_count, 25); + + schedule.run(&mut world); + + // After streaming: only 3×3 = 9 chunks around player chunk (2,2) + let after_count = world.resource::().chunk_count(); + assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1"); + + // Verify the player's chunk is still loaded + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + // Corner chunks should be unloaded + assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 })); + } + + #[test] + fn player_movement_loads_new_chunks() { + // Start with radius=1, player at (16, 16) → chunk (0,0). + // Map is 3×3 chunks (96×96 tiles). + let (mut world, mut schedule) = setup_streaming_world( + 1, + 1, + TilePosition::new(16, 16, 0), // chunk (0,0) + 96, + 96, + ); + + // Run streaming — unloads distant chunks + schedule.run(&mut world); + + // Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded + // (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1], + // but negative coords weren't in the original map. + // So loaded: (0,0) and its positive neighbors within range) + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); + // Chunk (2,2) should be unloaded (distance > 1 from (0,0)) + assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + + // Move player to chunk (2,2) + let mut q = world.query_filtered::<&mut TilePosition, With>(); + let mut pos = q.single_mut(&mut world).unwrap(); + pos.x = 80; + pos.y = 80; + + // Advance tick so cadence gate passes + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + // Now chunk (2,2) and its neighbors should be loaded + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 })); + // And chunk (0,0) should now be unloaded (distance 2 from (2,2)) + assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + } + + #[test] + fn cadence_gate_skips_intermediate_ticks() { + let (mut world, mut schedule) = setup_streaming_world( + 1, + 10, // run every 10 ticks + TilePosition::new(80, 80, 0), + 160, + 160, + ); + + // tick=0 → runs (0 % 10 == 0) + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + + // Reload all chunks to simulate "something loads chunks back" + world.insert_resource(WalkabilityMap::new(160, 160, 1)); + assert_eq!(world.resource::().chunk_count(), 25); + + // tick=5 → should NOT run (5 % 10 != 0) + world.resource_mut::().tick = 5; + schedule.run(&mut world); + assert_eq!( + world.resource::().chunk_count(), + 25, + "should not have run at tick 5" + ); + + // tick=10 → should run (10 % 10 == 0) + world.resource_mut::().tick = 10; + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + } + + #[test] + fn zero_cadence_runs_every_tick() { + let (mut world, mut schedule) = setup_streaming_world( + 1, + 0, // cadence=0 means run every tick + TilePosition::new(80, 80, 0), + 160, + 160, + ); + + // tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + } + + #[test] + fn other_z_levels_untouched() { + // Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk). + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(ChunkLoadRadius { radius: 0 }); + world.insert_resource(ChunkStreamingCadence { ticks: 1 }); + + // 2×2 chunks on 2 z-levels = 8 chunks total + world.insert_resource(WalkabilityMap::new(64, 64, 2)); + world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0))); + + let mut schedule = Schedule::default(); + schedule.add_systems(chunk_streaming); + + // Before: 8 chunks (2×2×2) + assert_eq!(world.resource::().chunk_count(), 8); + + schedule.run(&mut world); + + // After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4) + // Total: 1 + 4 = 5 + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); + // z=1 chunks all still there + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 })); + assert_eq!(wm.chunk_count(), 5); + } +} diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 0f802f54d..f271e3444 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -22,6 +22,7 @@ use rand::Rng; use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName}; +use crate::storyteller::EngagementRecord; use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; @@ -449,6 +450,7 @@ pub fn process_talk_interaction( Option<&NpcColorIndex>, Option<&KnowledgeGraph>, )>, + mut engagement_query: Query<&mut EngagementRecord>, ) { let Some(line_pool) = line_pool else { return; @@ -583,6 +585,11 @@ pub fn process_talk_interaction( started_tick: time.tick, }); + // Engagement tracking (#570): increment conversation count for this NPC + if let Ok(mut record) = engagement_query.get_mut(target) { + record.conversation_count += 1; + } + // Trust progression (#324): successful talk warms the NPC trust_queue.push(TrustEvent::TalkCompleted { npc: target, diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 789991789..126ef0f15 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -2,6 +2,7 @@ // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) +use crate::bridge::debug::DebugCommandBuffer; use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; @@ -98,6 +99,7 @@ pub fn process_player_input( reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option>, mut save_load: Option>, + mut debug_cmd_buffer: Option>, door_states: Query<&DoorState>, object_types: Query<&ObjectType>, ) { @@ -119,6 +121,7 @@ pub fn process_player_input( | PlayerAction::TeleportToHub | PlayerAction::SaveGame { .. } | PlayerAction::LoadGame { .. } + | PlayerAction::DebugCommand(_) ) { continue; @@ -364,6 +367,13 @@ pub fn process_player_input( tracing::warn!("LoadGame received but SaveLoadPending resource not registered"); } } + PlayerAction::DebugCommand(cmd) => { + if let Some(ref mut buf) = debug_cmd_buffer { + buf.push(cmd); + } else { + tracing::warn!("DebugCommand received but DebugCommandBuffer not registered"); + } + } } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 048b4a655..b3497debe 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -4,6 +4,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod chunk_streaming; pub mod contraband; pub mod conversation; pub mod dialogue; @@ -50,6 +51,8 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -127,6 +130,12 @@ impl Plugin for SimulationPlugin { .add_systems( Update, zone::detect_zone_crossings.after(movement::validate_movement), + ) + // Chunk streaming (#578, D-012) — loads/unloads chunks around the player. + // Runs before input processing so chunks are available for the current tick. + .add_systems( + Update, + chunk_streaming::chunk_streaming.before(input::process_player_input), ); tracing::debug!("SimulationPlugin initialized"); diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 5b0e1ef23..4f6d6b90e 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -16,10 +16,12 @@ use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::content::ContentStoreResource; use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry}; +use crate::perception::interpretation::ObservationTrigger; use crate::simulation::conversation::NpcName; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; +use crate::storyteller::EngagementRecord; /// Minimum ticks between monologue lines (prevents spam). /// At 10 ticks/game-minute, 300 ticks = 30 game-minutes. @@ -512,19 +514,25 @@ pub fn trigger_event_monologue( ), With, >, + registry: Option>, + mut engagement_query: Query<&mut EngagementRecord>, ) { + // Drain post_conversation queue unconditionally — consumed this tick. + // Saved for NPC attribution (engagement tracking #570) and trigger detection. + let post_conv_npcs: Vec = post_conv_queue.drain(); + let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else { - // Drain post_conversation queue even without a player - post_conv_queue.drain(); return; }; // Don't override existing monologue from higher-priority systems if buffer.event.is_some() { - post_conv_queue.drain(); return; } + // Save previous observation tick before update — needed for NPC attribution (#570) + let previous_observation_tick = state.last_observation_tick; + // Determine which trigger to fire (priority order) let trigger = if observation_queue .as_ref() @@ -540,15 +548,12 @@ pub fn trigger_event_monologue( Some("hear_sound") } else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) { Some("witness_interaction") - } else if !post_conv_queue.is_empty() { + } else if !post_conv_npcs.is_empty() { Some("post_conversation") } else { None }; - // Always drain post_conversation queue (consumed this tick) - post_conv_queue.drain(); - // Update observation tracking regardless of whether we fire if let Some(ref obs_queue) = observation_queue { if !obs_queue.is_empty() { @@ -588,6 +593,36 @@ pub fn trigger_event_monologue( id, time.tick ); + + // Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs. + // Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific. + match trigger { + "observe_npc" => { + // Attribute to all NPCs whose NewEntity event triggered this monologue + if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, ®istry) { + for event in obs_q.iter() { + if event.tick > previous_observation_tick { + if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger { + if let Some(npc_entity) = reg.to_entity(sid) { + if let Ok(mut record) = engagement_query.get_mut(npc_entity) { + record.monologue_trigger_count += 1; + } + } + } + } + } + } + } + "post_conversation" => { + // Attribute to the NPC(s) whose conversation just ended + for npc in &post_conv_npcs { + if let Ok(mut record) = engagement_query.get_mut(*npc) { + record.monologue_trigger_count += 1; + } + } + } + _ => {} // hear_sound, witness_interaction: no NPC-specific attribution + } } /// Check if any NewEntity observation events exist that we haven't processed. diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 1ca9111eb..671677117 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -1,6 +1,7 @@ // Tile-based movement and collision system // Implements Sprint 1 ticket #236: walkability map and movement validation // Extended by #420: TilePresence posture layers for same-tile occupancy (D-054) +// Extended by #576: TileKind layer per tile (server-authoritative tile classification) // Chunk-based storage per D-012: supports chunk load/unload for future borderless generation // Y-down convention: North = y-1, South = y+1 @@ -16,6 +17,30 @@ use crate::simulation::stance::Stance; /// Chunk size in tiles (32x32 per chunk) pub const CHUNK_SIZE: i32 = 32; +/// Server-side authoritative tile classification (#576, D-012). +/// +/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering) +/// but serves a different purpose: simulation logic and tile authoring. +/// +/// Tile format for location YAML authoring (#577): +/// - `F` = Floor (walkable, open space) +/// - `W` = Wall (solid obstacle, blocks movement and LOS) +/// - `V` = Void (out-of-bounds / unloaded; treated as blocked) +/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TileKind { + /// Walkable floor tile. Default for populated chunks. + #[default] + Floor, + /// Solid wall tile. Blocks movement and line-of-sight. + Wall, + /// Void / unloaded tile. Out-of-bounds or ungenerated space. + Void, + /// Restricted tile. Blocked for standard movement but accessible + /// to authorised entities (e.g. locked zones, maintenance airlocks). + Restricted, +} + /// Marker component identifying the player-controlled entity. #[derive(Component, Debug)] pub struct PlayerCharacter; @@ -121,7 +146,7 @@ impl TilePosition { } /// Get the chunk coordinate this tile belongs to. - fn chunk_coord(&self) -> ChunkCoord { + pub fn chunk_coord(&self) -> ChunkCoord { ChunkCoord { cx: self.x.div_euclid(CHUNK_SIZE), cy: self.y.div_euclid(CHUNK_SIZE), @@ -143,22 +168,43 @@ pub struct ChunkCoord { pub z: i32, } -/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles). +/// Per-tile storage cell: walkability flag and tile classification. +/// Private — exposed only through WalkabilityMap's public API. +#[derive(Debug, Clone, Copy)] +struct TileCell { + walkable: bool, + kind: TileKind, +} + +/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles). +/// Extended by #576 to carry TileKind alongside the walkability bool. #[derive(Debug, Clone)] struct ChunkData { - tiles: Vec, // CHUNK_SIZE * CHUNK_SIZE, true = walkable + tiles: Vec, } impl ChunkData { fn new_walkable() -> Self { Self { - tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize], + tiles: vec![ + TileCell { + walkable: true, + kind: TileKind::Floor + }; + (CHUNK_SIZE * CHUNK_SIZE) as usize + ], } } fn new_blocked() -> Self { Self { - tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize], + tiles: vec![ + TileCell { + walkable: false, + kind: TileKind::Wall + }; + (CHUNK_SIZE * CHUNK_SIZE) as usize + ], } } @@ -166,12 +212,24 @@ impl ChunkData { (ly * CHUNK_SIZE + lx) as usize } + /// Returns the walkability flag for a tile (backward-compatible internal accessor). fn get(&self, lx: i32, ly: i32) -> bool { - self.tiles[Self::index(lx, ly)] + self.tiles[Self::index(lx, ly)].walkable } + /// Sets only the walkability flag; tile kind is unchanged. fn set(&mut self, lx: i32, ly: i32, walkable: bool) { - self.tiles[Self::index(lx, ly)] = walkable; + self.tiles[Self::index(lx, ly)].walkable = walkable; + } + + /// Returns the tile kind for a cell. + fn get_kind(&self, lx: i32, ly: i32) -> TileKind { + self.tiles[Self::index(lx, ly)].kind + } + + /// Sets the tile kind for a cell; walkability is unchanged. + fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) { + self.tiles[Self::index(lx, ly)].kind = kind; } } @@ -226,6 +284,7 @@ impl WalkabilityMap { } /// Set walkability of a tile. Creates the chunk if it doesn't exist. + /// Does not change the tile's TileKind. pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { let coord = pos.chunk_coord(); let (lx, ly) = pos.local_offset(); @@ -236,6 +295,27 @@ impl WalkabilityMap { chunk.set(lx, ly, walkable); } + /// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks. + pub fn tile_kind(&self, pos: &TilePosition) -> TileKind { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + self.chunks + .get(&coord) + .map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly)) + } + + /// Set the tile kind at a position. Creates the chunk if it doesn't exist. + /// Does not change the tile's walkability. + pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + let chunk = self + .chunks + .entry(coord) + .or_insert_with(ChunkData::new_blocked); + chunk.set_kind(lx, ly, kind); + } + /// Check if a chunk is loaded. pub fn has_chunk(&self, coord: &ChunkCoord) -> bool { self.chunks.contains_key(coord) @@ -259,6 +339,12 @@ impl WalkabilityMap { pub fn chunk_count(&self) -> usize { self.chunks.len() } + + /// Returns the coordinates of all loaded chunks (#578). + /// Used by the chunk streaming system to determine which chunks to unload. + pub fn loaded_chunk_coords(&self) -> Vec { + self.chunks.keys().copied().collect() + } } /// Component representing an intent to move to a target tile. @@ -366,6 +452,89 @@ pub fn validate_movement( mod tests { use super::*; + // ----------------------------------------------------------------------- + // TileKind layer tests (#576) + // ----------------------------------------------------------------------- + + #[test] + fn tile_kind_default_is_floor_for_walkable_chunk() { + let map = WalkabilityMap::new(10, 10, 1); + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor); + } + + #[test] + fn tile_kind_unloaded_chunk_returns_void() { + let map = WalkabilityMap::new(10, 10, 1); + // Negative coords → unloaded chunk → Void + assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void); + assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void); + // z-level not loaded → Void + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void); + } + + #[test] + fn tile_kind_round_trip() { + let mut map = WalkabilityMap::new(10, 10, 1); + let pos = TilePosition::new(5, 5, 0); + + map.set_tile_kind(&pos, TileKind::Wall); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + + map.set_tile_kind(&pos, TileKind::Restricted); + assert_eq!(map.tile_kind(&pos), TileKind::Restricted); + + map.set_tile_kind(&pos, TileKind::Void); + assert_eq!(map.tile_kind(&pos), TileKind::Void); + + map.set_tile_kind(&pos, TileKind::Floor); + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + } + + #[test] + fn tile_kind_independent_of_walkability() { + let mut map = WalkabilityMap::new(10, 10, 1); + let pos = TilePosition::new(3, 3, 0); + + // Start: Floor + walkable + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + assert!(map.can_move_to(&pos)); + + // Set walkable = false; kind should remain Floor + map.set_walkable(&pos, false); + assert!(!map.can_move_to(&pos)); + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + + // Set kind = Wall; walkability should remain false + map.set_tile_kind(&pos, TileKind::Wall); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + assert!(!map.can_move_to(&pos)); + + // Restore walkable = true; kind should stay Wall + map.set_walkable(&pos, true); + assert!(map.can_move_to(&pos)); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + } + + #[test] + fn tile_kind_set_creates_chunk_on_demand() { + let mut map = WalkabilityMap::new(1, 1, 1); + let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk + + assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + map.set_tile_kind(&new_chunk_pos, TileKind::Restricted); + assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted); + } + + #[test] + fn blocked_chunk_default_kind_is_wall() { + let map = WalkabilityMap::new_blocked(32, 32, 1); + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall); + assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall); + } + #[test] fn tile_position_equality() { let pos1 = TilePosition::new(5, 10, 0); diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index ac14a2ece..c73475977 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -31,7 +31,10 @@ use crate::simulation::interaction::DoorState; use crate::simulation::tier::{ActiveSim, BackgroundSim}; use crate::simulation::time::SimulationTime; use crate::content::template::TriangleCrisisEventQueue; -use crate::storyteller::{ContaminationActive, ContaminationEventQueue}; +use crate::storyteller::{ + ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer, + TriangleActivatedQueue, +}; /// Errors from save/load operations (#553). #[derive(Debug, Error)] @@ -149,6 +152,12 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> contamination_active: world .get_resource::() .map_or(false, |c| c.0), + activated_count: world + .get_resource::() + .map_or(0, |a| a.activated_count), + last_activation_tick: world + .get_resource::() + .and_then(|a| a.last_activation_tick), }; let bytes = state @@ -268,10 +277,18 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro // Restore contamination state (#254) — prevents double-firing on reload. world.insert_resource(ContaminationActive(state.contamination_active)); - // Reset event queues — prevent stale events from the pre-load world - // leaking into the post-load simulation. + // Restore activation state (#572) — prevents double-activation on reload. + world.insert_resource(ActivationState { + activated_count: state.activated_count, + last_activation_tick: state.last_activation_tick, + }); + + // Reset event queues and transient buffers — prevent stale events/history + // from the pre-load world leaking into the post-load simulation. world.insert_resource(ContaminationEventQueue::default()); world.insert_resource(TriangleCrisisEventQueue::default()); + world.insert_resource(TriangleActivatedQueue::default()); + world.insert_resource(MovementHistoryBuffer::default()); // Restore door open states (#246) — find door entities by StableId and toggle. if !state.open_doors.is_empty() { @@ -401,6 +418,7 @@ mod tests { w.insert_resource(RelationshipGraph::new()); w.init_resource::(); w.init_resource::(); + w.init_resource::(); w } @@ -591,6 +609,8 @@ mod tests { open_doors: vec![], modifications: vec![], contamination_active: false, + activated_count: 0, + last_activation_tick: None, }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); @@ -862,4 +882,65 @@ mod tests { let _ = std::fs::remove_file(&path); } + + // ----------------------------------------------------------------------- + // ActivationState roundtrip (#572, Task #12) + // ----------------------------------------------------------------------- + + #[test] + fn load_from_file_restores_activation_state() { + let mut world = minimal_world(); + + // Set activation state: 1 activation at tick 500 + world.insert_resource(ActivationState { + activated_count: 1, + last_activation_tick: Some(500), + }); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Reset activation state to defaults before load + world.insert_resource(ActivationState::default()); + assert_eq!(world.resource::().activated_count, 0); + assert!(world.resource::().last_activation_tick.is_none()); + + load_from_file(&path, &mut world).expect("load"); + + let state = world.resource::(); + assert_eq!( + state.activated_count, 1, + "activated_count must survive save/load" + ); + assert_eq!( + state.last_activation_tick, + Some(500), + "last_activation_tick must survive save/load" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_restores_zero_activation_state() { + // Verify that saves with no activations restore correctly (serde(default)) + let mut world = minimal_world(); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Pollute state before load + world.insert_resource(ActivationState { + activated_count: 5, + last_activation_tick: Some(9999), + }); + + load_from_file(&path, &mut world).expect("load"); + + let state = world.resource::(); + assert_eq!(state.activated_count, 0); + assert!(state.last_activation_tick.is_none()); + + let _ = std::fs::remove_file(&path); + } } diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 4e52b2084..cf1979d6c 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -55,6 +55,7 @@ use crate::npc::relationships::RelationshipGraph; use crate::npc::vision::{NpcMemory, NpcVisionState}; use crate::simulation::movement::TilePosition; use crate::simulation::time::TickRate; +use crate::storyteller::EngagementRecord; /// Current format version. Bump on any breaking schema change. pub const SAVE_FORMAT_VERSION: u8 = 1; @@ -111,6 +112,16 @@ pub struct SaveStateV1 { /// and apply a duplicate tension delta to all ActiveFork triangles. #[serde(default)] pub contamination_active: bool, + /// Number of triangles activated this session (#572). + /// Persisted to prevent double-activation on save/load — without this, + /// reloading a save after activation would reset the one-shot guard + /// and allow a second triangle to be activated. + #[serde(default)] + pub activated_count: u32, + /// Tick at which the most recent triangle activation occurred (#572). + /// `None` if no activation yet. Persisted alongside `activated_count`. + #[serde(default)] + pub last_activation_tick: Option, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -363,6 +374,7 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E NpcVisionState::default(), NpcMemory::default(), PlayerAwareness::default(), + EngagementRecord::default(), )) .id(); @@ -425,6 +437,8 @@ mod tests { open_doors: vec![], modifications: vec![], contamination_active: false, + activated_count: 0, + last_activation_tick: None, } } @@ -824,6 +838,8 @@ mod tests { open_doors: vec![], modifications: vec![], contamination_active: false, + activated_count: 0, + last_activation_tick: None, }; let bytes = save.to_bytes().expect("serialize"); diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index 4b980210f..f135d415d 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -12,11 +12,54 @@ //! 1. Sets `ContaminationActive` resource to true (one-shot) //! 2. Applies a tension delta to all `ActiveFork` triangles //! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.) +//! +//! ## Activation Lifecycle (#572) +//! +//! After contamination, the activation pass (#162, #579) runs on a 10-tick +//! cadence, scoring NPCs by engagement and activating the best-fit triangle. +//! +//! **v0.1 lifecycle rules (single-activation model):** +//! +//! 1. **One activation per session.** The vertical slice is a ~30-minute +//! experience. Once a triangle is activated, the storyteller does not +//! activate additional triangles. `ActivationState::Activated` is the +//! terminal state for the storyteller within a single playthrough. +//! +//! 2. **No concurrent activations.** At most one triangle can be in the +//! `TrianglePhase::Active` state at any time. This is trivially enforced +//! by rule 1 for v0.1 — future versions (v0.3+) will need a concurrency +//! limit and priority queue. +//! +//! 3. **No cooldown.** Since only one activation fires, there is no cooldown +//! period between activations. Future multi-activation will need +//! `ACTIVATION_COOLDOWN_TICKS` — stub the constant now at 0. +//! +//! 4. **Resolution is terminal.** When a triangle reaches `TrianglePhase::Resolved` +//! (player completes investigation or consequences fire), it stays resolved. +//! The storyteller does not re-activate resolved triangles. For v0.1 this +//! is moot (one-shot), but the activation pass must still check for it. +//! +//! 5. **Activation cadence.** The activation pass polls every +//! `ACTIVATION_CADENCE_TICKS` (10 ticks = 1 game-second). This is cheap +//! because the pass no-ops if `ActivationState` is already `Activated`. +//! +//! **Future extensions (v0.3+):** +//! - `MAX_CONCURRENT_ACTIVATIONS` > 1 +//! - `ACTIVATION_COOLDOWN_TICKS` > 0 between successive activations +//! - `SelectionStrategy::WeightedRandom` alongside `HighestScore` +//! - Hubris wall gating (activation blocked above a threshold) + +use std::collections::VecDeque; use bevy_app::prelude::*; use bevy_ecs::prelude::*; +use rand::Rng; -use crate::content::template::{TriangleClassification, TriangleState}; +use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState}; +use crate::knowledge::EntityRegistry; +use crate::npc::Npc; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; @@ -34,10 +77,68 @@ pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10; /// 0–100 scale. Not yet consumed — placeholder for future confrontation system. pub const CONFRONTATION_THRESHOLD: u8 = 75; +// --- Activation lifecycle constants (#572) ---------------------------------- + +/// How often the activation pass runs, in ticks. +/// 10 ticks = 1 game-second (D-031). Cheap — no-ops if already activated. +pub const ACTIVATION_CADENCE_TICKS: u64 = 10; + +/// Maximum concurrent triangle activations. v0.1 = 1 (single-activation). +pub const MAX_CONCURRENT_ACTIVATIONS: u32 = 1; + +/// Cooldown between successive activations, in ticks. v0.1 = 0 (one-shot). +/// Future multi-activation (v0.3+) will set this to e.g. 600 (1 game-minute). +pub const ACTIVATION_COOLDOWN_TICKS: u64 = 0; + +/// Engagement window for NPC co-presence scoring (#162 step 2). +/// 3000 ticks = 5 game-minutes = ~5 real minutes at 10 tps. +pub const ENGAGEMENT_WINDOW_TICKS: u64 = 3000; + +// --- Engagement scoring weights (#162 step 3) ------------------------------- + +/// Weight per conversation (capped at `CONVERSATION_CAP`). +pub const ENGAGEMENT_WEIGHT_CONVERSATION: f32 = 2.0; + +/// Maximum conversations counted toward engagement score. +pub const CONVERSATION_CAP: u32 = 5; + +/// Weight per tick of observation time. +/// 200 ticks (~20 real seconds) = 1 point. +pub const ENGAGEMENT_WEIGHT_OBSERVATION: f32 = 0.005; + +/// Weight per monologue trigger — richest signal. +pub const ENGAGEMENT_WEIGHT_MONOLOGUE: f32 = 8.0; + +/// Proximity threshold (Chebyshev tiles) for co-presence detection in activation_pass. +/// Matches NaturalVision forward_range (D-015, D-017): 20 tiles. +pub const COPRESENCE_THRESHOLD: i32 = 20; + // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- +/// Per-NPC engagement metrics for storyteller activation scoring (#570, #162). +/// +/// Tracks cumulative engagement signals the player has generated with a specific NPC. +/// Used by `activation_pass()` (#579) to score NPCs and select the best-fit triangle. +/// +/// All three fields are additive counters — never overwritten, only incremented. +/// +/// **Write sites:** +/// - `observation_time_ticks`: `perception::observation::emit_observation_events` — +1 per tick in LOS +/// - `conversation_count`: `simulation::dialogue::process_talk_interaction` — +1 on Talk start +/// - `monologue_trigger_count`:`simulation::monologue::trigger_event_monologue` — +1 on NPC-context fire +#[derive(Component, Debug, Clone, Default)] +pub struct EngagementRecord { + /// Cumulative ticks this NPC was in the player's direct LOS. + pub observation_time_ticks: u64, + /// Number of player-initiated conversation starts with this NPC. + pub conversation_count: u32, + /// Number of monologue triggers fired in this NPC's context + /// (observe_npc or post_conversation referencing this entity). + pub monologue_trigger_count: u32, +} + /// Whether contamination has been activated by the storyteller. /// /// Once set to `true`, it stays true for the remainder of the session. @@ -45,6 +146,56 @@ pub const CONFRONTATION_THRESHOLD: u8 = 75; #[derive(Resource, Debug, Clone, Default)] pub struct ContaminationActive(pub bool); +/// Storyteller activation state (#572). +/// +/// Tracks how many triangles have been activated this session. For v0.1 this +/// is a simple boolean gate — once `activated_count >= MAX_CONCURRENT_ACTIVATIONS`, +/// the activation pass no-ops. Persisted in `SaveStateV1`. +#[derive(Resource, Debug, Clone)] +pub struct ActivationState { + /// Number of triangles activated this session. + pub activated_count: u32, + /// Tick at which the most recent activation occurred. `None` if no activation yet. + pub last_activation_tick: Option, +} + +impl Default for ActivationState { + fn default() -> Self { + Self { + activated_count: 0, + last_activation_tick: None, + } + } +} + +impl ActivationState { + /// Whether the activation pass should attempt to activate a new triangle. + /// + /// For v0.1 this returns `false` after the first activation. Future versions + /// will also check cooldown elapsed since `last_activation_tick`. + pub fn can_activate(&self, _current_tick: u64) -> bool { + if self.activated_count >= MAX_CONCURRENT_ACTIVATIONS { + return false; + } + // v0.1: cooldown is 0, so no cooldown check needed. + // Future: check (current_tick - last_activation_tick) >= ACTIVATION_COOLDOWN_TICKS + if ACTIVATION_COOLDOWN_TICKS > 0 { + if let Some(last) = self.last_activation_tick { + if _current_tick.saturating_sub(last) < ACTIVATION_COOLDOWN_TICKS { + return false; + } + } + } + true + } + + /// Record that a triangle was activated at the given tick. + pub fn record_activation(&mut self, tick: u64) { + self.activated_count += 1; + self.last_activation_tick = Some(tick); + } +} + // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- @@ -84,6 +235,129 @@ impl ContaminationEventQueue { } } +/// Emitted when the activation pass selects and activates a triangle (#162, #572). +/// +/// Downstream consumers: +/// - Tell system (D-024 axis 9): escalate tell behavior frequency +/// - Routine scheduler: activated triangle NPCs may deviate from routine +/// - Monologue system (D-016): unlock `triangle_activated` context lines +/// - FRIEND arc manager (D-034): advance relationship phase if applicable +#[derive(Debug, Clone)] +pub struct TriangleActivatedEvent { + /// Which triangle was activated. + pub triangle_id: TriangleId, + /// Tick at which activation occurred. + pub tick: u64, + /// The NPC entity that scored highest (activation anchor). + pub anchor_entity: Entity, + /// Engagement score of the anchor NPC. + pub anchor_score: f32, +} + +/// Resource queue for triangle activation events. +/// +/// Same drain pattern as `ContaminationEventQueue`. Populated by +/// `activation_pass()` (#579), consumed by downstream systems. +#[derive(Resource, Default)] +pub struct TriangleActivatedQueue { + pub events: Vec, +} + +impl TriangleActivatedQueue { + pub fn push(&mut self, event: TriangleActivatedEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Movement history (#571) +// --------------------------------------------------------------------------- + +/// Ring buffer of recent player tile positions for storyteller proximity scoring. +/// +/// Retains the player's path over the last `ENGAGEMENT_WINDOW_TICKS` ticks. +/// Provides `npcs_copresent_in_window` to identify which NPCs have shared +/// space with the player recently, feeding the activation pass (#162, #579). +#[derive(Resource, Debug, Clone)] +pub struct MovementHistoryBuffer { + positions: VecDeque, +} + +impl Default for MovementHistoryBuffer { + fn default() -> Self { + Self { + positions: VecDeque::with_capacity(ENGAGEMENT_WINDOW_TICKS as usize), + } + } +} + +impl MovementHistoryBuffer { + /// Append the player's current position for this tick. + /// + /// Maintains a maximum of `ENGAGEMENT_WINDOW_TICKS` entries by evicting + /// the oldest position when the buffer is full. + pub fn append(&mut self, pos: TilePosition) { + if self.positions.len() >= ENGAGEMENT_WINDOW_TICKS as usize { + self.positions.pop_front(); + } + self.positions.push_back(pos); + } + + /// Returns entities from `npc_positions` whose position is within + /// `threshold` tiles (Chebyshev distance) of any recorded player position + /// in the buffer on the same z-level. + /// + /// Used by the activation pass (#579) to identify which NPCs the player + /// was co-present with during the engagement window. + pub fn npcs_copresent_in_window( + &self, + npc_positions: impl Iterator, + threshold: i32, + ) -> Vec { + npc_positions + .filter(|(_, npc_pos)| { + self.positions.iter().any(|player_pos| { + player_pos.z == npc_pos.z + && (player_pos.x - npc_pos.x).abs() <= threshold + && (player_pos.y - npc_pos.y).abs() <= threshold + }) + }) + .map(|(entity, _)| entity) + .collect() + } + + /// Number of recorded positions in the buffer. + pub fn len(&self) -> usize { + self.positions.len() + } + + /// True if the buffer has no recorded positions. + pub fn is_empty(&self) -> bool { + self.positions.is_empty() + } +} + +/// System: append the player's current position to the movement history buffer. +/// +/// Runs each tick after `validate_movement`. Maintains the sliding +/// `ENGAGEMENT_WINDOW_TICKS` window consumed by the activation pass (#162). +pub fn append_player_history( + mut history: ResMut, + player_query: Query<&TilePosition, With>, +) { + if let Ok(pos) = player_query.single() { + history.append(*pos); + } +} + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -95,7 +369,22 @@ impl Plugin for StorytellerPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, tick_contamination_activation); + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, tick_contamination_activation) + .add_systems( + Update, + append_player_history + .after(crate::simulation::movement::validate_movement), + ) + .add_systems( + Update, + activation_pass + .after(append_player_history) + .after(tick_contamination_activation) + .before(crate::simulation::time::advance_tick), + ); tracing::debug!("StorytellerPlugin initialized"); } @@ -153,6 +442,166 @@ pub fn tick_contamination_activation( ); } +/// Helper: compute engagement score from an EngagementRecord. +/// +/// Formula: capped conversations × weight + observation ticks × weight + monologue triggers × weight. +/// Implements #162 step 3 scoring. +fn compute_engagement_score(record: &EngagementRecord) -> f32 { + let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32 + * ENGAGEMENT_WEIGHT_CONVERSATION; + // Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24), + // where f32 can no longer distinguish adjacent integers. + let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32; + let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE; + conv_score + obs_score + mono_score +} + +/// System: storyteller activation pass — select and activate the best-fit triangle. +/// +/// Implements #162 steps 1–6. Runs on a 10-tick cadence after contamination fires. +/// +/// Steps: +/// 1. Gate: contamination active + activation limit not reached + cadence check. +/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer). +/// 3. Engagement scoring: score each co-present NPC via EngagementRecord. +/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC. +/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+). +/// Holds (no activation) if no triangle-assigned NPC is co-present. +/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState. +#[allow(clippy::too_many_arguments)] +pub fn activation_pass( + time: Res, + contamination: Res, + mut activation_state: ResMut, + history: Res, + registry: Res, + mut rng: ResMut, + mut triangles: Query<(Entity, &mut TriangleState), With>, + npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With, With)>, + mut event_queue: ResMut, +) { + // Gate 1: contamination must be active + if !contamination.0 { + return; + } + // Gate 2: activation limit + if !activation_state.can_activate(time.tick) { + return; + } + // Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS. + // Guard: at tick 0 all engagement scores are 0.0 and selection is random + // (e.g. contamination pre-set in a save file). Skip tick 0. + if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 { + return; + } + + // Step 2: co-presence query + let npc_positions: Vec<(Entity, TilePosition)> = + npcs.iter().map(|(e, pos, _)| (e, *pos)).collect(); + let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD); + // Deduplicate: the co-presence query can return the same entity more than once + // if multiple player positions fall near the same NPC. Without dedup, the NPC + // gets added to candidates twice, doubling its RNG weight. + copresent.sort_unstable(); + copresent.dedup(); + + // Snapshot simmering triangles for read (avoids double-borrow during activation write) + let simmering: Vec<(Entity, TriangleState)> = triangles + .iter() + .filter(|(_, s)| s.phase == TrianglePhase::Simmering) + .map(|(e, s)| (e, s.clone())) + .collect(); + + if simmering.is_empty() { + tracing::warn!("activation_pass: no Simmering triangles — holding"); + return; + } + + // Steps 3+4: score co-present NPCs that belong to a Simmering triangle. + // Unentangled NPCs (not in any triangle) are excluded; v0.1 skips D-025 routing. + let mut candidates: Vec<(Entity, Entity, f32)> = Vec::new(); // (npc_entity, tri_entity, score) + for &npc_entity in &copresent { + let Some(stable_id) = registry.to_stable(npc_entity) else { + continue; + }; + let score = npcs + .get(npc_entity) + .ok() + .and_then(|(_, _, r)| r) + .map(compute_engagement_score) + .unwrap_or(0.0); + for (tri_entity, tri_state) in &simmering { + if tri_state + .role_assignments + .values() + .any(|sid| *sid == stable_id) + { + candidates.push((npc_entity, *tri_entity, score)); + break; // each NPC is assigned to at most one triangle per pass + } + } + } + + if candidates.is_empty() { + tracing::warn!( + "activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding", + time.tick + ); + return; + } + + // Step 5: select best candidate; SimRng tie-break among equal top scores (D-010) + candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + let top_score = candidates[0].2; + let top_count = candidates + .iter() + .take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0)) + .count(); + let selected_idx = if top_count > 1 { + rng.rng.random_range(0..top_count) + } else { + 0 + }; + let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx]; + + // Retrieve triangle_id for the event. + // Guard: the entity was in the simmering snapshot we collected above, but in + // theory it could have been despawned between snapshot and lookup (unlikely but + // a panic in the gameplay loop is unacceptable). + let Some(triangle_id) = simmering + .iter() + .find(|(e, _)| *e == tri_entity) + .map(|(_, s)| s.triangle_id) + else { + tracing::error!( + "activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}", + tri_entity, time.tick + ); + return; + }; + + // Step 6: set triangle phase to Active + if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) { + tri_state.phase = TrianglePhase::Active; + } + + // Step 7: record activation and emit event + activation_state.record_activation(time.tick); + event_queue.push(TriangleActivatedEvent { + triangle_id, + tick: time.tick, + anchor_entity, + anchor_score, + }); + + tracing::info!( + "activation_pass: activated triangle {} at tick {} (anchor score: {:.2})", + triangle_id.0, + time.tick, + anchor_score, + ); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -311,4 +760,325 @@ mod tests { let state = q.single(&world).unwrap(); assert_eq!(state.tension, 255); } + + // --- ActivationState lifecycle tests (#572) --- + + #[test] + fn activation_state_allows_first_activation() { + let state = ActivationState::default(); + assert!(state.can_activate(500)); + } + + #[test] + fn activation_state_blocks_after_max() { + let mut state = ActivationState::default(); + state.record_activation(500); + // v0.1: MAX_CONCURRENT_ACTIVATIONS = 1, so second is blocked + assert!(!state.can_activate(600)); + } + + #[test] + fn activation_state_records_tick() { + let mut state = ActivationState::default(); + assert_eq!(state.activated_count, 0); + assert!(state.last_activation_tick.is_none()); + + state.record_activation(1000); + assert_eq!(state.activated_count, 1); + assert_eq!(state.last_activation_tick, Some(1000)); + } + + #[test] + fn triangle_activated_queue_drain() { + let mut queue = TriangleActivatedQueue::default(); + assert!(queue.is_empty()); + + let mut world = World::new(); + let entity = world.spawn_empty().id(); + queue.push(TriangleActivatedEvent { + triangle_id: TriangleId::from_seed_and_slug(0, "test"), + tick: 500, + anchor_entity: entity, + anchor_score: 12.5, + }); + assert!(!queue.is_empty()); + + let events = queue.drain(); + assert_eq!(events.len(), 1); + assert!(queue.is_empty()); + } + + // --- MovementHistoryBuffer tests (#571) --- + + #[test] + fn movement_history_append_and_len() { + let mut buf = MovementHistoryBuffer::default(); + assert!(buf.is_empty()); + + buf.append(TilePosition::new(1, 2, 0)); + buf.append(TilePosition::new(3, 4, 0)); + assert_eq!(buf.len(), 2); + } + + #[test] + fn movement_history_evicts_oldest_at_capacity() { + let mut buf = MovementHistoryBuffer::default(); + // Fill to capacity + for i in 0..ENGAGEMENT_WINDOW_TICKS as i32 { + buf.append(TilePosition::new(i, 0, 0)); + } + assert_eq!(buf.len(), ENGAGEMENT_WINDOW_TICKS as usize); + + // One more — should evict x=0 + buf.append(TilePosition::new(9999, 0, 0)); + assert_eq!( + buf.len(), + ENGAGEMENT_WINDOW_TICKS as usize, + "buffer must not grow beyond ENGAGEMENT_WINDOW_TICKS" + ); + // The oldest entry (x=0) should be gone; newest (x=9999) should be present + let mut world = World::new(); + let entity_old = world.spawn_empty().id(); + let entity_new = world.spawn_empty().id(); + let copresent = buf.npcs_copresent_in_window( + [(entity_old, TilePosition::new(0, 0, 0))].into_iter(), + 0, + ); + assert!( + copresent.is_empty(), + "NPC at evicted position x=0 should not be copresent" + ); + let copresent_new = buf.npcs_copresent_in_window( + [(entity_new, TilePosition::new(9999, 0, 0))].into_iter(), + 0, + ); + assert_eq!(copresent_new.len(), 1, "NPC at newest position should be copresent"); + } + + #[test] + fn npcs_copresent_finds_nearby_npc() { + let mut buf = MovementHistoryBuffer::default(); + buf.append(TilePosition::new(10, 10, 0)); + buf.append(TilePosition::new(11, 10, 0)); + + let mut world = World::new(); + let nearby = world.spawn_empty().id(); + let distant = world.spawn_empty().id(); + + // NPC 1 tile from a history position — within threshold 2 + let result = buf.npcs_copresent_in_window( + [ + (nearby, TilePosition::new(10, 11, 0)), + (distant, TilePosition::new(50, 50, 0)), + ] + .into_iter(), + 2, + ); + + assert!(result.contains(&nearby), "nearby NPC should be copresent"); + assert!(!result.contains(&distant), "distant NPC should not be copresent"); + } + + #[test] + fn npcs_copresent_different_z_not_included() { + let mut buf = MovementHistoryBuffer::default(); + buf.append(TilePosition::new(10, 10, 0)); + + let mut world = World::new(); + let npc = world.spawn_empty().id(); + + // Same x/y but different z + let result = buf.npcs_copresent_in_window( + [(npc, TilePosition::new(10, 10, 1))].into_iter(), + 0, + ); + assert!( + result.is_empty(), + "NPC on different z-level must not be copresent" + ); + } + + #[test] + fn append_player_history_system_appends_position() { + let mut world = World::new(); + world.init_resource::(); + world.spawn((PlayerCharacter, TilePosition::new(5, 7, 0))); + + let mut schedule = Schedule::default(); + schedule.add_systems(append_player_history); + schedule.run(&mut world); + + let buf = world.resource::(); + assert_eq!(buf.len(), 1); + } + + #[test] + fn append_player_history_system_no_player_no_panic() { + // Should silently no-op if no player entity is present + let mut world = World::new(); + world.init_resource::(); + + let mut schedule = Schedule::default(); + schedule.add_systems(append_player_history); + schedule.run(&mut world); + + assert!(world.resource::().is_empty()); + } + + // --- activation_pass tests (#579) --- + + fn setup_activation_world() -> (World, Schedule) { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(ContaminationActive(true)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::simulation::rng::SimRng::new(42)); + let mut schedule = Schedule::default(); + schedule.add_systems(activation_pass); + (world, schedule) + } + + #[test] + fn activation_pass_blocked_before_contamination() { + let (mut world, mut schedule) = setup_activation_world(); + world.insert_resource(ContaminationActive(false)); + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; + + schedule.run(&mut world); + + assert!(world.resource::().is_empty()); + assert_eq!(world.resource::().activated_count, 0); + } + + #[test] + fn activation_pass_blocked_off_cadence() { + let (mut world, mut schedule) = setup_activation_world(); + // Off-cadence tick (not divisible by ACTIVATION_CADENCE_TICKS) + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS + 3; + + schedule.run(&mut world); + + assert!(world.resource::().is_empty()); + } + + #[test] + fn activation_pass_blocked_after_max_activations() { + let (mut world, mut schedule) = setup_activation_world(); + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; + // Record activation up to the limit + world.resource_mut::().record_activation(0); + + schedule.run(&mut world); + + // Should not fire again + assert!(world.resource::().is_empty()); + } + + #[test] + fn activation_pass_holds_when_no_triangle_npc_copresent() { + // No NPCs present at all — no candidates, should hold (not activate a random triangle) + let (mut world, mut schedule) = setup_activation_world(); + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; + + // Spawn a Simmering triangle (but no NPCs) + world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)); + + schedule.run(&mut world); + + // Should NOT activate — no co-present NPC is assigned to any triangle + assert!( + world.resource::().is_empty(), + "activation should hold when no triangle-assigned NPC is copresent" + ); + assert_eq!(world.resource::().activated_count, 0); + } + + #[test] + fn activation_pass_activates_triangle_via_copresent_npc() { + let (mut world, mut schedule) = setup_activation_world(); + let tick = ACTIVATION_CADENCE_TICKS; + world.resource_mut::().tick = tick; + + // Register NPC entity in EntityRegistry to get a StableId + let npc_entity = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(5, 5, 0))).id(); + let stable_id = world.resource_mut::().register(npc_entity); + + // Spawn a Simmering triangle with the NPC in a role + let mut tri = make_triangle(TriangleClassification::ActiveFork); + tri.role_assignments.insert(RoleId::new("a"), stable_id); + let tri_entity = world.spawn((tri, ActiveSim)).id(); + + // Put the player nearby in the history buffer + world.resource_mut::().append(TilePosition::new(5, 5, 0)); + + schedule.run(&mut world); + + let events = world.resource_mut::().drain(); + assert_eq!(events.len(), 1); + let tri_state = world.get::(tri_entity).unwrap(); + assert_eq!(tri_state.phase, TrianglePhase::Active, "triangle containing copresent NPC should be Active"); + } + + #[test] + fn activation_pass_does_not_fire_twice() { + // Verify the one-shot guard: a real first activation must happen, then confirm + // the second pass is blocked by ActivationState (not vacuously by empty candidates). + let (mut world, mut schedule) = setup_activation_world(); + + // Register an NPC and assign it to a triangle role so candidates are non-empty + let npc = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(1, 1, 0))).id(); + let stable_id = world.resource_mut::().register(npc); + + let mut tri = make_triangle(TriangleClassification::ActiveFork); + tri.role_assignments.insert(RoleId::new("a"), stable_id); + world.spawn((tri, ActiveSim)); + + // Put the player nearby so the NPC is copresent + world.resource_mut::().append(TilePosition::new(1, 1, 0)); + + // First run — should activate + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; + schedule.run(&mut world); + let first_events = world.resource_mut::().drain(); + assert_eq!(first_events.len(), 1, "first pass must activate the triangle"); + assert_eq!( + world.resource::().activated_count, 1, + "ActivationState must record the first activation" + ); + + // Second run — ActivationState blocks it (v0.1 one-shot) + world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS * 2; + schedule.run(&mut world); + assert!( + world.resource::().is_empty(), + "activation must not fire a second time (v0.1 one-shot)" + ); + } + + #[test] + fn compute_engagement_score_formula() { + let record = EngagementRecord { + observation_time_ticks: 200, + conversation_count: 3, + monologue_trigger_count: 1, + }; + let score = compute_engagement_score(&record); + // 3 * 2.0 + 200 * 0.005 + 1 * 8.0 = 6.0 + 1.0 + 8.0 = 15.0 + assert!((score - 15.0_f32).abs() < 0.001, "score={score}"); + } + + #[test] + fn compute_engagement_score_caps_conversations() { + let record = EngagementRecord { + observation_time_ticks: 0, + conversation_count: 100, // way above CONVERSATION_CAP = 5 + monologue_trigger_count: 0, + }; + let score = compute_engagement_score(&record); + // capped at 5 * 2.0 = 10.0 + assert!((score - 10.0_f32).abs() < 0.001, "score={score}"); + } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 55275814c..ce61862c2 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -74,6 +74,7 @@ fn snapshot_roundtrip_over_unix_socket() { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 75ad29a3d..a07fe204b 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_tcp() { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index fe9d01635..c43dbaeef 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -420,6 +420,8 @@ fn minimal_save() -> SaveStateV1 { open_doors: vec![], modifications: vec![], contamination_active: false, + activated_count: 0, + last_activation_tick: None, } } diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index 78ec5bc07..a6ec3f409 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -295,6 +295,7 @@ fn snapshot_with_sim_errors_roundtrips() { save_result: None, triangle_crisis_events: vec![], state_hash: Some(0xDEADBEEF), + debug_response: None, sim_errors: vec![ SimError { kind: SimErrorKind::ProtocolError, diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3582b4398..765409f1d 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -50,6 +50,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], } } @@ -244,6 +245,7 @@ fn generate_msgpack_fixtures() { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; write_fixture( @@ -408,6 +410,7 @@ fn generate_msgpack_fixtures() { }), triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; write_fixture( diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 8d0803fd8..a11e8c138 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -40,38 +40,255 @@ "state_hash": 14452262397297540338, "tick": 8, "triangle_crisis_events": [], - "version": 17, + "version": 18, "visible_tiles": [ { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -3, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -2, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": -1, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": -1, "y": -1, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": -1, "y": 0, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": -1, "y": 1, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": -1, "y": 2, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 0, "y": -1, @@ -106,7 +323,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 1, "y": -1, @@ -141,7 +400,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 2, "y": -1, @@ -183,7 +484,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 3, "y": -1, @@ -225,7 +568,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 4, "y": -1, @@ -274,7 +659,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 5, "y": -1, @@ -330,7 +757,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 6, "y": -1, @@ -386,7 +855,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 7, "y": -1, @@ -449,7 +960,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 8, "y": -1, @@ -512,7 +1065,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 9, "y": -1, @@ -582,7 +1177,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 10, "y": -1, @@ -652,7 +1289,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 11, "y": -1, @@ -729,7 +1408,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 12, "y": -1, @@ -813,7 +1534,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 13, "y": -1, @@ -897,7 +1660,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 14, "y": -1, @@ -988,7 +1793,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 15, "y": -1, @@ -1079,7 +1926,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 16, "y": -1, @@ -1177,7 +2066,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 17, "y": -1, @@ -1282,7 +2213,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 18, "y": -1, @@ -1380,7 +2353,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 19, "y": -1, @@ -1471,7 +2486,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 20, "y": -1, @@ -1562,7 +2619,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 21, "y": -1, @@ -1646,7 +2745,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 22, "y": -1, @@ -1730,7 +2871,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 23, "y": -1, @@ -1807,7 +2990,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 24, "y": -1, @@ -1877,7 +3102,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 25, "y": -1, @@ -1947,7 +3214,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 26, "y": -1, @@ -2010,7 +3319,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 27, "y": -1, @@ -2073,7 +3424,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 28, "y": -1, @@ -2129,7 +3522,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 29, "y": -1, @@ -2185,7 +3620,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 30, "y": -1, @@ -2234,7 +3711,49 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 31, "y": -1, @@ -2276,46 +3795,445 @@ "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 32, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": -1, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": 0, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": 1, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": 2, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": 3, "z": 0 }, { - "tile_kind": "Wall", + "tile_kind": "Floor", "visibility": "Forward", "x": 32, "y": 4, "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 33, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 34, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 35, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 36, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 37, + "y": 1, + "z": 0 } ] } diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 8bee8cc75..a2c7da566 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -208,6 +208,8 @@ fn save_state_npc_kg_isolation() { open_doors: vec![], modifications: vec![], contamination_active: false, + activated_count: 0, + last_activation_tick: None, }; // Roundtrip: serialize → deserialize. diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 959f93d57..5ac7d2c57 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -38,6 +38,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], } } @@ -299,6 +300,7 @@ fn snapshot_v2_fields_roundtrip() { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; @@ -354,7 +356,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 17, + PROTOCOL_VERSION, 18, "bump this assertion when protocol version changes" ); } @@ -406,6 +408,7 @@ fn all_facing_direction_variants_roundtrip() { save_result: None, triangle_crisis_events: vec![], state_hash: None, + debug_response: None, sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");