diff --git a/server/Cargo.lock b/server/Cargo.lock index e6cdd0d5b..5ea6fa21a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.22" +version = "0.1.23" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 4b45d7425..f97cc0290 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -318,6 +318,7 @@ mod tests { state_hash: None, sim_errors: vec![], debug_response: None, + current_ticker: None, } } @@ -457,6 +458,7 @@ mod tests { state_hash: None, sim_errors: vec![], debug_response: None, + current_ticker: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 4d86e2076..450a16e87 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,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 = 18; +pub const PROTOCOL_VERSION: u8 = 19; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -46,6 +46,12 @@ pub struct StartupMessage { /// Generated by SessionManager.new_game() on the client. /// Same seed → same EntanglementConfig → same NPC population (D-029). pub world_seed: u64, + /// Character archetype selected by the player (#587). + /// Gates monologue pool selection, verb labels, and examine text. + /// Defaults to Detective for backward compatibility (old clients + /// that omit this field). + #[serde(default)] + pub character_archetype: CharacterArchetype, } /// The ONLY data structure crossing the client-server boundary (D-020) @@ -73,10 +79,11 @@ pub struct StartupMessage { /// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), /// sim_errors (#85, structured error reporting to client). /// v18 adds: debug_response (#580, debug console server — command/response wire). +/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 18. + /// Protocol version for forward compatibility. Current: 19. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -197,6 +204,26 @@ pub struct ObserverSnapshot { /// the tilde console overlay. None in normal gameplay. #[serde(default, skip_serializing_if = "Option::is_none")] pub debug_response: Option, + /// Current news ticker headline (#591). + /// Populated only when player is in The Last Shift zone. + /// Rotates every TICKER_ROTATION_TICKS ticks (deterministic via SimRng). + /// None when player is outside the bar or no ticker content is loaded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ticker: Option, +} + +/// A single news ticker headline crossing the wire boundary (#591). +/// +/// Populated from `ticker/the-last-shift.yaml`. The `dual_lens` field +/// in the source YAML is authoring metadata only — it is NOT included here. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TickerLine { + /// Stable identifier for this headline (e.g. "ticker_001"). + pub id: String, + /// The headline text displayed in the HUD ticker. + pub text: String, + /// Thematic category (freight, politics, infrastructure, sports, etc.). + pub category: String, } /// Game time data for client display (D-031) @@ -421,6 +448,16 @@ pub enum CharacterArchetype { Detective, } +impl CharacterArchetype { + /// String key for monologue pool filtering (#587). + pub fn as_monologue_key(&self) -> &'static str { + match self { + Self::Smuggler => "smuggler", + Self::Detective => "detective", + } + } +} + /// Semantic player actions, not raw key events (D-020) /// Timestamped for deterministic processing #[derive(Debug, Clone, Serialize, Deserialize)] @@ -906,16 +943,50 @@ mod tests { #[test] fn startup_message_roundtrip() { - let msg = StartupMessage { world_seed: 0xDEADBEEF }; + let msg = StartupMessage { + world_seed: 0xDEADBEEF, + character_archetype: CharacterArchetype::Detective, + }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded, msg); assert_eq!(decoded.world_seed, 0xDEADBEEF); + assert_eq!(decoded.character_archetype, CharacterArchetype::Detective); + } + + #[test] + fn startup_message_smuggler_roundtrip() { + let msg = StartupMessage { + world_seed: 42, + character_archetype: CharacterArchetype::Smuggler, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, msg); + assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler); + } + + #[test] + fn startup_message_missing_archetype_defaults_to_detective() { + // Simulate an old client that sends only world_seed (no character_archetype). + // serde(default) on StartupMessage.character_archetype should default to Detective. + #[derive(Serialize)] + struct OldStartupMessage { + world_seed: u64, + } + let old = OldStartupMessage { world_seed: 99 }; + let bytes = rmp_serde::to_vec_named(&old).expect("serialize"); + let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded.world_seed, 99); + assert_eq!(decoded.character_archetype, CharacterArchetype::Detective); } #[test] fn startup_message_zero_seed() { - let msg = StartupMessage { world_seed: 0 }; + let msg = StartupMessage { + world_seed: 0, + character_archetype: CharacterArchetype::default(), + }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.world_seed, 0); @@ -923,7 +994,10 @@ mod tests { #[test] fn startup_message_max_seed() { - let msg = StartupMessage { world_seed: u64::MAX }; + let msg = StartupMessage { + world_seed: u64::MAX, + character_archetype: CharacterArchetype::default(), + }; let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.world_seed, u64::MAX); diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index 0ca061bd4..bd111bfbf 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -30,6 +30,8 @@ pub struct DistrictContent { pub routines: Option, pub dialogue_pools: Vec, pub monologue_pools: Vec, + /// Ticker headlines loaded from `ticker/*.yaml` files (#591). + pub ticker_headlines: Vec, } /// Errors that can occur during content loading. @@ -234,16 +236,32 @@ fn load_district(district_dir: &Path) -> Result { content.monologue_pools = load_yaml_recursive::(&monologue_dir); } + // Ticker headlines (#591): scan ticker/*.yaml in this district + let ticker_dir = district_dir.join("ticker"); + if ticker_dir.is_dir() { + let ticker_files = load_yaml_dir::(&ticker_dir); + for tf in ticker_files { + tracing::info!( + "Ticker loaded: {} headlines from '{}' (feed: {})", + tf.headlines.len(), + tf.location, + tf.feed, + ); + content.ticker_headlines.extend(tf.headlines); + } + } + let npc_count = content.npc_profiles.len(); let triangle_count = content.triangles.len(); let template_count = content.templates.len(); let pool_count = content.pools.len(); let dialogue_count = content.dialogue_pools.len(); let monologue_count = content.monologue_pools.len(); + let ticker_count = content.ticker_headlines.len(); tracing::info!( - "District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools", - npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count + "District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools, {} ticker headlines", + npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count, ticker_count ); Ok(content) diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 49ef8d9ca..a2efffbd0 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -96,6 +96,27 @@ fn load_and_spawn_content(world: &mut World) { index.monologue_line_count() ); + // Build TickerPool from loaded headlines (#591). + let ticker_lines: Vec = store + .districts + .values() + .flat_map(|d| &d.ticker_headlines) + .map(|h| crate::bridge::types::TickerLine { + id: h.id.clone(), + text: h.text.clone(), + category: h.category.clone(), + }) + .collect(); + let ticker_count = ticker_lines.len(); + if ticker_count > 0 { + world.insert_resource( + crate::simulation::ticker::TickerPool::from_lines(ticker_lines), + ); + tracing::info!("TickerPool built: {} headlines loaded", ticker_count); + } else { + tracing::warn!("TickerPool: no ticker headlines found — current_ticker will be None"); + } + world.insert_resource(ContentStoreResource(store)); world.insert_resource(LinePoolIndexResource(index)); } diff --git a/server/src/content/types.rs b/server/src/content/types.rs index ccfcb786d..c7fb71a63 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -594,3 +594,27 @@ pub struct RelationshipPrerequisite { #[serde(default)] pub state: Option, } + +// --------------------------------------------------------------------------- +// Ticker content (#591) +// --------------------------------------------------------------------------- + +/// Intermediate type for deserializing a news ticker YAML file. +/// The `dual_lens` field is authoring metadata — not deserialized or forwarded. +#[derive(Debug, Deserialize)] +pub struct TickerFile { + /// Location slug this ticker belongs to (e.g. "the-last-shift"). + pub location: String, + /// Feed identifier (e.g. "meridian"). + pub feed: String, + pub headlines: Vec, +} + +/// A single headline entry in a ticker YAML file. +#[derive(Debug, Deserialize)] +pub struct TickerHeadline { + pub id: String, + pub text: String, + pub category: String, + // dual_lens is intentionally omitted — authoring metadata only +} diff --git a/server/src/main.rs b/server/src/main.rs index 30c76e037..0f17d4cfb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -163,17 +163,20 @@ fn main() { // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); + // Character archetype from client's StartupMessage (#587). + let archetype = startup.character_archetype; + // Gauntlet test world for --test-mode, proof room for normal mode. if test_mode { #[cfg(feature = "gauntlet")] - settled_reach_server::test_world::setup_gauntlet(&mut app); + settled_reach_server::test_world::setup_gauntlet(&mut app, archetype); #[cfg(not(feature = "gauntlet"))] { eprintln!("--test-mode requires the 'gauntlet' feature"); std::process::exit(1); } } else { - setup_proof_room(&mut app); + setup_proof_room(&mut app, archetype); } tracing::info!( @@ -301,6 +304,7 @@ fn send_panic_error(app: &App, panic_msg: &str) { triangle_crisis_events: vec![], state_hash: None, debug_response: None, + current_ticker: None, sim_errors: vec![SimError { kind: SimErrorKind::Panic, message: format!("Simulation panic: {}", panic_msg), @@ -362,7 +366,7 @@ fn dump_schedule_graph() { /// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs. /// Extracted from the original inline setup for reuse by both test-mode and normal mode. -fn setup_proof_room(app: &mut App) { +fn setup_proof_room(app: &mut App, archetype: settled_reach_server::bridge::types::CharacterArchetype) { use settled_reach_server::knowledge::registry::EntityRegistry; use settled_reach_server::knowledge::KnowledgeGraph; use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph}; @@ -394,8 +398,10 @@ fn setup_proof_room(app: &mut App) { let mut registry = EntityRegistry::new(0); - // Player at (16,16) — smuggler archetype (#418, D-053) + // Player at (16,16) — archetype from StartupMessage (#587, D-053) let profile = MovementProfile::smuggler(); + let mut monologue_state = MonologueState::default(); + monologue_state.character = archetype.as_monologue_key().to_string(); let player = app .world_mut() .spawn(( @@ -404,11 +410,12 @@ fn setup_proof_room(app: &mut App) { Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), - MonologueState::default(), + monologue_state, MonologueBuffer::default(), SprintAnomalyQueue::default(), CognitiveDelay::default(), ListeningFocus::new(TilePosition::new(16, 16, 0)), + archetype, profile, profile.initial_stance(), PlayerMoveCooldown::default(), diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index e9e4be756..9bfa565d4 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -132,15 +132,25 @@ pub enum AnimationTier { Tier2, } +/// Duration (ticks) a storyteller-escalated RoutineDeviation persists. +/// 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031). +pub const TELL_ESCALATION_DURATION_TICKS: u64 = 300; + /// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2). /// /// Inserted when a player action causes an NPC to break from their scheduled /// behavior. Acts as a hook for the storyteller system and affects future /// interactions (e.g., second-approach dialogue differences). +/// +/// `expires_at_tick`: tick at which this component should be removed. +/// 0 means "never expires" (legacy default for old insertions). #[derive(Component, Debug, Clone)] pub struct RoutineDeviation { pub trigger: DeviationTrigger, pub tick: u64, + /// Tick at which this deviation expires (component removed by cleanup system). + /// Set to `tick + TELL_ESCALATION_DURATION_TICKS` for time-limited deviations. + pub expires_at_tick: u64, } /// What caused an NPC's routine deviation. @@ -150,6 +160,8 @@ pub enum DeviationTrigger { WalkAway, /// Player delivered a confrontation (D-063). Confrontation, + /// Storyteller activated a triangle this NPC belongs to (#589, D-024 axis 9). + TriangleEscalation, } // --------------------------------------------------------------------------- diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs index 5ed6fd66f..d2e97be92 100644 --- a/server/src/npc/tell_state.rs +++ b/server/src/npc/tell_state.rs @@ -247,6 +247,7 @@ mod tests { NpcRoutineDeviation { trigger: DeviationTrigger::WalkAway, tick: 100, + expires_at_tick: 500, } } diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 7586075e3..30e33574f 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -31,6 +31,7 @@ use crate::simulation::rng::SimRng; use crate::content::template::TriangleCrisisEventQueue; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; +use crate::simulation::ticker::{TickerPool, LAST_SHIFT_ZONE_ID}; use crate::simulation::time::SimulationTime; use crate::simulation::zone::ZoneMap; @@ -106,6 +107,7 @@ pub fn compute_observer_snapshot( pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With>, error_buffer: Option>, npc_count_query: Query>, + ticker_pool: Option>, ) { let Ok(( observer_entity, @@ -437,6 +439,19 @@ pub fn compute_observer_snapshot( .map(|mut buf| buf.drain()) .unwrap_or_default(); + // News ticker (#591): populate when player is in The Last Shift zone. + let player_zone = zone_map + .as_deref() + .map(|zm| zm.zone_at(observer_pos.x, observer_pos.y, observer_pos.z)) + .flatten(); + let current_ticker = if player_zone == Some(LAST_SHIFT_ZONE_ID) { + ticker_pool + .as_deref() + .and_then(|pool| pool.current_line().cloned()) + } else { + None + }; + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -468,6 +483,7 @@ pub fn compute_observer_snapshot( state_hash, sim_errors, debug_response, + current_ticker, }); } diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index f271e3444..29adfc7b2 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -765,6 +765,7 @@ pub fn process_walk_away( .insert(crate::npc::RoutineDeviation { trigger: crate::npc::DeviationTrigger::WalkAway, tick: time.tick, + expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, }); // Phase 3: Emit IncompleteInteraction knowledge event @@ -868,6 +869,7 @@ pub fn process_confrontation_response( crate::npc::RoutineDeviation { trigger: crate::npc::DeviationTrigger::Confrontation, tick: time.tick, + expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, }, )); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b3497debe..abbaf2c0f 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -32,6 +32,7 @@ pub mod spatial; pub mod stance; pub mod tier; pub mod time; +pub mod ticker; pub mod zone; /// Core simulation plugin @@ -136,8 +137,17 @@ impl Plugin for SimulationPlugin { .add_systems( Update, chunk_streaming::chunk_streaming.before(input::process_player_input), + ) + // News ticker rotation (#591) — deterministic via SimRng, before snapshot. + .add_systems( + Update, + ticker::tick_news_ticker + .before(crate::perception::observer::compute_observer_snapshot), ); + // Initialize TickerPool with empty default; populated by ContentPlugin at Startup. + app.init_resource::(); + tracing::debug!("SimulationPlugin initialized"); } } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4f6d6b90e..5376b39cc 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -155,7 +155,7 @@ pub struct MonologueState { pub entered: bool, /// IDs of lines already shown (dedup within session). pub shown_ids: BTreeSet, - /// Character type for pool filtering. v0.1: always "detective". + /// Character type for pool filtering. Set from CharacterArchetype (#587). pub character: String, /// Tick of the last observation event we reacted to (#119, observe_npc). /// Observation events arrive one tick after the snapshot that caused them, @@ -171,7 +171,7 @@ impl Default for MonologueState { idle_ticks: 0, entered: false, shown_ids: BTreeSet::new(), - // v0.1: default to detective; character selection sets this + // Default to detective; overridden by CharacterArchetype at spawn (#587) character: "detective".to_string(), last_observation_tick: 0, } diff --git a/server/src/simulation/ticker.rs b/server/src/simulation/ticker.rs new file mode 100644 index 000000000..ca3a4ed60 --- /dev/null +++ b/server/src/simulation/ticker.rs @@ -0,0 +1,158 @@ +//! News ticker pool for The Last Shift bar (#591). +//! +//! Holds the loaded headline pool and tracks which headline is currently +//! displayed. Rotates every `TICKER_ROTATION_TICKS` ticks using `SimRng` +//! for determinism (D-010 principle 4). +//! +//! Zone detection: headlines are only emitted when the player is in +//! `LAST_SHIFT_ZONE_ID` (The Last Shift bar tile region). + +use bevy_ecs::prelude::*; +use rand::Rng; + +use crate::bridge::types::TickerLine; + +/// Zone ID assigned to The Last Shift bar tiles (#591, D-077). +/// +/// Must match the zone_id used in the production location YAML +/// when the transit district ZoneMap is populated. +pub const LAST_SHIFT_ZONE_ID: u16 = 1; + +/// How often the displayed headline rotates, in ticks. +/// 200 ticks = 20 game-minutes at 10 ticks/game-minute (D-031). +pub const TICKER_ROTATION_TICKS: u64 = 200; + +/// Resource: loaded ticker pool for the news feed (#591). +/// +/// Built at startup from `ticker/the-last-shift.yaml`. +/// Holds all headlines and tracks the current display index. +/// Rotation is deterministic — always use `SimRng`, never system randomness. +#[derive(Resource, Debug, Default)] +pub struct TickerPool { + lines: Vec, + current_index: usize, + last_rotated_tick: u64, +} + +impl TickerPool { + /// Build a pool from the given headline list. + pub fn from_lines(lines: Vec) -> Self { + Self { + lines, + current_index: 0, + last_rotated_tick: 0, + } + } + + /// True if the pool has at least one headline. + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + /// The currently active headline, or `None` if the pool is empty. + pub fn current_line(&self) -> Option<&TickerLine> { + self.lines.get(self.current_index) + } + + /// Advance to a new headline using SimRng if `TICKER_ROTATION_TICKS` have elapsed. + /// + /// Called each tick by `tick_news_ticker`. Deterministic: same seed → same rotation. + pub fn maybe_rotate(&mut self, current_tick: u64, rng: &mut crate::simulation::rng::SimRng) { + if self.lines.is_empty() { + return; + } + if current_tick > 0 + && current_tick.saturating_sub(self.last_rotated_tick) >= TICKER_ROTATION_TICKS + { + self.current_index = rng.rng.random_range(0..self.lines.len()); + self.last_rotated_tick = current_tick; + } + } +} + +/// System: advance ticker rotation each tick (#591, D-010). +/// +/// Runs before `compute_observer_snapshot` so the correct headline +/// is available when the snapshot is assembled. +pub fn tick_news_ticker( + time: Res, + mut pool: ResMut, + mut rng: ResMut, +) { + pool.maybe_rotate(time.tick, &mut rng); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::rng::SimRng; + + fn make_pool(count: usize) -> TickerPool { + let lines: Vec = (0..count) + .map(|i| TickerLine { + id: format!("ticker_{:03}", i), + text: format!("Headline {}", i), + category: "test".to_string(), + }) + .collect(); + TickerPool::from_lines(lines) + } + + #[test] + fn empty_pool_returns_none() { + let pool = TickerPool::default(); + assert!(pool.current_line().is_none()); + } + + #[test] + fn non_empty_pool_returns_first_line() { + let pool = make_pool(5); + assert_eq!(pool.current_line().unwrap().id, "ticker_000"); + } + + #[test] + fn rotation_advances_after_interval() { + let mut pool = make_pool(30); + let mut rng = SimRng::new(42); + + // No rotation at tick 0 + pool.maybe_rotate(0, &mut rng); + assert_eq!(pool.current_index, 0); + + // Rotation fires at tick TICKER_ROTATION_TICKS + pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng); + // index changed (RNG picks something; just verify it's in range) + assert!(pool.current_index < 30); + } + + #[test] + fn rotation_is_deterministic() { + let mut pool_a = make_pool(30); + let mut rng_a = SimRng::new(42); + pool_a.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_a); + let idx_a = pool_a.current_index; + + let mut pool_b = make_pool(30); + let mut rng_b = SimRng::new(42); + pool_b.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_b); + let idx_b = pool_b.current_index; + + assert_eq!(idx_a, idx_b, "Same seed must produce same rotation"); + } + + #[test] + fn no_double_rotation_in_same_window() { + let mut pool = make_pool(30); + let mut rng = SimRng::new(42); + + pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng); + let idx_after_first = pool.current_index; + + // One tick later — within same window, should not rotate + pool.maybe_rotate(TICKER_ROTATION_TICKS + 1, &mut rng); + assert_eq!( + pool.current_index, idx_after_first, + "Should not rotate within the same window" + ); + } +} diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index f135d415d..cf0270449 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -384,6 +384,17 @@ impl Plugin for StorytellerPlugin { .after(append_player_history) .after(tick_contamination_activation) .before(crate::simulation::time::advance_tick), + ) + .add_systems( + Update, + escalate_tells_on_activation + .after(activation_pass) + .before(crate::npc::tell_state::derive_tell_state), + ) + .add_systems( + Update, + expire_routine_deviations + .before(crate::npc::tell_state::derive_tell_state), ); tracing::debug!("StorytellerPlugin initialized"); @@ -602,6 +613,75 @@ pub fn activation_pass( ); } +// --------------------------------------------------------------------------- +// Tell escalation (#589) +// --------------------------------------------------------------------------- + +/// System: insert RoutineDeviation on triangle NPCs when a triangle activates (#589). +/// +/// Reads TriangleActivatedQueue without draining — other consumers remain unaffected. +/// For each activation event, finds all NPCs assigned to that triangle and inserts +/// a time-limited RoutineDeviation component. RoutineDeviation is priority-1 in the +/// tell derivation hierarchy (D-024), making the NPCs immediately observable as anomalous. +/// +/// Runs after activation_pass (which populates the queue). +pub fn escalate_tells_on_activation( + time: Res, + queue: Res, + triangle_query: Query<&TriangleState>, + registry: Res, + mut commands: Commands, +) { + use crate::npc::{DeviationTrigger, RoutineDeviation, TELL_ESCALATION_DURATION_TICKS}; + + if queue.events.is_empty() { + return; + } + + for event in &queue.events { + // Find the TriangleState matching this activation event + for triangle in triangle_query.iter() { + if triangle.triangle_id != event.triangle_id { + continue; + } + + // Insert RoutineDeviation on every NPC in the triangle + for stable_id in triangle.role_assignments.values() { + let Some(entity) = registry.to_entity(stable_id) else { + tracing::warn!( + npc_id = stable_id.0, + "escalate_tells_on_activation: NPC not found in registry" + ); + continue; + }; + commands.entity(entity).insert(RoutineDeviation { + trigger: DeviationTrigger::TriangleEscalation, + tick: time.tick, + expires_at_tick: time.tick + TELL_ESCALATION_DURATION_TICKS, + }); + } + break; // each triangle_id appears at most once + } + } +} + +/// System: remove expired RoutineDeviation components (#589). +/// +/// Each tick, removes RoutineDeviation from any entity whose expires_at_tick +/// has elapsed. A zero expires_at_tick means "never expires" (legacy behavior +/// for WalkAway/Confrontation deviations that predate this cleanup system). +pub fn expire_routine_deviations( + time: Res, + deviations: Query<(Entity, &crate::npc::RoutineDeviation)>, + mut commands: Commands, +) { + for (entity, deviation) in deviations.iter() { + if deviation.expires_at_tick > 0 && time.tick >= deviation.expires_at_tick { + commands.entity(entity).remove::(); + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1081,4 +1161,121 @@ mod tests { // capped at 5 * 2.0 = 10.0 assert!((score - 10.0_f32).abs() < 0.001, "score={score}"); } + + // ----------------------------------------------------------------------- + // #589: escalate_tells_on_activation + // ----------------------------------------------------------------------- + + #[test] + fn escalate_inserts_routine_deviation_on_triangle_npcs() { + use crate::knowledge::registry::EntityRegistry; + use crate::knowledge::types::StableId; + use crate::npc::{Npc, RoutineDeviation}; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + + let mut registry = EntityRegistry::new(0); + + // Spawn two NPCs and register them + let npc_a = world.spawn(Npc).id(); + let npc_b = world.spawn(Npc).id(); + registry.register(npc_a); + registry.register(npc_b); + let sid_a = registry.to_stable(npc_a).unwrap(); + let sid_b = registry.to_stable(npc_b).unwrap(); + + world.insert_resource(registry); + + // Spawn a triangle with these two NPCs + let triangle_id = TriangleId::from_seed_and_slug(0, "test-escalation"); + let mut role_assignments = BTreeMap::new(); + role_assignments.insert(RoleId::new("role_a"), sid_a); + role_assignments.insert(RoleId::new("role_b"), sid_b); + world.spawn(( + TriangleState { + triangle_id, + role_assignments, + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 1, + template_id: TemplateId::from_seed_and_slug(0, "test"), + classification: TriangleClassification::ActiveFork, + }, + ActiveSim, + )); + + // Populate the activation queue + world.resource_mut::().push(TriangleActivatedEvent { + triangle_id, + tick: 100, + anchor_entity: npc_a, + anchor_score: 15.0, + }); + + // Run system + let mut schedule = Schedule::default(); + schedule.add_systems(escalate_tells_on_activation); + schedule.run(&mut world); + + // Both NPCs should have RoutineDeviation + assert!(world.get::(npc_a).is_some(), "NPC A should have RoutineDeviation"); + assert!(world.get::(npc_b).is_some(), "NPC B should have RoutineDeviation"); + let dev = world.get::(npc_a).unwrap(); + assert_eq!(dev.trigger, crate::npc::DeviationTrigger::TriangleEscalation); + assert_eq!(dev.expires_at_tick, crate::npc::TELL_ESCALATION_DURATION_TICKS); + } + + #[test] + fn expire_routine_deviations_removes_expired() { + use crate::npc::{DeviationTrigger, RoutineDeviation}; + + let mut world = World::new(); + let mut time = SimulationTime::default(); + time.tick = 500; + world.insert_resource(time); + + // Spawn entity with expired deviation + let entity = world.spawn(RoutineDeviation { + trigger: DeviationTrigger::TriangleEscalation, + tick: 100, + expires_at_tick: 400, // expired at tick 500 + }).id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(expire_routine_deviations); + schedule.run(&mut world); + + assert!( + world.get::(entity).is_none(), + "Expired RoutineDeviation should be removed" + ); + } + + #[test] + fn expire_routine_deviations_keeps_non_expired() { + use crate::npc::{DeviationTrigger, RoutineDeviation}; + + let mut world = World::new(); + let mut time = SimulationTime::default(); + time.tick = 200; + world.insert_resource(time); + + // Deviation that expires at tick 500 — should survive at tick 200 + let entity = world.spawn(RoutineDeviation { + trigger: DeviationTrigger::TriangleEscalation, + tick: 100, + expires_at_tick: 500, + }).id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(expire_routine_deviations); + schedule.run(&mut world); + + assert!( + world.get::(entity).is_some(), + "Non-expired RoutineDeviation should not be removed" + ); + } } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 91f14c0ab..18957e556 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125; /// is intentional for deterministic test setups but should be revisited /// if Gauntlet is ever served by the production startup pipeline. #[cfg(feature = "gauntlet")] -pub fn setup_gauntlet(app: &mut App) { +pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) { // Start with a fully blocked map, then carve rooms and corridors. let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1); @@ -192,6 +192,8 @@ pub fn setup_gauntlet(app: &mut App) { // Spawn at Hub center: absolute (50, 58) let profile = MovementProfile::smuggler(); let player_pos = TilePosition::new(50, 58, 0); + let mut monologue_state = MonologueState::default(); + monologue_state.character = archetype.as_monologue_key().to_string(); let player = app .world_mut() .spawn(( @@ -200,7 +202,7 @@ pub fn setup_gauntlet(app: &mut App) { Facing::default(), KnowledgeGraph::new(), NearbyInteractionBuffer::default(), - MonologueState::default(), + monologue_state, MonologueBuffer::default(), SprintAnomalyQueue::default(), ScanEventBuffer::default(), @@ -212,6 +214,7 @@ pub fn setup_gauntlet(app: &mut App) { crate::simulation::pressure::CharacterPressure::default(), )) .id(); + app.world_mut().entity_mut(player).insert(archetype); registry.register(player); // --- Hub signs (StableId 1-4) --- @@ -695,7 +698,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet(&mut app); + setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default()); // Run all 29 world-query invariants against the fully-initialized gauntlet world. invariants::run_invariants(app.world_mut()); @@ -715,7 +718,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet(&mut app); + setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default()); let wm = app.world().resource::(); // Hub center at (50, 58) must be walkable @@ -729,7 +732,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet(&mut app); + setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default()); let wm = app.world().resource::(); // North wall segment at absolute (90, 54) should be blocked @@ -745,7 +748,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet(&mut app); + setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default()); let wm = app.world().resource::(); // corridor-E center should be walkable @@ -759,7 +762,7 @@ mod tests { app.add_plugins(crate::knowledge::KnowledgePlugin); app.add_plugins(crate::npc::NpcPlugin); - setup_gauntlet(&mut app); + setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default()); let registry = app.world().resource::();