diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index a16ed0cad..581534bf6 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -38,7 +38,7 @@ pub const PROTOCOL_VERSION: u8 = 14; /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 13. + /// Protocol version for forward compatibility. Current: 14. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index a6e202636..b23e056b4 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // TODO: CombatCapability — no content schema type exists yet. When combat content // is authored, add weapon_proficiency + combat_style mapping here. + // Vision + awareness components (#115, #244) — must match generate_npc(). + // Without these, vision/awareness systems silently skip content-spawned NPCs. + entity_commands.insert(( + npc::vision::NpcVisionState::default(), + npc::vision::NpcMemory::default(), + npc::awareness::PlayerAwareness::default(), + )); + let entity = entity_commands.id(); // Register in EntityRegistry for StableId mapping diff --git a/server/src/npc/generate.rs b/server/src/npc/generate.rs index b1616d535..3fa55a1f8 100644 --- a/server/src/npc/generate.rs +++ b/server/src/npc/generate.rs @@ -269,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) -> DailyRoutine { entries, - description: format!("Routine schedule"), + description: "Routine schedule".to_string(), } } diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index bf9db6196..0c6a063dc 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -141,7 +141,8 @@ impl RelationshipGraph { } /// Get all entities who have feelings about a target. - /// O(N) full scan of all edges — use for event detection, not per-tick queries. + /// O(N) full scan of all edges. Called once per game-minute (every 10 ticks) + /// by the pressure system — acceptable at v0.1 NPC counts. pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .iter() @@ -292,8 +293,7 @@ impl DelayedTrustQueue { /// (D-010: integer-only determinism). Returns 0 when the scaled value would /// round to zero — small deltas naturally attenuate to nothing. /// -/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 15%... using 2/10=20% -/// as closest deterministic integer approximation of 15%). +/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%). /// /// Rounding: away from zero (ceiling of abs value, preserving sign). fn scale_delta(delta: i8, factor_tenths: i8) -> i8 { diff --git a/server/src/npc/vision.rs b/server/src/npc/vision.rs index 79dfcd912..a095c5ab4 100644 --- a/server/src/npc/vision.rs +++ b/server/src/npc/vision.rs @@ -30,7 +30,7 @@ use crate::perception::shadowcast::compute_fov; use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex}; -use crate::simulation::time::SimulationTime; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; use crate::simulation::tier::ActiveSim; /// NPC vision range in tiles (matches player forward range from VisionConeConfig). @@ -140,11 +140,10 @@ pub fn compute_npc_vision( let mut new_visible = BTreeSet::new(); let mut player_vis = false; - // Query entities within vision range, then filter by FOV tile set - let nearby = spatial_index.entities_in_range(npc_pos, NPC_VISION_RANGE as u32); - let at_origin = spatial_index.entities_at(npc_pos); + // Single-pass query: all entities within vision range including own tile + let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32); - for entity in nearby.into_iter().chain(at_origin.into_iter()) { + for entity in candidates { if entity == npc_entity { continue; } @@ -262,7 +261,7 @@ pub fn degrade_npc_inferences( time: Res, mut npc_query: Query<&mut NpcMemory, (With, With)>, ) { - if time.tick % 10 != 0 { + if time.tick % TICKS_PER_GAME_MINUTE != 0 { return; } @@ -494,6 +493,38 @@ mod tests { ); } + #[test] + fn npc_sees_non_npc_entity_on_same_tile() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + // NPC at (16, 16) + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + // Non-NPC entity on the same tile (e.g. dropped item) + let item = world.spawn(pos(16, 16)).id(); + let item_sid = registry.register(item); + spatial.update(item, pos(16, 16)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::(npc).unwrap(); + assert!( + vision.visible_entities.contains(&item_sid), + "NPC should see non-NPC entity sharing its tile" + ); + } + #[test] fn background_npc_not_processed() { let mut world = setup_world(32, 32); diff --git a/server/src/simulation/pressure.rs b/server/src/simulation/pressure.rs index b600d92c4..2264aca21 100644 --- a/server/src/simulation/pressure.rs +++ b/server/src/simulation/pressure.rs @@ -82,9 +82,12 @@ pub struct CharacterPressure { } impl CharacterPressure { - /// Total pressure as a weighted average of all axes (0–100). + /// Total pressure as a simple average of all axes (0–100). + /// + /// Uses integer division — remainders are floor-truncated (D-010, no floats). + /// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67). pub fn total(&self) -> i32 { - // Simple average, clamped. Integer arithmetic only (D-010). + // Simple average, clamped. Integer division truncates toward zero (D-010). ((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100) } @@ -178,6 +181,8 @@ pub fn update_character_pressure( // --- Relationship pressure --- let player_stable = registry.to_stable(player_entity); if let Some(player_sid) = player_stable { + // O(N) over all relationship edges — called once per game-minute, not every tick. + // Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations). let hostile_edges = relationship_graph .who_knows_full_scan(&player_sid) .iter() diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 8762bb20f..672cd0681 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -31,6 +31,8 @@ //! - Full ECS world extraction/injection (system not yet written) //! - Pathfinding state (reconstructed from position + routine) //! - Tier transitions in-flight (dropped to background state on load) +//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after +//! reload; memory degrades naturally over time so reset-on-load is acceptable) use serde::{Deserialize, Serialize}; diff --git a/server/src/simulation/spatial.rs b/server/src/simulation/spatial.rs index 3bde13cbb..678285604 100644 --- a/server/src/simulation/spatial.rs +++ b/server/src/simulation/spatial.rs @@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync { /// Return all entities at the exact `position`. fn entities_at(&self, position: &TilePosition) -> Vec; + /// Return all entities within Manhattan distance `radius` of `position`, + /// **including** entities exactly at `position`. Single-pass alternative to + /// `entities_in_range` + `entities_at`. + fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec { + let mut result = self.entities_in_range(position, radius); + result.extend(self.entities_at(position)); + result + } + /// Insert or update an entity's position in the index. fn update(&mut self, entity: Entity, position: TilePosition); @@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex { .collect() } + fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec { + self.entries + .iter() + .filter(|(_, pos)| { + pos == position + || pos + .manhattan_distance(position) + .is_some_and(|d| d <= radius) + }) + .map(|(entity, _)| *entity) + .collect() + } + fn update(&mut self, entity: Entity, position: TilePosition) { if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) { entry.1 = position;