- 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>
159 lines
5.0 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|