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