feat(server): Sprint 3 Know — NPC model, pathfinding, routines, observation events #14

Merged
jpmschweitzer merged 10 commits from server into main 2026-02-12 18:20:40 +01:00
20 changed files with 2093 additions and 83 deletions
+13
View File
@@ -7,9 +7,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Structured NPC data model (#86) — replaced stub string/f32 fields with typed enums and integer types for D-010 determinism (WantKind, SecretSeverity, Skill, PersonalityTrait, CombatStyle, RelationshipKind)
- Global RelationshipGraph resource (#87) — BTreeMap with tuple key for efficient prefix queries and reverse lookups
- A* pathfinding system (#237) — PathRequest/ComputedPath/PathBlocked components with cardinal-neighbor A* and manhattan heuristic
- NPC path following system (#238) — MovementSpeed throttling, per-tick path advancement with MoveIntent creation
- Daily routine system (#88) — NpcPlugin with PreviousDayPhase resource and check_phase_transition system issuing PathRequests at day-phase boundaries
- Multiple NPC spawning (#84) — 3 distinct NPCs (dock worker, field tech, guard) with full component bundles and RelationshipGraph edges
- Observation event generator (#239) — RoutineDeviation, Absence, and NewEntity triggers from comparing visible snapshot against NPC routines and knowledge state
- Review-pr skill routes reviewers by branch type — server/client get Hoshe+Tyre, copy gets Hoshe+Paula+Miri, visual gets Hoshe+Araminta, audio gets Hoshe+Ozzie
- Start-sprint skill spawns team agents from sprint briefings — parses the Agents line, creates a team with tasks from tickets, and launches all listed agents as background teammates
### Fixed
- IPC error handling (#341) — DeserializationWithDump/MutexPoisoned error variants, hex dump logging on deserialization failure, graceful error classification in bridge I/O systems
- NpcPlugin system ordering — routine phase transitions now run before pathfinding so PathRequests are picked up same frame
- Stale doc comment in observation event generator — system runs before knowledge updates, not after
- Clippy warnings from Rust 1.93 — derive Default, is_multiple_of, collapsible if
### Changed
- Sprint 3 "Know" team briefings regenerated from database — all four files (server, client, joint, copy) now match actual sprint 3 ticket assignments
- Ticketing database moved to shared worktree location (`../settledreach.db`) — eliminates binary merge conflicts across branches
+42
View File
@@ -378,6 +378,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "deprecate-until"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704"
dependencies = [
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -595,6 +607,15 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "integer-sqrt"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770"
dependencies = [
"num-traits",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -701,6 +722,20 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pathfinding"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ac35caa284c08f3721fb33c2741b5f763decaf42d080c8a6a722154347017e"
dependencies = [
"deprecate-until",
"indexmap",
"integer-sqrt",
"num-traits",
"rustc-hash",
"thiserror",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -846,6 +881,12 @@ dependencies = [
"serde",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -904,6 +945,7 @@ dependencies = [
"bevy_app",
"bevy_ecs",
"bincode",
"pathfinding",
"rand",
"rand_chacha",
"rmp-serde",
+1
View File
@@ -11,6 +11,7 @@ rmp-serde = "1"
bincode = "1"
rand = "0.9"
rand_chacha = "0.9"
pathfinding = "4.11"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+29 -7
View File
@@ -83,7 +83,10 @@ impl SimBridge for LocalBridge {
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(snapshot)?;
let mut writer = self.writer.lock().expect("writer mutex poisoned");
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
@@ -91,14 +94,33 @@ impl SimBridge for LocalBridge {
}
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
let mut reader = self.reader.lock().expect("reader mutex poisoned");
let mut reader = self
.reader
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
match read_framed(reader.get_mut())? {
Some(payload) => {
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
Some(payload) => match rmp_serde::from_slice::<Vec<PlayerInput>>(&payload) {
Ok(inputs) => {
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
Err(e) => {
let dump_len = payload.len().min(256);
tracing::error!(
"deserialization failed: {}. Raw bytes ({} of {} total): {:02x?}",
e,
dump_len,
payload.len(),
&payload[..dump_len]
);
Err(BridgeError::DeserializationWithDump(format!(
"{} (payload {} bytes)",
e,
payload.len()
)))
}
},
None => Err(BridgeError::Disconnected),
}
}
+32 -2
View File
@@ -19,12 +19,16 @@ pub enum BridgeError {
Serialization(#[from] rmp_serde::encode::Error),
#[error("deserialization error: {0}")]
Deserialization(#[from] rmp_serde::decode::Error),
#[error("deserialization error (raw bytes logged): {0}")]
DeserializationWithDump(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("transport error: {0}")]
Transport(String),
#[error("client disconnected")]
Disconnected,
#[error("internal mutex poisoned: {0}")]
MutexPoisoned(String),
}
/// Abstracts transport layer (D-020)
@@ -133,13 +137,29 @@ pub fn receive_bridge_inputs(
tracing::info!("Client disconnected, shutting down");
running.0 = false;
}
Err(BridgeError::Io(ref e))
if e.kind() == std::io::ErrorKind::BrokenPipe
|| e.kind() == std::io::ErrorKind::ConnectionReset =>
{
tracing::info!("Pipe broken, shutting down cleanly");
running.0 = false;
}
Err(BridgeError::MutexPoisoned(ref msg)) => {
tracing::error!("Bridge mutex poisoned: {}. Shutting down.", msg);
running.0 = false;
}
Err(BridgeError::DeserializationWithDump(ref msg)) => {
// Recoverable: skip this frame's input, don't shut down
tracing::error!("Skipping malformed input frame: {}", msg);
}
Err(e) => {
tracing::error!("Bridge receive error: {}", e);
}
}
}
/// Send snapshot from buffer to bridge
/// Send snapshot from buffer to bridge.
/// Any send error is fatal — the client cannot proceed without snapshots.
pub fn send_bridge_snapshot(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<SnapshotBuffer>,
@@ -148,7 +168,17 @@ pub fn send_bridge_snapshot(
let Some(bridge) = bridge else { return };
if let Some(snapshot) = buffer.snapshot.take() {
if let Err(e) = bridge.send_snapshot(&snapshot) {
tracing::error!("Bridge send error: {}", e);
match &e {
BridgeError::Disconnected => {
tracing::info!("Client disconnected during send, shutting down");
}
BridgeError::MutexPoisoned(msg) => {
tracing::error!("Bridge mutex poisoned during send: {}", msg);
}
_ => {
tracing::error!("Bridge send error: {}", e);
}
}
running.0 = false;
}
}
+29 -7
View File
@@ -122,7 +122,10 @@ impl SimBridge for TcpBridge {
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(snapshot)?;
let mut writer = self.writer.lock().expect("writer mutex poisoned");
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
@@ -130,14 +133,33 @@ impl SimBridge for TcpBridge {
}
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
let mut reader = self.reader.lock().expect("reader mutex poisoned");
let mut reader = self
.reader
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
match read_framed(reader.get_mut())? {
Some(payload) => {
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
Some(payload) => match rmp_serde::from_slice::<Vec<PlayerInput>>(&payload) {
Ok(inputs) => {
tracing::trace!("received {} inputs", inputs.len());
Ok(inputs)
}
Err(e) => {
let dump_len = payload.len().min(256);
tracing::error!(
"deserialization failed: {}. Raw bytes ({} of {} total): {:02x?}",
e,
dump_len,
payload.len(),
&payload[..dump_len]
);
Err(BridgeError::DeserializationWithDump(format!(
"{} (payload {} bytes)",
e,
payload.len()
)))
}
},
None => Err(BridgeError::Disconnected),
}
}
+2 -7
View File
@@ -47,8 +47,9 @@ pub struct GameTime {
/// 8-directional facing direction, matching movement system.
/// Used for vision cone computation (D-015) and snapshot wire format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum FacingDirection {
#[default]
North,
Northeast,
East,
@@ -59,12 +60,6 @@ pub enum FacingDirection {
Northwest,
}
impl Default for FacingDirection {
fn default() -> Self {
FacingDirection::North
}
}
/// A tile visible to the observer with its visibility quality
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleTile {
+1 -1
View File
@@ -100,7 +100,7 @@ pub fn decay_knowledge(
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
// Decay runs every 10 ticks (1 game-minute per D-031)
if time.tick % 10 != 0 {
if !time.tick.is_multiple_of(10) {
return;
}
for mut kg in knowledge_query.iter_mut() {
+6 -21
View File
@@ -70,9 +70,10 @@ impl KnowledgeConfidence {
/// Temporal/logical state of a knowledge entry.
/// Orthogonal to confidence: a KnowsDetails entry can be Active or Contradicted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum KnowledgeState {
/// Currently believed true. Default state.
#[default]
Active,
/// Conflicting information exists. Both conflicting entries receive this state.
/// Triggers monologue event when set. THE FRIEND arc detector.
@@ -82,12 +83,6 @@ pub enum KnowledgeState {
Stale,
}
impl Default for KnowledgeState {
fn default() -> Self {
Self::Active
}
}
// --- Knowledge Source ---
/// How knowledge was acquired. Tracked per-entry for provenance.
@@ -119,9 +114,10 @@ pub enum SoundRange {
/// Relationship state drives D-033 entity color rendering.
/// Derived from knowledge + NPC relationship axes (D-024).
/// Client maps this to color palette defined in D-033.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum RelationshipState {
/// No prior knowledge. Teal #4a9ebb.
#[default]
Unknown,
/// Recognized, neutral-to-positive. Soft green #6bc9a6.
Known,
@@ -133,12 +129,6 @@ pub enum RelationshipState {
Hostile,
}
impl Default for RelationshipState {
fn default() -> Self {
Self::Unknown
}
}
// --- Entity Knowledge ---
/// What entity A knows about entity B.
@@ -205,9 +195,10 @@ impl Default for DecayThresholds {
/// How an entity appears in the observer snapshot.
/// Extends VisibleEntity for knowledge-based rendering.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum EntityVisibility {
/// Currently in line of sight.
#[default]
Visible,
/// Not in LOS but remembered from knowledge graph.
Remembered {
@@ -215,9 +206,3 @@ pub enum EntityVisibility {
age_ticks: u64,
},
}
impl Default for EntityVisibility {
fn default() -> Self {
Self::Visible
}
}
+154 -11
View File
@@ -6,10 +6,17 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::Npc;
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
use settled_reach_server::npc::{
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
Want, WantKind,
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
fn main() {
@@ -41,24 +48,160 @@ fn main() {
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Proof room: wall at (16,14) between player and NPC
// NPC at (16,13) hidden behind wall until player moves around it
// Proof room: wall at (16,14) between player and NPC 1
{
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
}
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
));
app.world_mut()
.spawn((Npc, TilePosition::new(16, 13, 0)));
let mut registry = EntityRegistry::new(0);
// Player at (16,16)
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(player);
// NPC 1: Dock worker at (16,13) — behind wall, full routine
let npc1 = app
.world_mut()
.spawn((
Npc,
TilePosition::new(16, 13, 0),
Want {
primary: WantKind::Wealth,
intensity: 6,
description: "Wants a bigger share of docking fees".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 13, 0),
activity: "Prep cargo bay".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 10, 0),
activity: "Unload freight".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 20, 0),
activity: "Drink at canteen".into(),
},
RoutineEntry {
phase: DayPhase::Night,
location: TilePosition::new(16, 13, 0),
activity: "Sleep in bunk".into(),
},
],
description: "Dock worker shift pattern".into(),
},
Contentment { level: 20 },
ToleranceThreshold {
current_stress: 30,
threshold: 70,
},
MovementSpeed::new(2),
))
.id();
let npc1_sid = registry.register(npc1);
// NPC 2: Field tech at (14,18) — visible to player, has routine
let npc2 = app
.world_mut()
.spawn((
Npc,
TilePosition::new(14, 18, 0),
Want {
primary: WantKind::Knowledge,
intensity: 8,
description: "Obsessed with pre-Collapse sensor arrays".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(14, 18, 0),
activity: "Calibrate instruments".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(22, 22, 0),
activity: "Field survey".into(),
},
],
description: "Field tech survey pattern".into(),
},
Contentment { level: 45 },
ToleranceThreshold {
current_stress: 10,
threshold: 60,
},
MovementSpeed::default(),
))
.id();
let npc2_sid = registry.register(npc2);
// NPC 3: Guard at (18,14) — stationary, no routine
let npc3 = app
.world_mut()
.spawn((
Npc,
TilePosition::new(18, 14, 0),
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Wants a quiet shift".into(),
},
Contentment { level: -5 },
ToleranceThreshold {
current_stress: 45,
threshold: 55,
},
))
.id();
let npc3_sid = registry.register(npc3);
// Populate RelationshipGraph with a few edges
{
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
// Dock worker and guard are colleagues with moderate trust
rel_graph.set_relationship(
npc1_sid,
npc3_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 3,
history: vec![],
last_interaction_tick: 0,
},
);
// Guard distrusts the field tech (rival for resources)
rel_graph.set_relationship(
npc3_sid,
npc2_sid,
RelationshipEdge {
kind: RelationshipKind::Rival,
trust: -4,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.insert_resource(registry);
tracing::info!("Simulation initialized, entering game loop");
+281 -18
View File
@@ -2,76 +2,339 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod relationships;
pub mod routine;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
use crate::simulation::movement::TilePosition;
use crate::simulation::time::DayPhase;
/// NPC plugin: initializes NPC-related resources and systems.
pub struct NpcPlugin;
impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<routine::PreviousDayPhase>()
.add_systems(
Update,
routine::check_phase_transition
.before(crate::simulation::pathfinding::compute_paths),
);
tracing::debug!("NpcPlugin initialized");
}
}
#[derive(Component, Debug)]
pub struct Npc;
// 7 essential axes (D-024)
// ---------------------------------------------------------------------------
// Axis 1: Want (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum WantKind {
Wealth,
Safety,
Knowledge,
Connection,
Power,
Freedom,
Justice,
Revenge,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Want {
pub primary: WantKind,
pub intensity: u8, // 1-10, integer for determinism (D-010)
pub description: String,
}
// ---------------------------------------------------------------------------
// Axis 2: Secret / vulnerability (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SecretSeverity {
Minor, // Social embarrassment
Moderate, // Career-threatening
Major, // Criminal / life-threatening
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Secret {
pub description: String,
pub severity: SecretSeverity,
pub known_by: Vec<StableId>,
}
// ---------------------------------------------------------------------------
// Axis 3: Relationships 1-3 (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RelationshipKind {
Colleague,
Friend,
Rival,
Romantic,
Family,
Superior,
Subordinate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipEvent {
pub tick: u64,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
pub target_id: StableId,
pub kind: RelationshipKind,
pub trust_level: i8, // -10..+10, integer for determinism (D-010)
pub history: Vec<RelationshipEvent>,
}
/// Per-NPC relationship slots. D-024: 3 key relationships for Active-tier.
pub const MAX_KEY_RELATIONSHIPS: usize = 3;
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Relationships {
pub entries: Vec<Relationship>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Relationship {
/// Wire-format entity ID of the relationship target (scales to 10K+ NPCs)
pub target_id: u64,
pub kind: String,
pub trust_level: f32,
}
// ---------------------------------------------------------------------------
// Axis 4: Tolerance threshold (D-024)
// ---------------------------------------------------------------------------
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct ToleranceThreshold {
pub current_stress: f32,
pub threshold: f32,
pub current_stress: i16, // 0-100, integer for determinism (D-010)
pub threshold: i16,
}
// ---------------------------------------------------------------------------
// Axis 5: Daily routine (D-024, D-031)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineEntry {
pub phase: DayPhase,
pub location: TilePosition,
pub activity: String,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct DailyRoutine {
pub entries: Vec<RoutineEntry>,
pub description: String,
}
impl DailyRoutine {
/// Get the routine entry for a given day phase.
pub fn entry_for_phase(&self, phase: DayPhase) -> Option<&RoutineEntry> {
self.entries.iter().find(|e| e.phase == phase)
}
/// Get the expected location for a given day phase.
pub fn expected_location(&self, phase: DayPhase) -> Option<TilePosition> {
self.entry_for_phase(phase).map(|e| e.location)
}
}
// ---------------------------------------------------------------------------
// Axis 6: Information inventory (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnownFact {
pub fact_id: FactId,
pub confidence: KnowledgeConfidence,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct InformationInventory {
pub known_facts: Vec<String>,
pub facts: Vec<KnownFact>,
}
// ---------------------------------------------------------------------------
// Axis 7: Contentment (D-024, Gore's thematic axis)
// ---------------------------------------------------------------------------
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct Contentment {
pub level: f32,
pub level: i16, // -100..+100, integer for determinism (D-010)
}
// ---------------------------------------------------------------------------
// Supporting axis 1: Personality traits (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PersonalityTrait {
Cautious,
Bold,
Honest,
Deceptive,
Compassionate,
Ruthless,
Curious,
Incurious,
Social,
Reclusive,
}
// 3 supporting axes (D-024)
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct PersonalityTraits {
pub traits: Vec<String>,
pub traits: Vec<PersonalityTrait>,
}
// ---------------------------------------------------------------------------
// Supporting axis 2: Tell system (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TellTrigger {
StressAboveThreshold,
NearSpecificEntity(StableId),
DuringActivity(String),
TimeOfDay(DayPhase),
Always,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tell {
pub trigger: TellTrigger,
pub behavior: String,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct TellSystem {
pub tells: Vec<String>,
pub tells: Vec<Tell>,
}
// ---------------------------------------------------------------------------
// Supporting axis 3: Skill set + combat component (D-024)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Skill {
Combat,
Intimidation,
Medical,
Observation,
Persuasion,
Piloting,
Stealth,
Technical,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct SkillSet {
pub skills: Vec<String>,
pub skills: BTreeMap<Skill, u8>, // Skill -> proficiency (1-10), BTreeMap for determinism
pub combat_trained: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CombatStyle {
Ranged,
Melee,
Evasive,
Defensive,
}
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct CombatCapability {
pub weapon_proficiency: f32,
pub combat_style: String,
pub weapon_proficiency: u8, // 1-10
pub combat_style: CombatStyle,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daily_routine_entry_for_phase() {
let routine = DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(5, 5, 0),
activity: "Work".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 10, 0),
activity: "Bar".into(),
},
],
description: "Test routine".into(),
};
assert_eq!(
routine.expected_location(DayPhase::Morning),
Some(TilePosition::new(5, 5, 0))
);
assert_eq!(
routine.expected_location(DayPhase::Evening),
Some(TilePosition::new(10, 10, 0))
);
assert_eq!(routine.expected_location(DayPhase::Afternoon), None);
assert_eq!(routine.expected_location(DayPhase::Night), None);
}
#[test]
fn skill_set_btreemap_deterministic() {
let mut skills1 = BTreeMap::new();
skills1.insert(Skill::Combat, 5);
skills1.insert(Skill::Stealth, 3);
skills1.insert(Skill::Persuasion, 7);
let mut skills2 = BTreeMap::new();
skills2.insert(Skill::Persuasion, 7);
skills2.insert(Skill::Combat, 5);
skills2.insert(Skill::Stealth, 3);
// Insertion order doesn't matter — iteration is deterministic
let keys1: Vec<_> = skills1.keys().collect();
let keys2: Vec<_> = skills2.keys().collect();
assert_eq!(keys1, keys2);
}
#[test]
fn relationship_max_entries() {
let rels = Relationships {
entries: vec![
Relationship {
target_id: StableId(1),
kind: RelationshipKind::Friend,
trust_level: 5,
history: vec![],
},
Relationship {
target_id: StableId(2),
kind: RelationshipKind::Colleague,
trust_level: 2,
history: vec![],
},
Relationship {
target_id: StableId(3),
kind: RelationshipKind::Rival,
trust_level: -3,
history: vec![],
},
],
};
assert_eq!(rels.entries.len(), MAX_KEY_RELATIONSHIPS);
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Global relationship graph resource (D-024).
//!
//! Tracks how entities feel about each other. Separate from KnowledgeGraph
//! (what entities know) — this is what entities feel.
//! BTreeMap with tuple key (subject, target) for deterministic iteration
//! and efficient prefix queries via range().
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::knowledge::types::StableId;
use super::{RelationshipEvent, RelationshipKind};
/// Edge in the relationship graph. Directed: A's feelings about B.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipEdge {
pub kind: RelationshipKind,
pub trust: i8, // -10..+10, integer for determinism (D-010)
pub history: Vec<RelationshipEvent>,
pub last_interaction_tick: u64,
}
/// Global relationship graph resource.
/// BTreeMap<(subject, target), edge> for deterministic iteration (D-010).
/// Directed graph: edge (A, B) represents how A feels about B.
///
/// TODO(v0.2): This is a global omniscient resource — all entities share one
/// graph. This violates information boundaries (D-009/D-010) because any
/// system can read any relationship. For multiplayer, this needs per-observer
/// projection so each entity only sees relationships they should know about.
/// Acceptable for v0.1 single-player where the server is authoritative.
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelationshipGraph {
edges: BTreeMap<(StableId, StableId), RelationshipEdge>,
}
impl RelationshipGraph {
pub fn new() -> Self {
Self {
edges: BTreeMap::new(),
}
}
/// Set or update a relationship edge.
pub fn set_relationship(
&mut self,
subject: StableId,
target: StableId,
edge: RelationshipEdge,
) {
self.edges.insert((subject, target), edge);
}
/// Get a relationship edge (how subject feels about target).
pub fn get_relationship(
&self,
subject: &StableId,
target: &StableId,
) -> Option<&RelationshipEdge> {
self.edges.get(&(*subject, *target))
}
/// Get all relationships for a subject (who the subject has feelings about).
/// Uses BTreeMap range query: all edges with matching subject are contiguous.
pub fn relationships_of(&self, subject: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.range((*subject, StableId(0))..=(*subject, StableId(u64::MAX)))
.map(|((_, target), edge)| (target, edge))
.collect()
}
/// Get all entities who have feelings about a target.
/// Full scan — use for event detection, not per-tick queries.
pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.iter()
.filter(|((_, t), _)| t == target)
.map(|((s, _), edge)| (s, edge))
.collect()
}
/// Update trust level for an existing relationship.
/// Clamps to -10..+10. Returns false if edge does not exist.
pub fn adjust_trust(&mut self, subject: &StableId, target: &StableId, delta: i8) -> bool {
if let Some(edge) = self.edges.get_mut(&(*subject, *target)) {
edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10);
true
} else {
false
}
}
/// Number of edges in the graph.
pub fn edge_count(&self) -> usize {
self.edges.len()
}
/// Whether the graph has any edges.
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_edge(kind: RelationshipKind, trust: i8) -> RelationshipEdge {
RelationshipEdge {
kind,
trust,
history: vec![],
last_interaction_tick: 0,
}
}
#[test]
fn set_and_get_relationship() {
let mut graph = RelationshipGraph::new();
let a = StableId(1);
let b = StableId(2);
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 5));
let edge = graph.get_relationship(&a, &b).unwrap();
assert_eq!(edge.kind, RelationshipKind::Friend);
assert_eq!(edge.trust, 5);
// Reverse direction is empty
assert!(graph.get_relationship(&b, &a).is_none());
}
#[test]
fn relationships_of_subject() {
let mut graph = RelationshipGraph::new();
let a = StableId(1);
graph.set_relationship(a, StableId(10), make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(a, StableId(20), make_edge(RelationshipKind::Colleague, 2));
graph.set_relationship(a, StableId(30), make_edge(RelationshipKind::Rival, -3));
// Different subject — should not appear
graph.set_relationship(StableId(2), StableId(10), make_edge(RelationshipKind::Family, 8));
let rels = graph.relationships_of(&a);
assert_eq!(rels.len(), 3);
// BTreeMap iteration order: sorted by target StableId
assert_eq!(*rels[0].0, StableId(10));
assert_eq!(*rels[1].0, StableId(20));
assert_eq!(*rels[2].0, StableId(30));
}
#[test]
fn who_knows_target() {
let mut graph = RelationshipGraph::new();
let target = StableId(10);
graph.set_relationship(StableId(1), target, make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(StableId(2), target, make_edge(RelationshipKind::Rival, -2));
graph.set_relationship(StableId(3), target, make_edge(RelationshipKind::Colleague, 0));
// Edge to different target — should not appear
graph.set_relationship(StableId(1), StableId(99), make_edge(RelationshipKind::Family, 8));
let knowers = graph.who_knows(&target);
assert_eq!(knowers.len(), 3);
}
#[test]
fn adjust_trust_clamps() {
let mut graph = RelationshipGraph::new();
let a = StableId(1);
let b = StableId(2);
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 8));
// Positive overflow clamps at +10
assert!(graph.adjust_trust(&a, &b, 5));
assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, 10);
// Negative underflow clamps at -10
assert!(graph.adjust_trust(&a, &b, -25));
assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, -10);
}
#[test]
fn adjust_trust_missing_edge_returns_false() {
let mut graph = RelationshipGraph::new();
assert!(!graph.adjust_trust(&StableId(1), &StableId(2), 1));
}
#[test]
fn empty_graph() {
let graph = RelationshipGraph::new();
assert!(graph.is_empty());
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn deterministic_iteration() {
let mut graph = RelationshipGraph::new();
// Insert in arbitrary order
graph.set_relationship(StableId(3), StableId(1), make_edge(RelationshipKind::Rival, -1));
graph.set_relationship(StableId(1), StableId(2), make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(StableId(2), StableId(3), make_edge(RelationshipKind::Colleague, 0));
// Iteration order should be deterministic (sorted by (subject, target))
let keys: Vec<_> = graph.edges.keys().collect();
assert_eq!(*keys[0], (StableId(1), StableId(2)));
assert_eq!(*keys[1], (StableId(2), StableId(3)));
assert_eq!(*keys[2], (StableId(3), StableId(1)));
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Daily routine system (#88).
//!
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
//! whose DailyRoutine has a location for the new phase.
use bevy_ecs::prelude::*;
use crate::npc::{DailyRoutine, Npc};
use crate::simulation::movement::TilePosition;
use crate::simulation::pathfinding::PathRequest;
use crate::simulation::time::{DayPhase, SimulationTime};
/// Resource tracking the previous day phase for transition detection.
#[derive(Resource, Debug, Clone)]
pub struct PreviousDayPhase {
pub phase: DayPhase,
pub day: u64,
}
impl Default for PreviousDayPhase {
fn default() -> Self {
Self {
phase: DayPhase::Morning,
day: 0,
}
}
}
/// System: detect day-phase transitions and issue PathRequests for NPC routines.
/// Runs after advance_tick so the current phase is up-to-date.
pub fn check_phase_transition(
time: Res<SimulationTime>,
mut previous: ResMut<PreviousDayPhase>,
mut commands: Commands,
npcs: Query<(Entity, &TilePosition, &DailyRoutine), With<Npc>>,
) {
let current_phase = time.day_phase();
let current_day = time.day();
if current_phase == previous.phase && current_day == previous.day {
return;
}
tracing::debug!(
"Day phase transition: {:?} -> {:?} (day {} -> {})",
previous.phase,
current_phase,
previous.day,
current_day
);
previous.phase = current_phase;
previous.day = current_day;
for (entity, current_pos, routine) in npcs.iter() {
if let Some(expected_location) = routine.expected_location(current_phase) {
if *current_pos != expected_location {
commands
.entity(entity)
.insert(PathRequest {
goal: expected_location,
});
tracing::trace!(
"Entity {:?}: routine path request to {:?} for {:?}",
entity,
expected_location,
current_phase
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::RoutineEntry;
use crate::simulation::time::{MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
fn setup_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<PreviousDayPhase>();
world
}
#[test]
fn phase_transition_generates_path_request() {
let mut world = setup_world();
let afternoon_loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
TilePosition::new(5, 5, 0), // Not at afternoon location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: afternoon_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
// Advance time to Afternoon boundary
world.resource_mut::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
let request = world.get::<PathRequest>(entity).unwrap();
assert_eq!(request.goal, afternoon_loc);
}
#[test]
fn no_transition_no_request() {
let mut world = setup_world();
let entity = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
// Time still at Morning (tick 0), same as PreviousDayPhase default
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
assert!(world.get::<PathRequest>(entity).is_none());
}
#[test]
fn npc_already_at_destination_no_request() {
let mut world = setup_world();
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
loc, // Already at afternoon location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
world.resource_mut::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
assert!(world.get::<PathRequest>(entity).is_none());
}
#[test]
fn npc_without_routine_entry_ignored() {
let mut world = setup_world();
let entity = world
.spawn((
Npc,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(5, 5, 0),
activity: "Sleep".into(),
}],
description: "Test".into(),
},
))
.id();
// Transition to Afternoon, but NPC only has Morning entry
world.resource_mut::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
assert!(world.get::<PathRequest>(entity).is_none());
}
#[test]
fn day_rollover_triggers_morning_routine() {
let mut world = setup_world();
// Start at Night
let night_tick = 1080 * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = night_tick;
world.resource_mut::<PreviousDayPhase>().phase = DayPhase::Night;
world.resource_mut::<PreviousDayPhase>().day = 0;
let morning_loc = TilePosition::new(3, 3, 0);
let entity = world
.spawn((
Npc,
TilePosition::new(20, 20, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Morning,
location: morning_loc,
activity: "Wake up".into(),
}],
description: "Test".into(),
},
))
.id();
// Advance to next day's Morning (day 1, tick 0 of new day)
world.resource_mut::<SimulationTime>().tick = 1440 * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
let request = world.get::<PathRequest>(entity).unwrap();
assert_eq!(request.goal, morning_loc);
}
}
+453
View File
@@ -0,0 +1,453 @@
//! Observation event generator (#239).
//!
//! Interprets what the observer sees (and doesn't see) against known NPC
//! routines and knowledge graph state. Produces high-level observation events
//! that drive monologue and investigation triggers.
use bevy_ecs::prelude::*;
use crate::bridge::types::*;
use crate::knowledge::types::StableId;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::{DailyRoutine, Npc};
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
/// What triggered an observation event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObservationTrigger {
/// NPC is visible but not at their expected routine location.
RoutineDeviation {
npc: StableId,
expected: TilePosition,
actual: TilePosition,
},
/// Known NPC's expected routine location is visible, but the NPC is not there.
Absence {
npc: StableId,
expected: TilePosition,
},
/// An entity visible in LOS that the observer has no prior knowledge of.
NewEntity {
entity: StableId,
location: TilePosition,
},
}
/// A single observation event produced by the interpretation system.
#[derive(Debug, Clone)]
pub struct ObservationEvent {
pub tick: u64,
pub trigger: ObservationTrigger,
pub observer: Entity,
}
/// Resource: queue of observation events for downstream systems (monologue, UI).
#[derive(Resource, Debug, Default)]
pub struct ObservationEventQueue {
events: Vec<ObservationEvent>,
}
impl ObservationEventQueue {
pub fn push(&mut self, event: ObservationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ObservationEvent> {
std::mem::take(&mut self.events)
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// System: interpret visible snapshot against known routines and knowledge.
///
/// Runs BEFORE knowledge events are processed so it can detect new entities
/// by comparing visible NPCs against the previous tick's knowledge state.
/// Produces observation events for: routine deviations, absences, new entities.
pub fn generate_observation_events(
time: Res<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Res<EntityRegistry>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
npc_query: Query<(&TilePosition, &DailyRoutine), With<Npc>>,
mut event_queue: ResMut<ObservationEventQueue>,
) {
let Some(snapshot) = &buffer.snapshot else {
return;
};
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
return;
};
let current_phase = time.day_phase();
// Build set of visible tile positions for absence checks
let visible_tile_set: std::collections::HashSet<(i32, i32, i32)> = snapshot
.visible_tiles
.iter()
.map(|t| (t.x, t.y, t.z))
.collect();
// Collect visible NPC entity bits for absence checks
let visible_npc_bits: std::collections::HashSet<u64> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.map(|e| e.entity_id)
.collect();
// --- Routine deviation + New entity detection ---
for visible in &snapshot.entities {
if matches!(visible.kind, EntityKind::Player) {
continue;
}
let entity = Entity::from_bits(visible.entity_id);
// Check if this is a new entity (not in observer's knowledge graph)
if let Some(stable_id) = registry.to_stable(entity) {
if !observer_kg.knows_entity(&stable_id) {
// Reconstruct tile position from render coords
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::NewEntity {
entity: stable_id,
location: tile_pos,
},
observer: observer_entity,
});
}
}
// Check routine deviation: visible NPC not at expected location
if let Ok((actual_pos, routine)) = npc_query.get(entity) {
if let Some(expected_pos) = routine.expected_location(current_phase) {
if *actual_pos != expected_pos {
if let Some(stable_id) = registry.to_stable(entity) {
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::RoutineDeviation {
npc: stable_id,
expected: expected_pos,
actual: *actual_pos,
},
observer: observer_entity,
});
}
}
}
}
}
// --- Absence detection ---
// For each known NPC not in visible set, check if their expected routine
// location IS in our visible tiles (meaning we can see the spot but
// the NPC isn't there).
for (stable_id, _knowledge) in observer_kg.known_entities_iter() {
let Some(entity) = registry.to_entity(stable_id) else {
continue;
};
// Skip if currently visible
if visible_npc_bits.contains(&entity.to_bits()) {
continue;
}
// Check if this NPC has a routine with an expected location
if let Ok((_pos, routine)) = npc_query.get(entity) {
if let Some(expected_pos) = routine.expected_location(current_phase) {
// If we can see the expected location but the NPC isn't there
if visible_tile_set.contains(&(expected_pos.x, expected_pos.y, expected_pos.z)) {
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::Absence {
npc: *stable_id,
expected: expected_pos,
},
observer: observer_entity,
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::RoutineEntry;
use crate::perception::observer::compute_observer_snapshot;
use crate::perception::vision_cone::Facing;
use crate::simulation::movement::WalkabilityMap;
use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
fn setup_world() -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(32, 32, 1));
world.init_resource::<SnapshotBuffer>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<ObservationEventQueue>();
world
}
/// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update.
/// Interpretation runs BEFORE knowledge updates so it can detect new entities
/// and compare against the PREVIOUS tick's knowledge state.
fn run_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
compute_observer_snapshot,
crate::perception::observation::emit_observation_events
.after(compute_observer_snapshot),
generate_observation_events
.after(crate::perception::observation::emit_observation_events),
crate::knowledge::events::process_knowledge_events
.after(generate_observation_events),
));
schedule.run(world);
}
#[test]
fn routine_deviation_detected() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
// Set time to Afternoon
world.resource_mut::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
))
.id();
registry.register(player);
// NPC at (16,14) but routine says they should be at (20,10) in Afternoon
let npc = world
.spawn((
Npc,
TilePosition::new(16, 14, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let npc_sid = registry.register(npc);
world.insert_resource(registry);
run_pipeline(&mut world);
let queue = world.resource::<ObservationEventQueue>();
let deviations: Vec<_> = queue
.events
.iter()
.filter(|e| {
matches!(
&e.trigger,
ObservationTrigger::RoutineDeviation { npc, .. } if *npc == npc_sid
)
})
.collect();
assert_eq!(deviations.len(), 1, "should detect routine deviation");
}
#[test]
fn no_deviation_at_correct_location() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
// Time at Morning (tick 0, default)
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
))
.id();
registry.register(player);
// NPC at (16,14) and routine says Morning at (16,14)
let npc = world
.spawn((
Npc,
TilePosition::new(16, 14, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 14, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
registry.register(npc);
world.insert_resource(registry);
run_pipeline(&mut world);
let queue = world.resource::<ObservationEventQueue>();
let deviations: Vec<_> = queue
.events
.iter()
.filter(|e| matches!(&e.trigger, ObservationTrigger::RoutineDeviation { .. }))
.collect();
assert!(
deviations.is_empty(),
"no deviation when NPC is at expected location"
);
}
#[test]
fn absence_when_location_visible() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
// NPC behind player (blind spot, not visible) but routine says
// Morning at (16,15) which IS in the player's forward view
let npc = world
.spawn((
Npc,
TilePosition::new(16, 30, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 15, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let npc_sid = registry.register(npc);
// Player knows about the NPC (has observed before)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 0);
kg.observe_entity_leaving_los(&npc_sid, 1);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
))
.id();
registry.register(player);
world.insert_resource(registry);
run_pipeline(&mut world);
let queue = world.resource::<ObservationEventQueue>();
let absences: Vec<_> = queue
.events
.iter()
.filter(|e| {
matches!(
&e.trigger,
ObservationTrigger::Absence { npc, .. } if *npc == npc_sid
)
})
.collect();
assert_eq!(absences.len(), 1, "should detect absence at visible location");
}
#[test]
fn new_entity_detected() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(), // Empty — never seen anyone
))
.id();
registry.register(player);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
world.insert_resource(registry);
run_pipeline(&mut world);
let queue = world.resource::<ObservationEventQueue>();
let new_entities: Vec<_> = queue
.events
.iter()
.filter(|e| {
matches!(
&e.trigger,
ObservationTrigger::NewEntity { entity, .. } if *entity == npc_sid
)
})
.collect();
assert_eq!(new_entities.len(), 1, "should detect new entity");
}
#[test]
fn known_entity_no_new_event() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((Npc, TilePosition::new(16, 14, 0)))
.id();
let npc_sid = registry.register(npc);
// Player already knows about the NPC
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
))
.id();
registry.register(player);
world.insert_resource(registry);
run_pipeline(&mut world);
let queue = world.resource::<ObservationEventQueue>();
let new_entities: Vec<_> = queue
.events
.iter()
.filter(|e| matches!(&e.trigger, ObservationTrigger::NewEntity { .. }))
.collect();
assert!(
new_entities.is_empty(),
"should not emit NewEntity for known entity"
);
}
}
+10 -2
View File
@@ -3,7 +3,9 @@
// Generates ObserverSnapshot for client rendering
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod interpretation;
pub mod observation;
pub mod observer;
pub mod shadowcast;
@@ -14,8 +16,14 @@ pub mod vision_cone;
pub struct PerceptionPlugin;
impl Plugin for PerceptionPlugin {
fn build(&self, _app: &mut App) {
// Stub implementation - will be populated in phase 2
fn build(&self, app: &mut App) {
app.init_resource::<interpretation::ObservationEventQueue>()
.add_systems(
Update,
interpretation::generate_observation_events
.after(observation::emit_observation_events)
.before(crate::knowledge::events::process_knowledge_events),
);
tracing::debug!("PerceptionPlugin initialized");
}
}
+67
View File
@@ -701,4 +701,71 @@ mod tests {
"entity without last_known_position should not appear as ghost"
);
}
#[test]
fn multiple_npcs_in_los_all_visible() {
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// Three NPCs in front of player, no walls
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Player + 3 NPCs = 4 entities
assert_eq!(snapshot.entities.len(), 4);
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 3);
assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible));
}
#[test]
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
let mut world = setup_world(32, 32);
// Wall at (16,14)
world
.resource_mut::<WalkabilityMap>()
.set_walkable(&TilePosition::new(16, 14, 0), false);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
));
// NPC 1: behind wall (should be hidden)
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
// NPC 2: to the side, no wall (should be visible)
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
// NPC 3: also visible
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_observer_snapshot);
schedule.run(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
let npcs: Vec<_> = snapshot
.entities
.iter()
.filter(|e| matches!(e.kind, EntityKind::Npc))
.collect();
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
}
}
+3 -5
View File
@@ -286,11 +286,9 @@ fn has_line_of_sight(
loop {
// Check if we hit a blocking tile BEFORE reaching target
if (x != x0 || y != y0) && (x != x1 || y != y1) {
if is_opaque(x, y) {
// Hit an obstacle before reaching target - blocked
return false;
}
if (x != x0 || y != y0) && (x != x1 || y != y1) && is_opaque(x, y) {
// Hit an obstacle before reaching target - blocked
return false;
}
// If we reach the target, we can see it
+7 -2
View File
@@ -6,6 +6,8 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
pub mod rng;
pub mod tier;
pub mod time;
@@ -24,8 +26,11 @@ impl Plugin for SimulationPlugin {
Update,
(
input::process_player_input,
movement::validate_movement.after(input::process_player_input),
time::advance_tick.after(movement::validate_movement),
pathfinding::compute_paths.after(input::process_player_input),
path_follow::follow_paths.after(pathfinding::compute_paths),
movement::validate_movement.after(path_follow::follow_paths),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+221
View File
@@ -0,0 +1,221 @@
//! NPC path following system (#238).
//!
//! Per-tick NPC position updates along computed paths.
//! Separate from pathfinding — this is the movement execution system.
use bevy_ecs::prelude::*;
use crate::npc::Npc;
use crate::simulation::movement::MoveIntent;
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
/// Movement speed component. Controls ticks between path steps.
/// Default: 1 step per tick. Higher values = slower movement.
#[derive(Component, Debug, Clone)]
pub struct MovementSpeed {
pub ticks_per_step: u32,
ticks_since_last_step: u32,
}
impl Default for MovementSpeed {
fn default() -> Self {
Self {
ticks_per_step: 1,
ticks_since_last_step: 0,
}
}
}
impl MovementSpeed {
pub fn new(ticks_per_step: u32) -> Self {
Self {
ticks_per_step: ticks_per_step.max(1),
ticks_since_last_step: 0,
}
}
/// Returns true if entity should step this tick.
fn should_step(&mut self) -> bool {
self.ticks_since_last_step += 1;
if self.ticks_since_last_step >= self.ticks_per_step {
self.ticks_since_last_step = 0;
true
} else {
false
}
}
}
/// System: NPC entities with ComputedPath advance along their path.
/// Creates MoveIntent for the next step. Removes ComputedPath when complete.
pub fn follow_paths(
mut commands: Commands,
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With<Npc>>,
) {
for (entity, mut path, speed_opt) in query.iter_mut() {
if let Some(mut speed) = speed_opt {
if !speed.should_step() {
continue;
}
}
if let Some(next_pos) = path.next_step() {
commands
.entity(entity)
.insert(MoveIntent { target: *next_pos });
path.advance();
}
if path.is_complete() {
commands.entity(entity).remove::<ComputedPath>();
tracing::trace!("Entity {:?}: path complete", entity);
}
}
}
/// System: clean up PathBlocked markers after one tick.
pub fn cleanup_path_blocked(mut commands: Commands, query: Query<Entity, With<PathBlocked>>) {
for entity in query.iter() {
commands.entity(entity).remove::<PathBlocked>();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
use crate::simulation::pathfinding::ComputedPath;
#[test]
fn npc_follows_path_one_step() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![
TilePosition::new(1, 0, 0),
TilePosition::new(2, 0, 0),
TilePosition::new(3, 0, 0),
],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// MoveIntent should target step 0
let intent = world.get::<MoveIntent>(entity).unwrap();
assert_eq!(intent.target, TilePosition::new(1, 0, 0));
// Path advanced to index 1
let path = world.get::<ComputedPath>(entity).unwrap();
assert_eq!(path.current_index, 1);
}
#[test]
fn npc_path_complete_removes_component() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(2, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(3, 0, 0)],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// After consuming the last step, ComputedPath should be removed
assert!(world.get::<ComputedPath>(entity).is_none());
// But MoveIntent was still created
assert!(world.get::<MoveIntent>(entity).is_some());
}
#[test]
fn movement_speed_throttles() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![
TilePosition::new(1, 0, 0),
TilePosition::new(2, 0, 0),
],
current_index: 0,
},
MovementSpeed::new(3),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
// Tick 1: no step (1/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_none());
// Tick 2: no step (2/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_none());
// Tick 3: step! (3/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_some());
assert_eq!(
world.get::<MoveIntent>(entity).unwrap().target,
TilePosition::new(1, 0, 0)
);
}
#[test]
fn non_npc_entity_ignored() {
let mut world = bevy_ecs::world::World::new();
// Entity without Npc marker
let entity = world
.spawn((
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(1, 0, 0)],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// Should NOT have MoveIntent since it's not an Npc
assert!(world.get::<MoveIntent>(entity).is_none());
// Path unchanged
assert_eq!(world.get::<ComputedPath>(entity).unwrap().current_index, 0);
}
#[test]
fn cleanup_path_blocked_removes_marker() {
let mut world = bevy_ecs::world::World::new();
let entity = world.spawn((Npc, PathBlocked)).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(cleanup_path_blocked);
schedule.run(&mut world);
assert!(world.get::<PathBlocked>(entity).is_none());
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Tile-based A* pathfinding (#237).
//!
//! Computes paths over the WalkabilityMap using the `pathfinding` crate.
//! NPCs request paths via PathRequest component; the compute_paths system
//! resolves them into ComputedPath (success) or PathBlocked (no route).
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::simulation::movement::{TilePosition, WalkabilityMap};
/// Component requesting a path from current position to a goal.
/// Consumed by the compute_paths system each tick.
#[derive(Component, Debug, Clone)]
pub struct PathRequest {
pub goal: TilePosition,
}
/// Component holding a computed path.
/// Steps run from start (exclusive) to goal (inclusive).
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct ComputedPath {
pub steps: Vec<TilePosition>,
pub current_index: usize,
}
impl ComputedPath {
/// Get the next step in the path, or None if finished.
pub fn next_step(&self) -> Option<&TilePosition> {
self.steps.get(self.current_index)
}
/// Advance to the next step. Returns true if there are more steps.
pub fn advance(&mut self) -> bool {
if self.current_index < self.steps.len() {
self.current_index += 1;
}
self.current_index < self.steps.len()
}
/// Whether the path has been fully traversed.
pub fn is_complete(&self) -> bool {
self.current_index >= self.steps.len()
}
/// Remaining steps count.
pub fn remaining(&self) -> usize {
self.steps.len().saturating_sub(self.current_index)
}
}
/// Marker component: pathfinding failed, no route exists.
#[derive(Component, Debug, Clone)]
pub struct PathBlocked;
/// System: compute paths for entities with PathRequest components.
/// Uses A* over the WalkabilityMap with cardinal movement (4 neighbors).
/// Cardinal-only is a deliberate v0.1 simplification: diagonal movement
/// would require √2 cost handling and diagonal wall-clipping checks.
/// Removes PathRequest and inserts ComputedPath or PathBlocked.
pub fn compute_paths(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
queries: Query<(Entity, &TilePosition, &PathRequest)>,
) {
let Some(walkability) = walkability else {
// No map loaded — consume requests and mark blocked
for (entity, _, _) in queries.iter() {
commands.entity(entity).remove::<PathRequest>();
commands.entity(entity).insert(PathBlocked);
}
return;
};
for (entity, current_pos, request) in queries.iter() {
commands.entity(entity).remove::<PathRequest>();
if *current_pos == request.goal {
commands.entity(entity).insert(ComputedPath {
steps: Vec::new(),
current_index: 0,
});
continue;
}
let goal = request.goal;
let result = pathfinding::directed::astar::astar(
current_pos,
|pos| {
pos.cardinal_neighbors()
.into_iter()
.filter(|neighbor| walkability.can_move_to(neighbor))
.map(|neighbor| (neighbor, 1u32))
},
// manhattan_distance returns None for cross-z-level pairs;
// u32::MAX makes A* deprioritize those nodes (v0.1: single z-level)
|pos| pos.manhattan_distance(&goal).unwrap_or(u32::MAX),
|pos| *pos == goal,
);
match result {
Some((path, _cost)) => {
// path includes start position; skip it
let steps: Vec<TilePosition> = path.into_iter().skip(1).collect();
tracing::trace!("Entity {:?}: path to {:?}, {} steps", entity, goal, steps.len());
commands.entity(entity).insert(ComputedPath {
steps,
current_index: 0,
});
}
None => {
tracing::trace!("Entity {:?}: no path to {:?}", entity, goal);
commands.entity(entity).insert(PathBlocked);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_to_adjacent_tile() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
PathRequest {
goal: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_paths);
schedule.run(&mut world);
assert!(world.get::<PathRequest>(entity).is_none());
let path = world.get::<ComputedPath>(entity).unwrap();
assert_eq!(path.steps, vec![TilePosition::new(5, 4, 0)]);
assert_eq!(path.current_index, 0);
}
#[test]
fn path_around_wall() {
let mut world = bevy_ecs::world::World::new();
let mut map = WalkabilityMap::new(10, 10, 1);
// Wall at (5,4) blocks direct north
map.set_walkable(&TilePosition::new(5, 4, 0), false);
world.insert_resource(map);
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
PathRequest {
goal: TilePosition::new(5, 3, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_paths);
schedule.run(&mut world);
let path = world.get::<ComputedPath>(entity).unwrap();
assert!(!path.steps.is_empty());
// Path should end at goal
assert_eq!(*path.steps.last().unwrap(), TilePosition::new(5, 3, 0));
// Path should not go through the wall
assert!(!path.steps.contains(&TilePosition::new(5, 4, 0)));
}
#[test]
fn path_to_same_position() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
PathRequest {
goal: TilePosition::new(5, 5, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_paths);
schedule.run(&mut world);
let path = world.get::<ComputedPath>(entity).unwrap();
assert!(path.steps.is_empty());
assert!(path.is_complete());
}
#[test]
fn path_blocked_no_route() {
let mut world = bevy_ecs::world::World::new();
let mut map = WalkabilityMap::new(10, 10, 1);
// Surround goal with walls
let goal = TilePosition::new(5, 3, 0);
for neighbor in goal.cardinal_neighbors() {
map.set_walkable(&neighbor, false);
}
world.insert_resource(map);
let entity = world
.spawn((
TilePosition::new(5, 5, 0),
PathRequest { goal },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_paths);
schedule.run(&mut world);
assert!(world.get::<PathRequest>(entity).is_none());
assert!(world.get::<ComputedPath>(entity).is_none());
assert!(world.get::<PathBlocked>(entity).is_some());
}
#[test]
fn computed_path_navigation() {
let mut path = ComputedPath {
steps: vec![
TilePosition::new(1, 0, 0),
TilePosition::new(2, 0, 0),
TilePosition::new(3, 0, 0),
],
current_index: 0,
};
assert_eq!(path.remaining(), 3);
assert!(!path.is_complete());
assert_eq!(*path.next_step().unwrap(), TilePosition::new(1, 0, 0));
assert!(path.advance()); // -> index 1
assert_eq!(*path.next_step().unwrap(), TilePosition::new(2, 0, 0));
assert!(path.advance()); // -> index 2
assert_eq!(*path.next_step().unwrap(), TilePosition::new(3, 0, 0));
assert!(!path.advance()); // -> index 3, no more steps
assert!(path.is_complete());
assert!(path.next_step().is_none());
assert_eq!(path.remaining(), 0);
}
#[test]
fn path_deterministic() {
let map = {
let mut m = WalkabilityMap::new(20, 20, 1);
// Add some walls to make routing interesting
for y in 3..8 {
m.set_walkable(&TilePosition::new(5, y, 0), false);
}
m
};
// Run pathfinding twice with same setup
let mut results = Vec::new();
for _ in 0..2 {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(map.clone());
world.spawn((
TilePosition::new(4, 5, 0),
PathRequest {
goal: TilePosition::new(6, 5, 0),
},
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_paths);
schedule.run(&mut world);
let mut paths: Vec<_> = world
.query::<&ComputedPath>()
.iter(&world)
.map(|p| p.steps.clone())
.collect();
results.push(paths.pop().unwrap());
}
assert_eq!(results[0], results[1], "pathfinding must be deterministic");
}
}