Files
settled-reach/server/src/simulation/ticker.rs
T
jpmschweitzerandClaude Opus 4.6 3194a6e491 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>
2026-03-05 09:13:03 +01:00

159 lines
5.0 KiB
Rust

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