diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index efbe628b8..18b7bee8f 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -15,7 +15,7 @@ use crate::content::template::TriangleState; use crate::knowledge::EntityRegistry; use crate::npc::Npc; use crate::simulation::conversation::NpcName; -use crate::simulation::movement::{PlayerCharacter, TilePosition}; +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}; @@ -63,6 +63,7 @@ pub fn handle_debug_commands( 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>, @@ -113,13 +114,25 @@ pub fn handle_debug_commands( ), 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!( - "Skipped to contamination delay: tick {} -> {}. Contamination will fire on next system run.", + "Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.", old_tick, time.tick ), success: true, @@ -128,7 +141,21 @@ pub fn handle_debug_commands( } } DebugCommandKind::TeleportToPosition { x, y, z } => { - if let Ok((_, mut pos)) = player_query.single_mut() { + 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; @@ -297,6 +324,12 @@ pub fn handle_debug_commands( }; 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); } } @@ -420,4 +453,53 @@ mod tests { 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/types.rs b/server/src/bridge/types.rs index 2d1a14068..4d86e2076 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, @@ -550,13 +551,13 @@ pub struct DebugResponsePayload { /// Whether the debug console is enabled (#580). /// /// Set at server startup. Cannot be toggled mid-session via IPC. -/// v0.1: defaults to true. Production builds will default to false. +/// 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(true) + Self(cfg!(debug_assertions)) } } diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index ae63ad704..0ca061bd4 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -377,29 +377,43 @@ fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) - 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::warn!( - "Location '{}': tile row count {} != expected height {} (from tile_bounds)", + 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::warn!( - "Location '{}' row {}: length {} != 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() { diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 825a9ba4d..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, 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 b8dbbd56f..f135d415d 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -382,7 +382,8 @@ impl Plugin for StorytellerPlugin { Update, activation_pass .after(append_player_history) - .after(tick_contamination_activation), + .after(tick_contamination_activation) + .before(crate::simulation::time::advance_tick), ); tracing::debug!("StorytellerPlugin initialized"); @@ -448,7 +449,9 @@ pub fn tick_contamination_activation( fn compute_engagement_score(record: &EngagementRecord) -> f32 { let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32 * ENGAGEMENT_WEIGHT_CONVERSATION; - let obs_score = record.observation_time_ticks as f32 * ENGAGEMENT_WEIGHT_OBSERVATION; + // 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 } @@ -462,7 +465,8 @@ fn compute_engagement_score(record: &EngagementRecord) -> f32 { /// 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. -/// Fallback (unentangled NPC, D-025): highest-tension Simmering triangle. +/// 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( @@ -484,15 +488,22 @@ pub fn activation_pass( if !activation_state.can_activate(time.tick) { return; } - // Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS - if time.tick % ACTIVATION_CADENCE_TICKS != 0 { + // 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 copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD); + 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 @@ -553,12 +564,21 @@ pub fn activation_pass( }; let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx]; - // Retrieve triangle_id for the event - let triangle_id = simmering + // 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) - .expect("target triangle must be in simmering snapshot"); + 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) { 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/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.