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"); + } }