From 3c04a0c5685d51c5189377a703321691b836d886 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:51:15 +0100 Subject: [PATCH 01/14] feat(npc): add structured NPC data model, relationships, and daily routines Implements tickets #86, #87, #88 for Sprint 3: - Replace stub string/f32 NPC fields with typed enums and integer types for D-010 determinism (WantKind, SecretSeverity, Skill, etc.) - Add RelationshipGraph global resource with BTreeMap<(StableId, StableId), RelationshipEdge> for efficient prefix queries - Add DailyRoutine with phase-based RoutineEntry and PreviousDayPhase resource for detecting day-phase transitions - Create NpcPlugin that initializes relationship graph, day-phase tracking, and registers check_phase_transition system Co-Authored-By: Claude Opus 4.6 --- server/src/npc/mod.rs | 299 ++++++++++++++++++++++++++++++-- server/src/npc/relationships.rs | 206 ++++++++++++++++++++++ server/src/npc/routine.rs | 242 ++++++++++++++++++++++++++ 3 files changed, 729 insertions(+), 18 deletions(-) create mode 100644 server/src/npc/relationships.rs create mode 100644 server/src/npc/routine.rs diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5498ffae0..27c3f532e 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -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::() + .init_resource::() + .add_systems( + Update, + routine::check_phase_transition + .after(crate::simulation::time::advance_tick), + ); + + 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, } +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, } -#[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, 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 { + 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, + pub facts: Vec, } +// --------------------------------------------------------------------------- +// 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, + pub traits: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + pub tells: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + pub skills: BTreeMap, // 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); + } } diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs new file mode 100644 index 000000000..5be72258f --- /dev/null +++ b/server/src/npc/relationships.rs @@ -0,0 +1,206 @@ +//! 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, + 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. +#[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))); + } +} diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs new file mode 100644 index 000000000..ee157d967 --- /dev/null +++ b/server/src/npc/routine.rs @@ -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, + mut previous: ResMut, + mut commands: Commands, + npcs: Query<(Entity, &TilePosition, &DailyRoutine), With>, +) { + 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::(); + 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::().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::(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::(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::().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::(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::().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::(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::().tick = night_tick; + world.resource_mut::().phase = DayPhase::Night; + world.resource_mut::().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::().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::(entity).unwrap(); + assert_eq!(request.goal, morning_loc); + } +} From f8996241032c8bccc1f610733e4c1c64cd379bf5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:51:27 +0100 Subject: [PATCH 02/14] feat(simulation): add A* pathfinding and NPC path following MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tickets #237 and #238 for Sprint 3: - Add pathfinding crate dependency for A* algorithm - PathRequest component triggers compute_paths system which uses cardinal-neighbor A* with manhattan distance heuristic - ComputedPath component with step navigation (next_step, advance, is_complete) and PathBlocked marker for no-route cases - MovementSpeed component throttles NPC movement (ticks_per_step) - follow_paths system advances NPCs along computed paths, creating MoveIntent per step; cleanup_path_blocked removes markers after one tick - System ordering: input → compute_paths → follow_paths → validate_movement → cleanup_path_blocked → advance_tick Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 42 ++++ server/Cargo.toml | 1 + server/src/simulation/mod.rs | 9 +- server/src/simulation/path_follow.rs | 221 +++++++++++++++++++++ server/src/simulation/pathfinding.rs | 284 +++++++++++++++++++++++++++ 5 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 server/src/simulation/path_follow.rs create mode 100644 server/src/simulation/pathfinding.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 4bba5b658..f397f2e21 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -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", diff --git a/server/Cargo.toml b/server/Cargo.toml index 6afe63cbd..5bf983a03 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -11,6 +11,7 @@ rmp-serde = "1" bincode = "1" rand = "0.9" rand_chacha = "0.9" +pathfinding = "4" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index e82407744..937b3384e 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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), ), ); diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs new file mode 100644 index 000000000..2e0d6bd88 --- /dev/null +++ b/server/src/simulation/path_follow.rs @@ -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>, +) { + 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::(); + tracing::trace!("Entity {:?}: path complete", entity); + } + } +} + +/// System: clean up PathBlocked markers after one tick. +pub fn cleanup_path_blocked(mut commands: Commands, query: Query>) { + for entity in query.iter() { + commands.entity(entity).remove::(); + } +} + +#[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::(entity).unwrap(); + assert_eq!(intent.target, TilePosition::new(1, 0, 0)); + // Path advanced to index 1 + let path = world.get::(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::(entity).is_none()); + // But MoveIntent was still created + assert!(world.get::(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::(entity).is_none()); + + // Tick 2: no step (2/3) + schedule.run(&mut world); + assert!(world.get::(entity).is_none()); + + // Tick 3: step! (3/3) + schedule.run(&mut world); + assert!(world.get::(entity).is_some()); + assert_eq!( + world.get::(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::(entity).is_none()); + // Path unchanged + assert_eq!(world.get::(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::(entity).is_none()); + } +} diff --git a/server/src/simulation/pathfinding.rs b/server/src/simulation/pathfinding.rs new file mode 100644 index 000000000..b4e53d319 --- /dev/null +++ b/server/src/simulation/pathfinding.rs @@ -0,0 +1,284 @@ +//! 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, + 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). +/// Removes PathRequest and inserts ComputedPath or PathBlocked. +pub fn compute_paths( + mut commands: Commands, + walkability: Option>, + 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::(); + commands.entity(entity).insert(PathBlocked); + } + return; + }; + + for (entity, current_pos, request) in queries.iter() { + commands.entity(entity).remove::(); + + 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)) + }, + |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 = 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::(entity).is_none()); + let path = world.get::(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::(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::(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::(entity).is_none()); + assert!(world.get::(entity).is_none()); + assert!(world.get::(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"); + } +} From f186cec2641ab780b17c4ff54efa3cbd48ea56f3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:51:38 +0100 Subject: [PATCH 03/14] fix(bridge): add error variants and diagnostic logging for IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ticket #341 for Sprint 3: - Add DeserializationWithDump and MutexPoisoned variants to BridgeError - Replace .expect("mutex poisoned") with graceful error propagation in LocalBridge and TcpBridge (4 locations) - Log first 256 bytes as hex dump on deserialization failure for debugging malformed payloads - Classify errors in receive_bridge_inputs: BrokenPipe/ConnectionReset → clean shutdown, MutexPoisoned → shutdown, DeserializationWithDump → skip frame (recoverable) Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/local.rs | 36 +++++++++++++++++++++++++++++------- server/src/bridge/mod.rs | 34 ++++++++++++++++++++++++++++++++-- server/src/bridge/tcp.rs | 36 +++++++++++++++++++++++++++++------- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index 5e73649a6..af5b395b3 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -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, 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 = rmp_serde::from_slice(&payload)?; - tracing::trace!("received {} inputs", inputs.len()); - Ok(inputs) - } + Some(payload) => match rmp_serde::from_slice::>(&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), } } diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index ee5722d86..17301d47f 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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>, mut buffer: ResMut, @@ -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; } } diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index 72781e96e..fe640f7c7 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -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, 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 = rmp_serde::from_slice(&payload)?; - tracing::trace!("received {} inputs", inputs.len()); - Ok(inputs) - } + Some(payload) => match rmp_serde::from_slice::>(&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), } } From 0b599ed662dc84bde33c45d9537708e6a4b89bd0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:51:50 +0100 Subject: [PATCH 04/14] feat(simulation): spawn 3 NPCs with full component bundles Implements ticket #84 for Sprint 3: - Replace single bare NPC with 3 distinct NPCs: dock worker (16,13) with full 4-phase routine and MovementSpeed(2), field tech (14,18) with partial routine, stationary guard (18,14) with no routine - Register all entities (player + 3 NPCs) in EntityRegistry - Populate RelationshipGraph with colleague and rival edges - Add NpcPlugin to app for routine system registration - Add multi-entity visibility tests: 3 NPCs in LOS all visible, NPC behind wall excluded from snapshot Co-Authored-By: Claude Opus 4.6 --- server/src/main.rs | 165 ++++++++++++++++++++++++++++-- server/src/perception/observer.rs | 67 ++++++++++++ 2 files changed, 221 insertions(+), 11 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index 29ee34cb1..4f4318359 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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::(); 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::(); + // 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"); diff --git a/server/src/perception/observer.rs b/server/src/perception/observer.rs index 7483ed202..0a096e0a4 100644 --- a/server/src/perception/observer.rs +++ b/server/src/perception/observer.rs @@ -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::(); + 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::() + .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::(); + 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"); + } } From 9c3c32e854e559a9181432bb1d8b21b0708ed54f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:52:02 +0100 Subject: [PATCH 05/14] feat(perception): add observation event generator Implements ticket #239 for Sprint 3: - ObservationTrigger enum: RoutineDeviation (NPC not at expected routine location), Absence (expected location visible but NPC missing), NewEntity (unknown entity in LOS) - ObservationEventQueue resource with push/drain/len/is_empty - generate_observation_events system runs after emit_observation_events but before process_knowledge_events so it can compare current snapshot against previous-tick knowledge state - Populate PerceptionPlugin with ObservationEventQueue resource and system registration with correct ordering constraints Co-Authored-By: Claude Opus 4.6 --- server/src/perception/interpretation.rs | 452 ++++++++++++++++++++++++ server/src/perception/mod.rs | 12 +- 2 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 server/src/perception/interpretation.rs diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs new file mode 100644 index 000000000..8d2eb9c2a --- /dev/null +++ b/server/src/perception/interpretation.rs @@ -0,0 +1,452 @@ +//! 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, +} + +impl ObservationEventQueue { + pub fn push(&mut self, event: ObservationEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + 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 after knowledge events are processed so the knowledge graph is up-to-date. +/// Produces observation events for: routine deviations, absences, new entities. +pub fn generate_observation_events( + time: Res, + buffer: Res, + registry: Res, + observer_query: Query<(Entity, &KnowledgeGraph), With>, + npc_query: Query<(&TilePosition, &DailyRoutine), With>, + mut event_queue: ResMut, +) { + 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 = 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::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + 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::().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::(); + 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::(); + 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::(); + 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::(); + 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::(); + 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" + ); + } +} diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 0f1d09523..38ae3839d 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -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::() + .add_systems( + Update, + interpretation::generate_observation_events + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + ); tracing::debug!("PerceptionPlugin initialized"); } } From 03884164b3ad955bffefd14cea9847e0972ff81a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 17:52:29 +0100 Subject: [PATCH 06/14] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d05483a4..e918b229d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ 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 + +### Fixed +- IPC error handling (#341) — DeserializationWithDump/MutexPoisoned error variants, hex dump logging on deserialization failure, graceful error classification in bridge I/O systems + ### Changed - Ticketing database moved to shared worktree location (`../settledreach.db`) — eliminates binary merge conflicts across branches - All Python connectors use script-relative path resolution instead of `$REPO_ROOT` env var or git rev-parse From 941547082f61671f18069366d5d695b8ccb0b65c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:15:55 +0100 Subject: [PATCH 07/14] feat(skills): add branch-type reviewer routing to review-pr Review-pr now selects reviewers based on branch type: server/client get Hoshe+Tyre (code), copy gets Hoshe+Paula+Miri (narrative+lore), visual gets Hoshe+Araminta (art direction), audio gets Hoshe+Ozzie (player experience). Hoshe always present as QA baseline. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/review-pr/SKILL.md | 160 ++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 33 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 40d58ebbc..c9c23daf0 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -1,18 +1,18 @@ --- name: review-pr description: > - Review a branch diff with dual agents before merge. Use when the user says - "review-pr", "review this PR", "review this branch", or invokes /review-pr. - Spawns Hoshe (code quality) and Tyre (architecture) in parallel to review - the diff against main. Reports approve/reject with inline comments. + Review a branch diff with team-appropriate agents before merge. Use when the + user says "review-pr", "review this PR", "review this branch", or invokes + /review-pr. Spawns reviewers matched to the branch type (code, copy, visual, + audio) in parallel. Reports approve/reject with inline comments. user-invocable: true allowed-tools: Bash, Read, Grep, Glob, Task --- # PR Review Skill -Dual-agent review of a branch diff against main. Hoshe reviews code quality, -Tyre reviews architecture. Both must approve for a clean review. +Multi-agent review of a branch diff against main. Reviewer composition depends +on the branch type. All reviewers must approve for a clean review. ## Workflow @@ -32,7 +32,21 @@ Fetch remote branches first: git fetch --all ``` -### 2. Generate the diff +### 2. Determine reviewer team + +Map the branch name to a reviewer set. Use the branch prefix (before any `/` +or `-` suffix) to classify: + +| Branch type | Branches | Reviewers | +|-------------|----------|-----------| +| **code** | `server`, `client`, `ci`, or unknown | Hoshe (code quality) + Tyre (architecture) | +| **copy** | `copy` | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) | +| **visual** | `visual` | Hoshe (QA) + Araminta (art direction) | +| **audio** | `audio` | Hoshe (QA) + Ozzie (player experience) | + +If the branch name doesn't match any known type, default to **code** reviewers. + +### 3. Generate the diff ```bash git log --oneline main.. @@ -56,14 +70,17 @@ For large diffs (>1000 lines of source), provide **source files** rather than raw diff to reviewers — cleaner context, better reviews. Read files with `git show origin/:` and include them in the prompt. -### 3. Spawn both reviewers in parallel +### 4. Spawn reviewers in parallel -Use the Task tool to spawn **two agents simultaneously** in a single message. -Use `model: sonnet` for both — sufficient for review, saves cost. +Use the Task tool to spawn **all reviewers simultaneously** in a single message. +Use `model: sonnet` for all — sufficient for review, saves cost. -**Agent 1 — Hoshe (Code Quality)** -- `subagent_type`: `hoshe` -- `model`: `sonnet` +--- + +#### Code reviews (`server`, `client`, `ci`) + +**Hoshe (Code Quality)** +- `subagent_type`: `hoshe`, `model`: `sonnet` - Prompt: Include source code and commit log. Ask Hoshe to review for: - Correctness and bug risks - Error handling gaps @@ -71,11 +88,9 @@ Use `model: sonnet` for both — sufficient for review, saves cost. - Code style and clarity - Security concerns (OWASP top 10, injection risks) - Performance issues -- Request structured verdict: APPROVE or REQUEST_CHANGES with file-specific comments -**Agent 2 — Tyre (Architecture)** -- `subagent_type`: `tyre` -- `model`: `sonnet` +**Tyre (Architecture)** +- `subagent_type`: `tyre`, `model`: `sonnet` - Prompt: Include source code and commit log. Tell Tyre to read the relevant `decisions/*.md` files first, then review for: - Architectural consistency with project decisions @@ -84,40 +99,119 @@ Use `model: sonnet` for both — sufficient for review, saves cost. - Scalability implications - Whether the change respects non-negotiable baselines (D-010, D-012) - Tyre can read files directly from the branch using `git show origin/:` -- Request structured verdict: APPROVE or REQUEST_CHANGES with file-specific comments -### 4. Present results +--- -Format the combined review as a table per reviewer: +#### Copy reviews (`copy`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - Formatting consistency (markdown, file naming, frontmatter) + - Broken references or links + - Spelling and grammar + - File organization and structure + - Missing or orphaned files + +**Paula (Narrative Depth)** +- `subagent_type`: `paula`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Paula to read the + relevant `decisions/*.md` files first, then review for: + - Narrative quality and character voice consistency + - Whether dialogue and monologue feel authentic to the characters + - Consequences and stakes — do choices carry weight? + - Political and interpersonal depth + - Emotional resonance — does the text make you feel something? + +**Miri (World Consistency)** +- `subagent_type`: `miri`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Miri to read the + relevant `decisions/*.md` files first, then review for: + - Lore accuracy — do facts match established setting? + - Internal consistency across files + - IP originality — nothing should read as a copy from another franchise + - Faction, technology, and location details match the worldbuilding docs + - Setting serves gameplay mechanics (asymmetric information, perception) + +--- + +#### Visual reviews (`visual`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - File format and naming conventions + - Asset organization and directory structure + - Missing or broken references in scene/resource files + - Import settings consistency + +**Araminta (Art Direction)** +- `subagent_type`: `araminta`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Araminta to read + the style guide and relevant design docs first, then review for: + - Visual consistency with the established style guide + - Color palette adherence + - UI pattern consistency (diegetic-first, clarity over beauty) + - Whether assets scale gracefully (boxes-with-labels to full-art) + - Mood and tone — sleek, advanced, subtle Commonwealth aesthetic + +--- + +#### Audio reviews (`audio`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - File format and naming conventions + - Audio asset organization and directory structure + - Missing or broken references + - Import/bus configuration consistency + +**Ozzie (Player Experience)** +- `subagent_type`: `ozzie`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Ozzie to review for: + - Emotional impact — does the audio enhance the moment? + - Atmosphere and tone — does it feel like the Commonwealth? + - Player feedback clarity — can the player tell what just happened? + - Pacing — do sounds support or fight the gameplay rhythm? + - Memorable moments — will players remember these audio cues? + +--- + +All reviewers: request structured verdict: APPROVE or REQUEST_CHANGES with +file-specific comments. + +### 5. Present results + +Format the combined review as a table per reviewer. Include one section per +reviewer that was spawned (2 for code/visual/audio, 3 for copy): ``` -## Review: -> main +## Review: -> main (type: code|copy|visual|audio) -### Hoshe (Code Quality): [APPROVE | REQUEST_CHANGES] +### (): [APPROVE | REQUEST_CHANGES] [Summary] | # | File | Severity | Issue | |---|------|----------|-------| | 1 | path:line | critical/warning/suggestion | description | -### Tyre (Architecture): [APPROVE | REQUEST_CHANGES] -[Summary] -| # | File | Severity | Issue | -|---|------|----------|-------| -| 1 | path:line | critical/warning/suggestion | description | +### (): [APPROVE | REQUEST_CHANGES] +... ### Verdict: [APPROVED | CHANGES REQUESTED] ``` -The overall verdict is APPROVED only if **both** reviewers approve. +The overall verdict is APPROVED only if **all** reviewers approve. ## Prompt template for reviewers Use this structure when constructing the agent prompts (adapt as needed): ``` -Review the following branch diff for merge into main. +Review the following {branch_type} branch diff for merge into main. Branch: {branch} +Branch type: {branch_type} (code|copy|visual|audio) Commits: {commit_log} @@ -126,7 +220,7 @@ Diff stats: [Source files or diff here — exclude vendor/generated code] -Review focus: {focus_area} +Your review focus: {focus_area} Respond with: 1. Verdict: APPROVE or REQUEST_CHANGES @@ -138,7 +232,7 @@ Respond with: If no issues found, say APPROVE with a brief positive summary. ``` -## Posting results to Gitea +## 6. Posting results to Gitea After presenting results to the user, post the review as a PR comment: @@ -149,7 +243,7 @@ tea comment --login schweitz --repo jpmschweitzer/settled-reach " "$(cat <<'REVIEW' -## Dual-Agent Review: -> main +## Review: -> main ...review content... REVIEW )" @@ -163,7 +257,7 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead. tea comment --login schweitz --repo jpmschweitzer/settled-reach "$(cat /tmp/review.md)" ``` -## Merging approved PRs +## 7. Merging approved PRs `tea pr merge` fails (405) when branches have conflicts with main. Merge locally instead: From 1761e1aa11dd9897fb5bdb9b124a6bb22260eb7a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:16:10 +0100 Subject: [PATCH 08/14] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be736f633..9474edaf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- 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 ### Changed From 2fd16f08b0d6f22e4399260207c3919e56f0b8b6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:17:55 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fix(server):=20address=20PR=20#14=20revie?= =?UTF-8?q?w=20=E2=80=94=20ordering,=20docs,=20version=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix NpcPlugin system ordering: .before(compute_paths) instead of .after(advance_tick) so PathRequests are picked up same frame - Fix stale doc comment in interpretation.rs: system runs BEFORE knowledge events, not after - Add TODO(v0.2) on RelationshipGraph about information boundary limitation for multiplayer - Document cardinal-only movement as deliberate v0.1 choice - Add comment on manhattan_distance u32::MAX fallback for cross-z - Pin pathfinding crate to 4.11 Co-Authored-By: Claude Opus 4.6 --- server/Cargo.toml | 2 +- server/src/npc/mod.rs | 2 +- server/src/npc/relationships.rs | 6 ++++++ server/src/perception/interpretation.rs | 3 ++- server/src/simulation/pathfinding.rs | 4 ++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/server/Cargo.toml b/server/Cargo.toml index 5bf983a03..61e535a49 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -11,7 +11,7 @@ rmp-serde = "1" bincode = "1" rand = "0.9" rand_chacha = "0.9" -pathfinding = "4" +pathfinding = "4.11" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 27c3f532e..d4c4f3527 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -25,7 +25,7 @@ impl Plugin for NpcPlugin { .add_systems( Update, routine::check_phase_transition - .after(crate::simulation::time::advance_tick), + .before(crate::simulation::pathfinding::compute_paths), ); tracing::debug!("NpcPlugin initialized"); diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 5be72258f..44334e48c 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -25,6 +25,12 @@ pub struct RelationshipEdge { /// 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>, diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 8d2eb9c2a..d09ce6887 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -68,7 +68,8 @@ impl ObservationEventQueue { /// System: interpret visible snapshot against known routines and knowledge. /// -/// Runs after knowledge events are processed so the knowledge graph is up-to-date. +/// 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, diff --git a/server/src/simulation/pathfinding.rs b/server/src/simulation/pathfinding.rs index b4e53d319..6a64ef11c 100644 --- a/server/src/simulation/pathfinding.rs +++ b/server/src/simulation/pathfinding.rs @@ -55,6 +55,8 @@ 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, @@ -90,6 +92,8 @@ pub fn compute_paths( .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, ); From ad653cffc3940e1b95ef3ae6e2ebe19ccd3f866d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:18:04 +0100 Subject: [PATCH 10/14] refactor(server): fix clippy warnings from Rust 1.93 - Use #[derive(Default)] + #[default] instead of manual Default impls for FacingDirection, KnowledgeState, RelationshipState, EntityVisibility - Replace manual modulo check with .is_multiple_of() - Collapse nested if in shadowcast symmetry check Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/types.rs | 9 ++------- server/src/knowledge/events.rs | 2 +- server/src/knowledge/types.rs | 27 ++++++--------------------- server/src/perception/shadowcast.rs | 8 +++----- 4 files changed, 12 insertions(+), 34 deletions(-) diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index b51e38866..d3a2a7f53 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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 { diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index 6dcb85c7f..c81cad404 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -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() { diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 9cec2ea43..2f3acde14 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -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 - } -} diff --git a/server/src/perception/shadowcast.rs b/server/src/perception/shadowcast.rs index cbe28b6b4..7da3779bf 100644 --- a/server/src/perception/shadowcast.rs +++ b/server/src/perception/shadowcast.rs @@ -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 From 526696206e5e6a3161c04b02ec0799299387890f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:18:17 +0100 Subject: [PATCH 11/14] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e918b229d..613fbfdd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### 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 - Ticketing database moved to shared worktree location (`../settledreach.db`) — eliminates binary merge conflicts across branches From 905fc764f52ef92cf1490d795fbf4a8292c82d10 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:41:07 +0100 Subject: [PATCH 12/14] fix(skills): pass file contents to non-Bash reviewer agents in review-pr Paula, Miri, Ozzie lack Bash access and cannot git show from branches. Skill now documents which agents can self-serve and instructs the caller to read and paste file contents into prompts for agents that can't. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/review-pr/SKILL.md | 45 ++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index c9c23daf0..b4aaec879 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -46,7 +46,7 @@ or `-` suffix) to classify: If the branch name doesn't match any known type, default to **code** reviewers. -### 3. Generate the diff +### 3. Generate the diff and read source files ```bash git log --oneline main.. @@ -63,13 +63,24 @@ If the diff is empty, report "No changes to review" and stop. Three-dot diff with pathspec exclusions is unreliable. Instead, either: 1. Use `git diff main...` (full diff) and filter in the prompt, or -2. Have Tyre read source files directly from the branch: +2. Read source files directly from the branch: `git show origin/:` For large diffs (>1000 lines of source), provide **source files** rather than raw diff to reviewers — cleaner context, better reviews. Read files with `git show origin/:` and include them in the prompt. +**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access. +Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**. +Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**. + +For agents without Bash, you MUST read the source files yourself (via +`git show origin/:`) and **paste the file contents directly +into the agent prompt**. Do not tell these agents to read files — they can't. +For very large PRs, read the key files (new/heavily modified) and include +summaries or excerpts of minor changes. Also read and include the relevant +`decisions/*.md` files these agents need for context. + ### 4. Spawn reviewers in parallel Use the Task tool to spawn **all reviewers simultaneously** in a single message. @@ -113,20 +124,24 @@ Use `model: sonnet` for all — sufficient for review, saves cost. - File organization and structure - Missing or orphaned files -**Paula (Narrative Depth)** +**Paula (Narrative Depth)** — NO BASH ACCESS - `subagent_type`: `paula`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Paula to read the - relevant `decisions/*.md` files first, then review for: +- Paula cannot read from branches. You must paste file contents and decision + files directly into the prompt. +- Prompt: Include full text of changed files, commit log, and relevant + `decisions/*.md` content. Ask Paula to review for: - Narrative quality and character voice consistency - Whether dialogue and monologue feel authentic to the characters - Consequences and stakes — do choices carry weight? - Political and interpersonal depth - Emotional resonance — does the text make you feel something? -**Miri (World Consistency)** +**Miri (World Consistency)** — NO BASH ACCESS - `subagent_type`: `miri`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Miri to read the - relevant `decisions/*.md` files first, then review for: +- Miri cannot read from branches. You must paste file contents and decision + files directly into the prompt. +- Prompt: Include full text of changed files, commit log, and relevant + `decisions/*.md` content. Ask Miri to review for: - Lore accuracy — do facts match established setting? - Internal consistency across files - IP originality — nothing should read as a copy from another franchise @@ -145,10 +160,11 @@ Use `model: sonnet` for all — sufficient for review, saves cost. - Missing or broken references in scene/resource files - Import settings consistency -**Araminta (Art Direction)** +**Araminta (Art Direction)** — HAS BASH ACCESS - `subagent_type`: `araminta`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Araminta to read - the style guide and relevant design docs first, then review for: +- Araminta can read files from branches via `git show`. Prompt: Include commit + log and diff stats. Tell Araminta to read the style guide and relevant + design docs first, then review for: - Visual consistency with the established style guide - Color palette adherence - UI pattern consistency (diegetic-first, clarity over beauty) @@ -167,9 +183,12 @@ Use `model: sonnet` for all — sufficient for review, saves cost. - Missing or broken references - Import/bus configuration consistency -**Ozzie (Player Experience)** +**Ozzie (Player Experience)** — NO BASH ACCESS - `subagent_type`: `ozzie`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Ask Ozzie to review for: +- Ozzie cannot read from branches. You must paste file contents directly + into the prompt. +- Prompt: Include full text of changed files and commit log. Ask Ozzie to + review for: - Emotional impact — does the audio enhance the moment? - Atmosphere and tone — does it feel like the Commonwealth? - Player feedback clarity — can the player tell what just happened? From f1bc79a0d2bc39ed08d1b36192dd1e2bbc14e065 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 18:41:53 +0100 Subject: [PATCH 13/14] refactor(skills): trim duplication and improve progressive disclosure - search-docs: remove content already in CLAUDE.md (basic commands, endpoints, examples), keep only unique operations and workflows - ticket: remove duplicated access method and SQL wrapper docs, add missing show --brief command reference - review-pr: extract branch-type reviewer profiles to references/reviewer-profiles.md, reducing SKILL.md from 287 to 187 lines while preserving all reviewer specifications - create-skill: remove empty .gitkeep placeholders from unused references/ and scripts/ directories Reviewed all skills against /create-skill conventions. Main findings were CLAUDE.md content duplication wasting context tokens and a progressive disclosure opportunity in the longest skill. Co-Authored-By: Claude Opus 4.6 --- .../skills/create-skill/references/.gitkeep | 0 .claude/skills/create-skill/scripts/.gitkeep | 0 .claude/skills/review-pr/SKILL.md | 106 +----------------- .../review-pr/references/reviewer-profiles.md | 96 ++++++++++++++++ .claude/skills/search-docs/SKILL.md | 77 +++---------- .claude/skills/ticket/SKILL.md | 35 ++---- 6 files changed, 121 insertions(+), 193 deletions(-) delete mode 100644 .claude/skills/create-skill/references/.gitkeep delete mode 100644 .claude/skills/create-skill/scripts/.gitkeep create mode 100644 .claude/skills/review-pr/references/reviewer-profiles.md diff --git a/.claude/skills/create-skill/references/.gitkeep b/.claude/skills/create-skill/references/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/.claude/skills/create-skill/scripts/.gitkeep b/.claude/skills/create-skill/scripts/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index c9c23daf0..7ea3a0e3b 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -73,110 +73,10 @@ raw diff to reviewers — cleaner context, better reviews. Read files with ### 4. Spawn reviewers in parallel Use the Task tool to spawn **all reviewers simultaneously** in a single message. -Use `model: sonnet` for all — sufficient for review, saves cost. ---- - -#### Code reviews (`server`, `client`, `ci`) - -**Hoshe (Code Quality)** -- `subagent_type`: `hoshe`, `model`: `sonnet` -- Prompt: Include source code and commit log. Ask Hoshe to review for: - - Correctness and bug risks - - Error handling gaps - - Test coverage (are new features tested?) - - Code style and clarity - - Security concerns (OWASP top 10, injection risks) - - Performance issues - -**Tyre (Architecture)** -- `subagent_type`: `tyre`, `model`: `sonnet` -- Prompt: Include source code and commit log. Tell Tyre to read the relevant - `decisions/*.md` files first, then review for: - - Architectural consistency with project decisions - - API/interface design quality - - Dependency and coupling concerns - - Scalability implications - - Whether the change respects non-negotiable baselines (D-010, D-012) -- Tyre can read files directly from the branch using `git show origin/:` - ---- - -#### Copy reviews (`copy`) - -**Hoshe (QA)** -- `subagent_type`: `hoshe`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Ask Hoshe to review for: - - Formatting consistency (markdown, file naming, frontmatter) - - Broken references or links - - Spelling and grammar - - File organization and structure - - Missing or orphaned files - -**Paula (Narrative Depth)** -- `subagent_type`: `paula`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Paula to read the - relevant `decisions/*.md` files first, then review for: - - Narrative quality and character voice consistency - - Whether dialogue and monologue feel authentic to the characters - - Consequences and stakes — do choices carry weight? - - Political and interpersonal depth - - Emotional resonance — does the text make you feel something? - -**Miri (World Consistency)** -- `subagent_type`: `miri`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Miri to read the - relevant `decisions/*.md` files first, then review for: - - Lore accuracy — do facts match established setting? - - Internal consistency across files - - IP originality — nothing should read as a copy from another franchise - - Faction, technology, and location details match the worldbuilding docs - - Setting serves gameplay mechanics (asymmetric information, perception) - ---- - -#### Visual reviews (`visual`) - -**Hoshe (QA)** -- `subagent_type`: `hoshe`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Ask Hoshe to review for: - - File format and naming conventions - - Asset organization and directory structure - - Missing or broken references in scene/resource files - - Import settings consistency - -**Araminta (Art Direction)** -- `subagent_type`: `araminta`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Tell Araminta to read - the style guide and relevant design docs first, then review for: - - Visual consistency with the established style guide - - Color palette adherence - - UI pattern consistency (diegetic-first, clarity over beauty) - - Whether assets scale gracefully (boxes-with-labels to full-art) - - Mood and tone — sleek, advanced, subtle Commonwealth aesthetic - ---- - -#### Audio reviews (`audio`) - -**Hoshe (QA)** -- `subagent_type`: `hoshe`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Ask Hoshe to review for: - - File format and naming conventions - - Audio asset organization and directory structure - - Missing or broken references - - Import/bus configuration consistency - -**Ozzie (Player Experience)** -- `subagent_type`: `ozzie`, `model`: `sonnet` -- Prompt: Include the changed files and commit log. Ask Ozzie to review for: - - Emotional impact — does the audio enhance the moment? - - Atmosphere and tone — does it feel like the Commonwealth? - - Player feedback clarity — can the player tell what just happened? - - Pacing — do sounds support or fight the gameplay rhythm? - - Memorable moments — will players remember these audio cues? - ---- +Read `references/reviewer-profiles.md` for the full per-branch-type reviewer +specifications (agent types, models, prompt focus areas). Match the branch type +from step 2 to the corresponding section. All reviewers: request structured verdict: APPROVE or REQUEST_CHANGES with file-specific comments. diff --git a/.claude/skills/review-pr/references/reviewer-profiles.md b/.claude/skills/review-pr/references/reviewer-profiles.md new file mode 100644 index 000000000..a27e861ff --- /dev/null +++ b/.claude/skills/review-pr/references/reviewer-profiles.md @@ -0,0 +1,96 @@ +# Reviewer Profiles by Branch Type + +Use `model: sonnet` for all reviewers — sufficient for review, saves cost. + +## Code reviews (`server`, `client`, `ci`) + +**Hoshe (Code Quality)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include source code and commit log. Ask Hoshe to review for: + - Correctness and bug risks + - Error handling gaps + - Test coverage (are new features tested?) + - Code style and clarity + - Security concerns (OWASP top 10, injection risks) + - Performance issues + +**Tyre (Architecture)** +- `subagent_type`: `tyre`, `model`: `sonnet` +- Prompt: Include source code and commit log. Tell Tyre to read the relevant + `decisions/*.md` files first, then review for: + - Architectural consistency with project decisions + - API/interface design quality + - Dependency and coupling concerns + - Scalability implications + - Whether the change respects non-negotiable baselines (D-010, D-012) +- Tyre can read files directly from the branch using `git show origin/:` + +## Copy reviews (`copy`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - Formatting consistency (markdown, file naming, frontmatter) + - Broken references or links + - Spelling and grammar + - File organization and structure + - Missing or orphaned files + +**Paula (Narrative Depth)** +- `subagent_type`: `paula`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Paula to read the + relevant `decisions/*.md` files first, then review for: + - Narrative quality and character voice consistency + - Whether dialogue and monologue feel authentic to the characters + - Consequences and stakes — do choices carry weight? + - Political and interpersonal depth + - Emotional resonance — does the text make you feel something? + +**Miri (World Consistency)** +- `subagent_type`: `miri`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Miri to read the + relevant `decisions/*.md` files first, then review for: + - Lore accuracy — do facts match established setting? + - Internal consistency across files + - IP originality — nothing should read as a copy from another franchise + - Faction, technology, and location details match the worldbuilding docs + - Setting serves gameplay mechanics (asymmetric information, perception) + +## Visual reviews (`visual`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - File format and naming conventions + - Asset organization and directory structure + - Missing or broken references in scene/resource files + - Import settings consistency + +**Araminta (Art Direction)** +- `subagent_type`: `araminta`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Tell Araminta to read + the style guide and relevant design docs first, then review for: + - Visual consistency with the established style guide + - Color palette adherence + - UI pattern consistency (diegetic-first, clarity over beauty) + - Whether assets scale gracefully (boxes-with-labels to full-art) + - Mood and tone — sleek, advanced, subtle Commonwealth aesthetic + +## Audio reviews (`audio`) + +**Hoshe (QA)** +- `subagent_type`: `hoshe`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Hoshe to review for: + - File format and naming conventions + - Audio asset organization and directory structure + - Missing or broken references + - Import/bus configuration consistency + +**Ozzie (Player Experience)** +- `subagent_type`: `ozzie`, `model`: `sonnet` +- Prompt: Include the changed files and commit log. Ask Ozzie to review for: + - Emotional impact — does the audio enhance the moment? + - Atmosphere and tone — does it feel like the Commonwealth? + - Player feedback clarity — can the player tell what just happened? + - Pacing — do sounds support or fight the gameplay rhythm? + - Memorable moments — will players remember these audio cues? diff --git a/.claude/skills/search-docs/SKILL.md b/.claude/skills/search-docs/SKILL.md index 4da561f5e..04637933b 100644 --- a/.claude/skills/search-docs/SKILL.md +++ b/.claude/skills/search-docs/SKILL.md @@ -10,82 +10,39 @@ allowed-tools: Bash, Read, Grep, Glob # Search Docs Skill -Semantic search across Commonwealth project documents using Qdrant vector database and ollama embeddings. +Semantic search across project documents. Basic commands (`qdrant-search`, +`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented +in CLAUDE.md. This skill covers advanced operations and workflows. -## Access Method - -**Use the connector wrapper scripts:** -```bash -db/connectors/qdrant-search "query text" -db/connectors/qdrant-index -db/connectors/qdrant-health -db/connectors/qdrant-count -``` - -Or the Python script directly: -```bash -python3 db/connectors/qdrant_connector.py [args] -``` - -## Commands - -### Search -Find documents semantically related to a query: -```bash -db/connectors/qdrant-search "asymmetric information design" -db/connectors/qdrant-search "what did we decide about fog of war" -db/connectors/qdrant-search "engine requirements" -``` - -Returns top 5 matching document chunks with source file, heading, and relevance score. - -### Index a file -Add or update a document in the search index: -```bash -db/connectors/qdrant-index decisions/architecture.md -db/connectors/qdrant-index decisions/perception.md -db/connectors/qdrant-index docs/discussions/round-10-map-fog-borderless.md -db/connectors/qdrant-index docs/briefings/tyre.md -``` - -Files are chunked by markdown headings (# and ##). Each chunk is embedded via ollama and stored in Qdrant with metadata (source_file, heading, chunk_index). +## Advanced Commands ### Index a single chunk + For precise indexing of specific content: ```bash python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading" ``` -### Health check -Verify connectivity to Qdrant and ollama: -```bash -db/connectors/qdrant-health -``` - -### Collection info -Check how many documents are indexed: -```bash -db/connectors/qdrant-count -``` - ### Create collection + Initialize the Qdrant collection (run once during setup): ```bash python3 db/connectors/qdrant_connector.py create-collection ``` -## Endpoints +## Bulk Indexing -Configured in `db/connectors/config.json`: -- **Qdrant:** `http://tower-of-joy:6333` -- **Ollama:** `http://tower-of-joy:11434` (model: nomic-embed-text) -- **Collection:** `commonwealth` (768 dimensions, cosine distance) +Index all project documents at once: +```bash +for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do + db/connectors/qdrant-index "$f" +done +``` ## Fallback If Qdrant or ollama is unreachable, fall back to grep-based search: ```bash -# Search across all project docs grep -r -i "search term" decisions/ DISCUSSION.md docs/ --include="*.md" ``` @@ -96,11 +53,3 @@ grep -r -i "search term" decisions/ DISCUSSION.md docs/ --include="*.md" 3. After briefing updates, re-index affected briefings 4. After decision changes, re-index the relevant decisions/*.md domain files 5. Use search to answer "did we discuss this?" questions with citations - -## Bulk indexing -To index all project documents at once: -```bash -for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do - db/connectors/qdrant-index "$f" -done -``` diff --git a/.claude/skills/ticket/SKILL.md b/.claude/skills/ticket/SKILL.md index a5df765cb..e5ef809a3 100644 --- a/.claude/skills/ticket/SKILL.md +++ b/.claude/skills/ticket/SKILL.md @@ -10,39 +10,17 @@ allowed-tools: Bash, Read, Grep, Glob # Ticket Skill -Manage the project ticketing database (shared `settledreach.db` in the worktree parent directory). - -## Access Method - -**Use the `ticket` CLI for all ticket operations:** -```bash -db/connectors/ticket [args...] -db/connectors/ticket --help -``` - -All output is JSON on stdout. - -For raw SQL access (rare), use the wrapper scripts: -| Script | Purpose | -|--------|---------| -| `db/connectors/sqlite-query ""` | Run SELECT queries | -| `db/connectors/sqlite-exec ""` | Run INSERT/UPDATE/DELETE | -| `db/connectors/sqlite-init` | Create/update database from schema | -| `db/connectors/sqlite-seed` | Seed initiatives from DECISIONS.md | +Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`, +`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md. +This skill covers the full command reference. ## Commands -### List tickets +### List tickets (full flags) ```bash db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T] ``` -### Show ticket detail -```bash -db/connectors/ticket show -``` -Returns full ticket with children, blockers, and dependents. - ### Create ticket ```bash db/connectors/ticket create [--parent N] [--priority P] [--decision D] [--team T] @@ -88,6 +66,11 @@ db/connectors/ticket children <id> db/connectors/ticket count [--status S] ``` +### Batch show +```bash +db/connectors/ticket show --brief <id> [<id>...] +``` + ## Workflow 1. **SI (Project Manager)** is the primary user of this skill From d153035621003a06c16e888ae32b1efd9b3e288d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer <jpmschweitzer@gmail.com> Date: Thu, 12 Feb 2026 18:42:06 +0100 Subject: [PATCH 14/14] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9474edaf8..59dcb5bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed +- Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them +- Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure + ### Added - 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