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