pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
//! The round's event feed: the rolling list of raids, gold, lures, tier-ups
//! and tide events down the right sidebar.
//!
//! Repeats collapse into one counted line, which is why this has state
//! worth testing.

use super::{LOG_TOP, card};
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::sim_events::SimEvent;
use crate::sim::CrabKind;
use bevy::prelude::*;
use std::collections::VecDeque;

/// One line of the event log (0 = newest, at the top).
#[derive(Component)]
pub struct LogLine(pub usize);

/// One line of the feed. Identical lines collapse into a single entry
/// carrying a repeat counter instead of scrolling the interesting ones off
/// the top ("a gull swoops in ×9").
pub struct LogEntry {
    pub text: String,
    pub color: Color,
    pub count: u32,
}

impl LogEntry {
    /// What the line reads as, counter included once it has repeated,
    /// written into a buffer the caller keeps: nine lines repaint every
    /// frame and change only when something happens.
    pub fn render_into(&self, out: &mut String) {
        use std::fmt::Write;
        out.clear();
        out.push_str(&self.text);
        if self.count > 1 {
            let _ = write!(out, " ×{}", self.count);
        }
    }

    /// What the line reads as, counter included once it has repeated.
    #[cfg(test)]
    pub fn rendered(&self) -> String {
        let mut out = String::new();
        self.render_into(&mut out);
        out
    }
}

/// The rolling event log for the current round: newest first.
#[derive(Resource, Default)]
pub struct EventLog(pub VecDeque<LogEntry>);

impl EventLog {
    /// Add a line. A repeat of something already on the feed bumps that
    /// entry's counter and floats it back to the top rather than pushing a
    /// duplicate: nine gulls are one line, not nine.
    pub fn push(&mut self, text: String, color: Color) {
        if let Some(index) = self.0.iter().position(|entry| entry.text == text) {
            let mut entry = self.0.remove(index).expect("index came from the deque");
            entry.count += 1;
            self.0.push_front(entry);
            return;
        }
        self.0.push_front(LogEntry {
            text,
            color,
            count: 1,
        });
        self.0.truncate(LOG_LINES);
    }
}

const LOG_LINES: usize = 9;

/// Fold what the spectators said into the same feed the round's own events use.
///
/// Drained from the session rather than read: a line is shown once and
/// then belongs to the feed, which keeps its own history and its own
/// nine-line window.
///
/// Idle ink, not a seat colour. The voice has no castle on the board, and
/// a line in a player's colour reads as that player having said it.
pub fn collect_chat(mut online: ResMut<crate::app::net::Online>, mut log: ResMut<EventLog>) {
    let Some(session) = &mut online.0 else {
        return;
    };
    for (who, line) in session.heard.drain(..) {
        // A nameless speaker is the beach itself, which is how the lobby
        // already spells a notice that came from nobody.
        let said = match who.is_empty() {
            true => line,
            false => format!("{who}: {line}"),
        };
        log.push(said, palette::IDLE_ROW);
    }
}

/// Fold notable sim events into the log: raids, gold, lures, tier-ups,
/// tide events, gull arrivals, and the surge. Common banks are too chatty
/// to list.
pub fn collect_log(
    mut events: MessageReader<SimEvent>,
    settings: Res<GameSettings>,
    names: Res<crate::app::SeatNames>,
    mut log: ResMut<EventLog>,
) {
    let tr = settings.tr();
    let seat_label = |seat: u8| names.label(tr, seat);
    for event in events.read() {
        let line = match event {
            SimEvent::CastleRaided { owner, lost, .. } => Some((
                fill(
                    tr.log_raid,
                    &[("p", &seat_label(*owner)), ("n", &lost.to_string())],
                ),
                palette::INK_RAID,
            )),
            SimEvent::CrabBanked {
                owner,
                points,
                kind: CrabKind::Golden,
                ..
            } => Some((
                fill(
                    tr.log_golden,
                    &[("p", &seat_label(*owner)), ("n", &format!("{points:+}"))],
                ),
                palette::GOLD,
            )),
            SimEvent::LureStarted { owner } => Some((
                fill(tr.log_lure, &[("p", &seat_label(*owner))]),
                palette::INK_LURE,
            )),
            SimEvent::TierUp { owner } => Some((
                fill(tr.log_tier, &[("p", &seat_label(*owner))]),
                palette::player_color(*owner).lighter(0.15),
            )),
            SimEvent::TideEventFired { event, .. } => Some((
                crate::app::hud::event_name(tr, *event).to_string(),
                palette::INK_TIDE,
            )),
            SimEvent::WheelLocked { owner } => Some((
                fill(tr.log_wheel_locked, &[("p", &seat_label(*owner))]),
                palette::INK_TIDE,
            )),
            SimEvent::GullArrived => Some((tr.log_gull.to_string(), palette::GULL_INK)),
            SimEvent::SurgeStarted => Some((tr.log_surge.to_string(), palette::INK_SURGE)),
            SimEvent::GoldenCalled { .. } => Some((tr.log_golden_call.to_string(), palette::GOLD)),
            SimEvent::GoldenOut { .. } => Some((tr.log_golden_out.to_string(), palette::GOLD)),
            SimEvent::CrabBanked { .. }
            | SimEvent::CrabEaten { .. }
            | SimEvent::CrabSpawned { .. }
            | SimEvent::GullTookOff
            | SimEvent::GullLanded { .. }
            | SimEvent::GullShooed { .. }
            | SimEvent::GullFlewOff { .. }
            | SimEvent::SignpostPlaced { .. }
            | SimEvent::SignpostRemoved { .. }
            | SimEvent::SignpostEvicted { .. }
            | SimEvent::RoundEnded => None,
        };
        if let Some((text, color)) = line {
            log.push(text, color);
        }
    }
}

/// Render the log lines, newest at the top, older ones dimmer.
pub fn update_log(
    log: Res<EventLog>,
    mut lines: Query<(&LogLine, &mut Text, &mut TextColor)>,
    mut value: Local<String>,
) {
    for (line, mut text, mut color) in &mut lines {
        let target = match log.0.get(line.0) {
            Some(entry) => {
                let fade = 1.0 - line.0 as f32 * 0.09;
                entry.render_into(&mut value);
                entry.color.with_alpha(fade)
            }
            None => {
                value.clear();
                Color::NONE
            }
        };
        menu_ui::set_text(&mut text, &value);
        menu_ui::set_color(&mut color, target);
    }
}

/// Spawn the feed card, filling the sidebar below the clock.
pub(super) fn spawn_feed(root: &mut ChildSpawnerCommands) {
    // Its rows stack from the top, so it drops the clock card's centring.
    let (mut node, edge, fill, shadow) = card(LOG_TOP, None);
    node.flex_direction = FlexDirection::Column;
    node.align_items = AlignItems::default();
    node.justify_content = JustifyContent::default();
    node.row_gap = Val::Px(6.0);
    node.padding = UiRect::axes(Val::Px(12.0), Val::Px(10.0));
    node.overflow = Overflow::clip();
    // The card's own fill, kept rather than thinned: at 0.9 the sand behind
    // the sidebar comes through and the feed reads as a different material
    // from the clock card touching it above, (36, 34, 33) against the
    // clock's (18, 20, 28).
    root.spawn((node, edge, fill, shadow))
        .with_children(|list| {
            for index in 0..LOG_LINES {
                list.spawn((
                    LogLine(index),
                    Text::new(""),
                    TextFont {
                        font_size: FontSize::Px(menu_ui::type_scale::BODY),
                        ..default()
                    },
                    TextColor(Color::NONE),
                ));
            }
        });
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Repeats collapse into one counted line instead of flooding the feed,
    /// and the counted line floats back to the top when it happens again.
    #[test]
    fn repeated_lines_merge_with_a_counter() {
        let mut log = EventLog::default();
        for _ in 0..9 {
            log.push("a gull swoops in".into(), Color::WHITE);
        }
        assert_eq!(log.0.len(), 1);
        assert_eq!(log.0[0].rendered(), "a gull swoops in ×9");

        log.push("P1 raided! -4".into(), Color::WHITE);
        assert_eq!(log.0[0].rendered(), "P1 raided! -4");
        log.push("a gull swoops in".into(), Color::WHITE);
        assert_eq!(log.0.len(), 2, "the gull line was bumped, not duplicated");
        assert_eq!(log.0[0].rendered(), "a gull swoops in ×10");
    }

    /// Distinct lines still scroll, and the feed never grows past its slots.
    #[test]
    fn distinct_lines_scroll_and_stay_bounded() {
        let mut log = EventLog::default();
        for n in 0..LOG_LINES + 5 {
            log.push(format!("line {n}"), Color::WHITE);
        }
        assert_eq!(log.0.len(), LOG_LINES);
        assert_eq!(log.0[0].rendered(), format!("line {}", LOG_LINES + 4));
    }
}