use crate::app::i18n::{Tr, fill};
use crate::app::teams::TeamMode;
use crate::sim::{Board, MAX_PLAYERS, PlayerAction, PlayerId};
use bevy::prelude::*;
pub const COMEBACK_MIN: u32 = 20;
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub struct SeatTally {
pub lost: u32,
pub placed: u32,
pub best_lure: u32,
pub worst_deficit: u32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Reading {
pub ticks: u64,
pub scores: [u32; MAX_PLAYERS],
pub lure: Option<PlayerId>,
}
impl Reading {
pub fn of(board: &Board) -> Reading {
Reading {
ticks: board.ticks(),
scores: *board.scores(),
lure: board.lure().map(|(owner, _)| owner),
}
}
}
#[derive(Resource, Clone, Default, PartialEq, Eq, Debug)]
pub struct RoundTally {
pub seats: [SeatTally; MAX_PLAYERS],
lure: Option<(PlayerId, u32)>,
next_tick: u64,
whole: bool,
}
impl RoundTally {
pub fn observe(
&mut self,
before: Reading,
board: &Board,
actions: &[PlayerAction; MAX_PLAYERS],
) {
let placed = std::array::from_fn(|seat| match actions[seat] {
PlayerAction::Place { x, y, dir } => board.signpost_at(x, y).is_some_and(|post| {
usize::from(post.owner) == seat && post.dir == dir && post.placed >= before.ticks
}),
PlayerAction::None | PlayerAction::Remove { .. } | PlayerAction::CallEvent(_) => false,
});
self.fold(before, Reading::of(board), placed);
}
pub fn fold(&mut self, before: Reading, after: Reading, placed: [bool; MAX_PLAYERS]) {
if before.ticks == 0 {
*self = RoundTally {
whole: true,
..RoundTally::default()
};
} else if before.ticks != self.next_tick {
self.whole = false;
}
self.next_tick = after.ticks;
let running = self.lure.map(|(owner, _)| owner);
if running != before.lure {
self.close_lure();
self.lure = before.lure.map(|owner| (owner, 0));
}
for (seat, tally) in self.seats.iter_mut().enumerate() {
let (was, now) = (before.scores[seat], after.scores[seat]);
tally.lost += was.saturating_sub(now);
tally.placed += u32::from(placed[seat]);
if let Some((owner, gained)) = &mut self.lure
&& usize::from(*owner) == seat
{
*gained += now.saturating_sub(was);
}
}
let leader = after.scores.iter().copied().max().unwrap_or(0);
for (tally, &score) in self.seats.iter_mut().zip(&after.scores) {
tally.worst_deficit = tally.worst_deficit.max(leader - score);
}
}
fn close_lure(&mut self) {
if let Some((owner, gained)) = self.lure.take() {
let best = &mut self.seats[usize::from(owner)].best_lure;
*best = (*best).max(gained);
}
}
pub fn covers(&self, board: &Board) -> bool {
self.whole && self.next_tick == board.ticks()
}
pub fn awards(&self, seats: u8, mode: TeamMode, winners: &[bool; MAX_PLAYERS]) -> Vec<Award> {
let mut settled = self.clone();
settled.close_lure();
let seats = &settled.seats[..usize::from(seats).min(MAX_PLAYERS)];
let mut out = Vec::new();
if mode == TeamMode::Solo {
let behind = |seat: usize| winners[seat].then_some(seats[seat].worst_deficit);
out.extend(
top(seats.len(), behind, COMEBACK_MIN).map(|(who, n)| Award {
kind: AwardKind::Comeback,
who,
n,
}),
);
}
for (kind, figure) in [
(
AwardKind::Lure,
(|t: &SeatTally| t.best_lure) as fn(&SeatTally) -> u32,
),
(AwardKind::Arrows, |t| t.placed),
(AwardKind::Hit, |t| t.lost),
] {
let of = |seat: usize| Some(figure(&seats[seat]));
out.extend(top(seats.len(), of, 1).map(|(who, n)| Award { kind, who, n }));
}
out
}
}
fn top(seats: usize, figure: impl Fn(usize) -> Option<u32>, floor: u32) -> Option<(Vec<u8>, u32)> {
let best = (0..seats).filter_map(&figure).max()?;
(best >= floor).then(|| {
let who = (0..seats)
.filter(|&seat| figure(seat) == Some(best))
.map(|seat| seat as u8)
.collect();
(who, best)
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AwardKind {
Comeback,
Lure,
Arrows,
Hit,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Award {
pub kind: AwardKind,
pub who: Vec<u8>,
pub n: u32,
}
impl Award {
pub fn line(&self, tr: &Tr, name: impl Fn(u8) -> String) -> String {
let template = match self.kind {
AwardKind::Comeback => tr.award_comeback,
AwardKind::Lure => tr.award_lure,
AwardKind::Arrows => tr.award_arrows,
AwardKind::Hit => tr.award_hit,
};
let who: Vec<String> = self.who.iter().map(|&seat| name(seat)).collect();
fill(
template,
&[("p", &who.join(" & ")), ("n", &self.n.to_string())],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::EN;
use crate::sim::{BotLevel, bot_action, generate_arena};
fn reading(ticks: u64, scores: [u32; MAX_PLAYERS], lure: Option<PlayerId>) -> Reading {
Reading {
ticks,
scores,
lure,
}
}
fn tally_of(steps: &[([u32; MAX_PLAYERS], Option<PlayerId>)]) -> RoundTally {
let mut tally = RoundTally::default();
let mut before = reading(0, [0; MAX_PLAYERS], None);
for (tick, &(scores, lure)) in steps.iter().enumerate() {
let after = reading(tick as u64 + 1, scores, lure);
tally.fold(before, after, [false; MAX_PLAYERS]);
before = after;
}
tally
}
const SOLO_WIN_P2: [bool; MAX_PLAYERS] = [false, true, false, false, false, false];
#[test]
fn a_comeback_is_a_winner_who_was_well_behind() {
let tally = tally_of(&[([30, 0, 0, 0, 0, 0], None), ([30, 40, 0, 0, 0, 0], None)]);
let awards = tally.awards(2, TeamMode::Solo, &SOLO_WIN_P2);
assert_eq!(
awards[0],
Award {
kind: AwardKind::Comeback,
who: vec![1],
n: 30
}
);
let close = tally_of(&[([10, 0, 0, 0, 0, 0], None), ([10, 40, 0, 0, 0, 0], None)]);
let awards = close.awards(2, TeamMode::Solo, &SOLO_WIN_P2);
assert!(
awards.iter().all(|a| a.kind != AwardKind::Comeback),
"{awards:?}"
);
let awards = tally.awards(2, TeamMode::Pairs, &SOLO_WIN_P2);
assert!(
awards.iter().all(|a| a.kind != AwardKind::Comeback),
"{awards:?}"
);
}
#[test]
fn a_lure_counts_its_owners_gains_while_it_runs() {
let tally = tally_of(&[
([0, 0, 0, 0, 0, 0], Some(0)),
([5, 9, 0, 0, 0, 0], Some(0)),
([8, 9, 0, 0, 0, 0], None),
([20, 9, 0, 0, 0, 0], None),
([20, 9, 0, 0, 0, 0], Some(0)),
([22, 9, 0, 0, 0, 0], Some(0)),
]);
let lure = tally
.awards(
2,
TeamMode::Solo,
&[true, false, false, false, false, false],
)
.into_iter()
.find(|a| a.kind == AwardKind::Lure)
.expect("a lure award");
assert_eq!(lure.who, vec![0]);
assert_eq!(
lure.n, 8,
"5 then 3, not the 12 banked after it, nor P2's 9"
);
}
#[test]
fn losses_add_up_and_ties_share_the_line() {
let tally = tally_of(&[([10, 10, 10, 0, 0, 0], None), ([4, 4, 10, 0, 0, 0], None)]);
let hit = tally
.awards(
3,
TeamMode::Solo,
&[false, false, true, false, false, false],
)
.into_iter()
.find(|a| a.kind == AwardKind::Hit)
.expect("a hit award");
assert_eq!((hit.who.clone(), hit.n), (vec![0, 1], 6));
let line = hit.line(&EN, |seat| format!("P{}", seat + 1));
assert_eq!(line, "Hardest hit: P1 & P2 lost 6");
}
#[test]
fn a_quiet_round_earns_no_lines() {
let tally = tally_of(&[([0; MAX_PLAYERS], None), ([0; MAX_PLAYERS], None)]);
assert!(
tally
.awards(2, TeamMode::Solo, &[true, true, false, false, false, false])
.is_empty()
);
}
#[test]
fn only_a_round_seen_whole_is_covered() {
let mut tally = RoundTally::default();
let mut board = generate_arena(7, 2, 12, 9);
let idle = [PlayerAction::None; MAX_PLAYERS];
for _ in 0..30 {
let before = Reading::of(&board);
board.tick(&idle);
tally.observe(before, &board, &idle);
}
assert!(tally.covers(&board));
board.tick(&idle);
let before = Reading::of(&board);
board.tick(&idle);
tally.observe(before, &board, &idle);
assert!(!tally.covers(&board), "a gap is not a whole round");
tally.seats[0].placed = 99;
let mut fresh = generate_arena(8, 2, 12, 9);
let before = Reading::of(&fresh);
fresh.tick(&idle);
tally.observe(before, &fresh, &idle);
assert!(tally.covers(&fresh), "a new round's first tick");
assert_eq!(tally.seats[0].placed, 0, "and nothing carried over");
}
#[test]
fn a_bot_round_tallies_what_the_board_did() {
let mut board = generate_arena(11, 4, 12, 9);
let mut tally = RoundTally::default();
let mut gained = [0u32; MAX_PLAYERS];
let mut attempted = [0u32; MAX_PLAYERS];
for _ in 0..3000 {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
for seat in 0..4u8 {
actions[usize::from(seat)] = bot_action(&board, seat, BotLevel::Hard);
if matches!(actions[usize::from(seat)], PlayerAction::Place { .. }) {
attempted[usize::from(seat)] += 1;
}
}
let before = Reading::of(&board);
board.tick(&actions);
for (seat, gained) in gained.iter_mut().enumerate() {
*gained += board.scores()[seat].saturating_sub(before.scores[seat]);
}
tally.observe(before, &board, &actions);
}
assert!(tally.covers(&board));
for seat in 0..4 {
let t = tally.seats[seat];
assert_eq!(
gained[seat] - t.lost,
board.scores()[seat],
"seat {seat}: what came in less what went out is the score"
);
assert!(t.placed <= attempted[seat], "seat {seat}: {t:?}");
}
assert!(
tally.seats.iter().any(|t| t.placed > 0),
"the bots planted arrows: {:?}",
tally.seats
);
}
}