feat(simulation): character archetype, tell escalation, and news ticker (#587, #589, #591)

- Add character_archetype to StartupMessage with serde default (Detective)
- Bump PROTOCOL_VERSION to 19
- Add escalate_tells_on_activation() and expire_routine_deviations() systems
- RoutineDeviation inserted on triangle NPCs with 300-tick TTL
- Add TickerPool resource with deterministic SimRng rotation (200 ticks)
- Emit current_ticker in ObserverSnapshot when player is in bar zone
- Load ticker YAML from district content directories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 09:13:03 +01:00
co-authored by Claude Opus 4.6
parent fc24de6128
commit 3194a6e491
16 changed files with 567 additions and 22 deletions
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.22"
version = "0.1.23"
dependencies = [
"bevy_app",
"bevy_ecs",
+2
View File
@@ -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"));
+79 -5
View File
@@ -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<DebugResponsePayload>,
/// 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<TickerLine>,
}
/// 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);
+20 -2
View File
@@ -30,6 +30,8 @@ pub struct DistrictContent {
pub routines: Option<RoutineFile>,
pub dialogue_pools: Vec<DialoguePool>,
pub monologue_pools: Vec<MonologuePool>,
/// Ticker headlines loaded from `ticker/*.yaml` files (#591).
pub ticker_headlines: Vec<crate::content::types::TickerHeadline>,
}
/// Errors that can occur during content loading.
@@ -234,16 +236,32 @@ fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&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::<crate::content::types::TickerFile>(&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)
+21
View File
@@ -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<crate::bridge::types::TickerLine> = 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));
}
+24
View File
@@ -594,3 +594,27 @@ pub struct RelationshipPrerequisite {
#[serde(default)]
pub state: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<TickerHeadline>,
}
/// 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
}
+12 -5
View File
@@ -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(),
+12
View File
@@ -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,
}
// ---------------------------------------------------------------------------
+1
View File
@@ -247,6 +247,7 @@ mod tests {
NpcRoutineDeviation {
trigger: DeviationTrigger::WalkAway,
tick: 100,
expires_at_tick: 500,
}
}
+16
View File
@@ -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<PlayerCharacter>>,
error_buffer: Option<ResMut<SimErrorBuffer>>,
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
ticker_pool: Option<Res<TickerPool>>,
) {
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,
});
}
+2
View File
@@ -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,
},
));
+10
View File
@@ -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::<ticker::TickerPool>();
tracing::debug!("SimulationPlugin initialized");
}
}
+2 -2
View File
@@ -155,7 +155,7 @@ pub struct MonologueState {
pub entered: bool,
/// IDs of lines already shown (dedup within session).
pub shown_ids: BTreeSet<String>,
/// 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,
}
+158
View File
@@ -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<TickerLine>,
current_index: usize,
last_rotated_tick: u64,
}
impl TickerPool {
/// Build a pool from the given headline list.
pub fn from_lines(lines: Vec<TickerLine>) -> 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<crate::simulation::time::SimulationTime>,
mut pool: ResMut<TickerPool>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
) {
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<TickerLine> = (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"
);
}
}
+197
View File
@@ -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<SimulationTime>,
queue: Res<TriangleActivatedQueue>,
triangle_query: Query<&TriangleState>,
registry: Res<EntityRegistry>,
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<SimulationTime>,
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::<crate::npc::RoutineDeviation>();
}
}
}
// ---------------------------------------------------------------------------
// 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::<SimulationTime>();
world.init_resource::<TriangleActivatedQueue>();
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::<TriangleActivatedQueue>().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::<RoutineDeviation>(npc_a).is_some(), "NPC A should have RoutineDeviation");
assert!(world.get::<RoutineDeviation>(npc_b).is_some(), "NPC B should have RoutineDeviation");
let dev = world.get::<RoutineDeviation>(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::<RoutineDeviation>(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::<RoutineDeviation>(entity).is_some(),
"Non-expired RoutineDeviation should not be removed"
);
}
}
+10 -7
View File
@@ -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::<WalkabilityMap>();
// 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::<WalkabilityMap>();
// 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::<WalkabilityMap>();
// 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::<EntityRegistry>();