From 8538f916af89e8982fac2f8ef5e467752972bfe8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:40:09 +0100 Subject: [PATCH] feat(simulation): wire Sprint 15 systems + protocol v13 Register all new systems in NpcPlugin and SimulationPlugin with correct ordering constraints. Protocol bumped to v13: tell_state on VisibleEntity, follow_state and PostConversationQueue on ObserverSnapshot, Follow verb on VerbKind. Observer snapshot populates tell state from DerivedTellState and follow state from FollowTarget. System ordering: tolerance after mood, deviation after activity, tell after mood+deviation, follow after visibility geometry, event monologue after conversations. Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 2 +- server/src/bridge/mod.rs | 11 +++++++- server/src/bridge/text_renderer.rs | 5 ++++ server/src/bridge/types.rs | 20 +++++++++++++- server/src/npc/mod.rs | 15 +++++++++++ server/src/perception/observer/mod.rs | 35 ++++++++++++++++++++++++- server/src/perception/observer/tests.rs | 15 ++++++----- server/src/simulation/mod.rs | 6 +++++ 8 files changed, 98 insertions(+), 11 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 5b4567c66..b31b7ea23 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.13" +version = "0.1.14" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 3893ddfe2..83c872f20 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -173,6 +173,11 @@ impl Plugin for BridgePlugin { .after(crate::perception::anomaly::detect_anomalies), crate::simulation::monologue::process_sprint_anomaly_monologue .after(crate::simulation::monologue::trigger_recognition_monologue), + crate::simulation::monologue::trigger_event_monologue + .after(crate::simulation::monologue::process_sprint_anomaly_monologue) + .after(crate::simulation::sound::collect_sound_events) + .after(crate::simulation::conversation::run_npc_conversations) + .after(crate::simulation::dialogue::process_walk_away), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away @@ -180,10 +185,14 @@ impl Plugin for BridgePlugin { .after(crate::simulation::dialogue::process_talk_interaction), crate::simulation::dialogue::process_confrontation_response .after(crate::simulation::input::process_player_input), + crate::simulation::follow::update_follow_state + .after(crate::perception::observer::compute_visibility_geometry) + .after(crate::simulation::movement::validate_movement) + .before(crate::perception::observer::compute_observer_snapshot), crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) - .after(crate::simulation::monologue::process_sprint_anomaly_monologue) + .after(crate::simulation::monologue::trigger_event_monologue) .after(crate::simulation::dialogue::process_talk_interaction) .after(crate::simulation::dialogue::process_confrontation_response) .before(crate::simulation::time::advance_tick), diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 65e5c2fea..d03a30912 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -240,6 +240,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 100, @@ -250,6 +251,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Known, observation: EntityVisibility::Visible, + tell_state: None, }, VisibleEntity { entity_id: 200, @@ -260,6 +262,7 @@ mod tests { visibility: VisibilitySector::Forward, relationship: RelationshipState::Unknown, observation: EntityVisibility::Visible, + tell_state: None, }, ], visible_tiles: vec![VisibleTile { @@ -302,6 +305,7 @@ mod tests { scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, sound_events: vec![], rng_seed: None, } @@ -431,6 +435,7 @@ mod tests { scan_events: vec![], conversation_events: vec![], conversation_ended: vec![], + follow_state: None, sound_events: vec![], rng_seed: None, }; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 56db7ca1a..874f35622 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 12; +pub const PROTOCOL_VERSION: u8 = 13; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -32,6 +32,8 @@ pub const PROTOCOL_VERSION: u8 = 12; /// rng_seed (#527, deterministic replay — completes WRONG button loop). /// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade). /// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations). +/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use), +/// follow_state (#241, follow mechanic HUD state). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -98,6 +100,11 @@ pub struct ObserverSnapshot { /// Client dismisses the passive dialogue panel for these pairs. #[serde(default)] pub conversation_ended: Vec, + /// Follow-mode state for client HUD display (#241). + /// Present when the player is actively following an NPC. + /// Client shows follow indicator with distance, LOS, and tension. + #[serde(default)] + pub follow_state: Option, /// RNG seed active at this tick for deterministic replay (#527). /// The WRONG button writes this to seed.txt so replays reproduce observed bugs. /// None when the RNG resource is unavailable (should not occur in practice). @@ -270,6 +277,11 @@ pub struct VisibleEntity { /// Visible = in LOS right now. Remembered = known but not in LOS. #[serde(default)] pub observation: EntityVisibility, + /// Current observable tell category for NPC entities (#90, D-024). + /// None for non-NPC entities or NPCs with no active tell this tick. + /// v0.1: field is emitted for future client use; client may ignore. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tell_state: Option, } /// Category of visible entity @@ -452,6 +464,12 @@ pub enum VerbKind { /// Furniture — sit/use Sit, + // --- NPC extended verbs --- + /// Follow an NPC — designate as follow target (#241). + /// Close range only. Enters follow mode: server tracks distance, LOS, + /// and NPC suspicion. Replaces previous follow target if any. + Follow, + // --- Phase 2 verbs (observer, KG-gated, #422) --- /// Confront an NPC about known facts/contradictions. /// Phase 2 only: injected when observer has KnowsDetails+ confidence. diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5d80bff17..5e0188613 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,10 +2,13 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod generate; pub mod interaction; pub mod mood; pub mod relationships; pub mod routine; +pub mod tell_state; +pub mod tolerance; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -25,6 +28,8 @@ impl Plugin for NpcPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -44,6 +49,16 @@ impl Plugin for NpcPlugin { routine::enter_activity .after(crate::simulation::movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), + tolerance::check_tolerance_threshold + .after(mood::update_mood) + .before(crate::simulation::time::advance_tick), + routine::detect_routine_deviation + .after(routine::enter_activity) + .before(crate::perception::observer::compute_observer_snapshot), + tell_state::derive_tell_state + .after(mood::update_mood) + .after(routine::detect_routine_deviation) + .before(crate::perception::observer::compute_observer_snapshot), ), ); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 2105b6406..4720e806a 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; +use crate::simulation::follow::FollowTarget; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; @@ -80,6 +81,7 @@ pub fn compute_observer_snapshot( Option<&mut DialogueResponseBuffer>, Option<&mut ScanEventBuffer>, Option<&mut ConversationEventBuffer>, + Option<&FollowTarget>, ), With, >, @@ -89,6 +91,7 @@ pub fn compute_observer_snapshot( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, Option<&AccessRule>, + Option<&crate::npc::tell_state::DerivedTellState>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, @@ -108,6 +111,7 @@ pub fn compute_observer_snapshot( mut dialogue_response_opt, mut scan_event_buffer_opt, mut conversation_buffer_opt, + follow_target_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); @@ -245,6 +249,29 @@ pub fn compute_observer_snapshot( None => geometry.visible_tiles.clone(), }; + // Build follow-mode state for client HUD (#241) + let follow_state = follow_target_opt.and_then(|ft| { + let target_wire_id = registry.to_stable(ft.target)?.0; + // Distance computed from current positions + let all_query_iter = all_entities.iter(); + let target_pos = all_query_iter + .filter_map(|(e, pos, _, _, _, _)| (e == ft.target).then_some(pos)) + .next()?; + let distance = observer_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + let has_los = target_pos.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(target_pos.x, target_pos.y)); + Some(crate::simulation::follow::FollowStateWire { + target_entity_id: target_wire_id, + distance, + has_los, + proximity_ticks: ft.proximity_ticks, + }) + }); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -262,6 +289,7 @@ pub fn compute_observer_snapshot( scan_events, conversation_events, conversation_ended, + follow_state, sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); @@ -282,13 +310,14 @@ fn filter_visible_entities( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, Option<&AccessRule>, + Option<&crate::npc::tell_state::DerivedTellState>, )>, ) -> (Vec, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); let mut blocked_ids: BTreeSet = BTreeSet::new(); - for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() { + for (entity, pos, is_player, is_npc, access_rule, tell_opt) in all_entities.iter() { if pos.z != geometry.observer_z { continue; } @@ -343,6 +372,8 @@ fn filter_visible_entities( RelationshipState::Unknown }; + let tell_state = tell_opt.and_then(|t| t.category); + visible_ids.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, @@ -353,6 +384,7 @@ fn filter_visible_entities( visibility: sector, relationship, observation: EntityVisibility::Visible, + tell_state, }); } @@ -413,6 +445,7 @@ fn collect_remembered_entities( kind: EntityKind::Npc, visibility: VisibilitySector::Forward, relationship: knowledge.relationship, + tell_state: None, // Remembered entities have no live tell state observation: EntityVisibility::Remembered { confidence: knowledge.confidence, age_ticks, diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index d564eac2f..c397cd166 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -759,8 +759,8 @@ fn phase2_confront_injected_for_npc_with_knows_details() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; - // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) - assert_eq!(interaction.verbs.len(), 3); + // Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected) + assert_eq!(interaction.verbs.len(), 4); let confront = interaction .verbs .iter() @@ -1322,16 +1322,17 @@ fn phase2_poi_with_confront_verb_order() { let verbs = &snapshot.nearby_interactions[0].verbs; assert_eq!( verbs.len(), - 3, - "POI+KnowsDetails: ExamineNpc + Talk + Confront" + 4, + "POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront" ); - // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 + // POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3 assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(verbs[0].priority, 1); assert_eq!(verbs[1].kind, VerbKind::Talk); assert_eq!(verbs[1].priority, 2); - assert_eq!(verbs[2].kind, VerbKind::Confront); - assert_eq!(verbs[2].priority, 3); + // Follow and Confront both at priority 3 — sorted by VerbKind discriminant + assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow)); + assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront)); } // ----------------------------------------------------------------------- diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index ac305bad4..4bf692c88 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod contraband; pub mod conversation; pub mod dialogue; +pub mod follow; pub mod input; pub mod interaction; pub mod inventory; @@ -17,6 +18,7 @@ pub mod path_follow; pub mod pathfinding; pub mod rng; pub mod sound; +pub mod spatial; pub mod stance; pub mod tier; pub mod time; @@ -37,6 +39,9 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -45,6 +50,7 @@ impl Plugin for SimulationPlugin { 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), + spatial::sync_spatial_index.after(movement::validate_movement), listening::update_listening_focus.after(movement::validate_movement), contraband::check_contraband_scan .after(movement::validate_movement)