fix(simulation): PR #66 review — dedup vision, spawn components, doc fixes
- vision.rs: deduplicate own-tile entity iteration, add same-tile test - spawn.rs: add NpcVisionState, NpcMemory, PlayerAwareness to content- spawned NPCs (matching generate_npc) - pressure.rs: update who_knows_full_scan doc to reflect actual call frequency, document O(N) acceptability at call site - types.rs: fix stale protocol version comment (13→14) - generate.rs: format!() → .to_string() (clippy) - vision.rs: hardcoded 10 → TICKS_PER_GAME_MINUTE - relationships.rs: fix comment "15%" → "20%" to match code - pressure.rs: document total() floor-truncation - save_state.rs: document NpcMemory exclusion as intentional Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ pub const PROTOCOL_VERSION: u8 = 14;
|
|||||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObserverSnapshot {
|
pub struct ObserverSnapshot {
|
||||||
/// Protocol version for forward compatibility. Current: 13.
|
/// Protocol version for forward compatibility. Current: 14.
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
/// Simulation tick when this snapshot was produced
|
/// Simulation tick when this snapshot was produced
|
||||||
pub tick: u64,
|
pub tick: u64,
|
||||||
|
|||||||
@@ -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
|
// TODO: CombatCapability — no content schema type exists yet. When combat content
|
||||||
// is authored, add weapon_proficiency + combat_style mapping here.
|
// 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();
|
let entity = entity_commands.id();
|
||||||
|
|
||||||
// Register in EntityRegistry for StableId mapping
|
// Register in EntityRegistry for StableId mapping
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
|
|||||||
|
|
||||||
DailyRoutine {
|
DailyRoutine {
|
||||||
entries,
|
entries,
|
||||||
description: format!("Routine schedule"),
|
description: "Routine schedule".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ impl RelationshipGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all entities who have feelings about a target.
|
/// 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)> {
|
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||||
self.edges
|
self.edges
|
||||||
.iter()
|
.iter()
|
||||||
@@ -292,8 +293,7 @@ impl DelayedTrustQueue {
|
|||||||
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
|
||||||
/// round to zero — small deltas naturally attenuate to nothing.
|
/// round to zero — small deltas naturally attenuate to nothing.
|
||||||
///
|
///
|
||||||
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 15%... using 2/10=20%
|
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
|
||||||
/// as closest deterministic integer approximation of 15%).
|
|
||||||
///
|
///
|
||||||
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
/// Rounding: away from zero (ceiling of abs value, preserving sign).
|
||||||
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use crate::perception::shadowcast::compute_fov;
|
|||||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||||
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
|
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;
|
use crate::simulation::tier::ActiveSim;
|
||||||
|
|
||||||
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
|
/// 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 new_visible = BTreeSet::new();
|
||||||
let mut player_vis = false;
|
let mut player_vis = false;
|
||||||
|
|
||||||
// Query entities within vision range, then filter by FOV tile set
|
// Single-pass query: all entities within vision range including own tile
|
||||||
let nearby = spatial_index.entities_in_range(npc_pos, NPC_VISION_RANGE as u32);
|
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
|
||||||
let at_origin = spatial_index.entities_at(npc_pos);
|
|
||||||
|
|
||||||
for entity in nearby.into_iter().chain(at_origin.into_iter()) {
|
for entity in candidates {
|
||||||
if entity == npc_entity {
|
if entity == npc_entity {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -262,7 +261,7 @@ pub fn degrade_npc_inferences(
|
|||||||
time: Res<SimulationTime>,
|
time: Res<SimulationTime>,
|
||||||
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
|
||||||
) {
|
) {
|
||||||
if time.tick % 10 != 0 {
|
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
|
||||||
return;
|
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::<NpcVisionState>(npc).unwrap();
|
||||||
|
assert!(
|
||||||
|
vision.visible_entities.contains(&item_sid),
|
||||||
|
"NPC should see non-NPC entity sharing its tile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn background_npc_not_processed() {
|
fn background_npc_not_processed() {
|
||||||
let mut world = setup_world(32, 32);
|
let mut world = setup_world(32, 32);
|
||||||
|
|||||||
@@ -82,9 +82,12 @@ pub struct CharacterPressure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
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)
|
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +181,8 @@ pub fn update_character_pressure(
|
|||||||
// --- Relationship pressure ---
|
// --- Relationship pressure ---
|
||||||
let player_stable = registry.to_stable(player_entity);
|
let player_stable = registry.to_stable(player_entity);
|
||||||
if let Some(player_sid) = player_stable {
|
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
|
let hostile_edges = relationship_graph
|
||||||
.who_knows_full_scan(&player_sid)
|
.who_knows_full_scan(&player_sid)
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
//! - Full ECS world extraction/injection (system not yet written)
|
//! - Full ECS world extraction/injection (system not yet written)
|
||||||
//! - Pathfinding state (reconstructed from position + routine)
|
//! - Pathfinding state (reconstructed from position + routine)
|
||||||
//! - Tier transitions in-flight (dropped to background state on load)
|
//! - 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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync {
|
|||||||
/// Return all entities at the exact `position`.
|
/// Return all entities at the exact `position`.
|
||||||
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
|
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
|
||||||
|
|
||||||
|
/// 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<Entity> {
|
||||||
|
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.
|
/// Insert or update an entity's position in the index.
|
||||||
fn update(&mut self, entity: Entity, position: TilePosition);
|
fn update(&mut self, entity: Entity, position: TilePosition);
|
||||||
|
|
||||||
@@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
|
||||||
|
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) {
|
fn update(&mut self, entity: Entity, position: TilePosition) {
|
||||||
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
|
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
|
||||||
entry.1 = position;
|
entry.1 = position;
|
||||||
|
|||||||
Reference in New Issue
Block a user