use crate::app::{Sim, layout};
use crate::sim::{
Crab, CrabKind, Direction, GullState, Handedness, IncomingGull, MAX_PLAYERS, PlayerId,
TideEvent, TileKind, castle_tier,
};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
#[derive(Message, Clone, Debug)]
pub enum SimEvent {
CrabBanked {
id: u32,
owner: PlayerId,
pos: Vec2,
keep: Vec2,
points: i32,
kind: CrabKind,
handed: Handedness,
},
CrabEaten { pos: Vec2 },
CrabSpawned { pos: Vec2 },
CastleRaided {
owner: PlayerId,
pos: Vec2,
lost: u32,
by: Option<PlayerId>,
},
GullIncoming {
tile: (u8, u8),
dir: Direction,
at: u64,
},
GullArrived,
GullTookOff,
GullLanded { pos: Vec2 },
GullShooed { pos: Vec2, castle: Vec2 },
GullFlewOff { pos: Vec2 },
SignpostPlaced { owner: PlayerId, pos: Vec2 },
SignpostRemoved { owner: PlayerId, pos: Vec2 },
SignpostEvicted {
owner: PlayerId,
pos: Vec2,
dir: Direction,
},
TierUp { owner: PlayerId },
TideEventFired {
event: TideEvent,
by: Option<PlayerId>,
},
WheelLocked { owner: PlayerId },
LureStarted { owner: PlayerId },
LureRefused { owner: PlayerId, running: bool },
SurgeStarted,
GoldenCalled { pos: Vec2 },
GoldenOut { pos: Vec2 },
RoundEnded,
}
#[derive(PartialEq, Debug)]
struct Origin {
width: u8,
height: u8,
seed: u64,
tiles: Vec<TileKind>,
crabs: Vec<(u32, u16, Direction, CrabKind)>,
gulls: Vec<(u32, u16)>,
}
impl Origin {
fn of(board: &crate::sim::Board) -> Option<Origin> {
(board.ticks() == 0).then(|| Origin {
width: board.width(),
height: board.height(),
seed: board.seed(),
tiles: board.tiles().map(|(_, _, kind)| kind).collect(),
crabs: board
.crabs()
.iter()
.map(|c| (c.id, c.tile, c.dir, c.kind))
.collect(),
gulls: board.gulls().iter().map(|g| (g.id, g.tile)).collect(),
})
}
}
#[derive(Default)]
pub struct BoardIdentity {
ticks: u64,
beach: (u8, u8, u64),
origin: Option<Origin>,
}
impl BoardIdentity {
fn of(board: &crate::sim::Board) -> BoardIdentity {
BoardIdentity {
ticks: board.ticks(),
beach: (board.width(), board.height(), board.seed()),
origin: Origin::of(board),
}
}
fn is_new(&self, next: &BoardIdentity) -> bool {
let Self {
ticks,
beach,
origin,
} = self;
next.ticks < *ticks || next.beach != *beach || (next.ticks == 0 && next.origin != *origin)
}
}
#[derive(Resource, Default)]
pub struct BoardGeneration {
identity: BoardIdentity,
generation: u64,
}
impl BoardGeneration {
pub fn get(&self) -> u64 {
self.generation
}
fn observe(&mut self, board: &crate::sim::Board) {
let next = BoardIdentity::of(board);
if self.identity.is_new(&next) {
self.generation += 1;
}
self.identity = next;
}
pub fn laid_out(&mut self, board: &crate::sim::Board) {
self.generation += 1;
self.identity = BoardIdentity::of(board);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct CurrentBoard<'w> {
pub sim: Res<'w, Sim>,
pub generation: Res<'w, BoardGeneration>,
}
impl CurrentBoard<'_> {
pub fn generation(&self) -> u64 {
self.generation.get()
}
}
#[derive(Default)]
pub struct BoardStamp(Option<u64>);
impl BoardStamp {
pub fn is_new(&mut self, current: &CurrentBoard) -> bool {
let generation = Some(current.generation());
let new = self.0 != generation;
self.0 = generation;
new
}
}
pub struct BoardMemory<K, V> {
filled_on: BoardStamp,
by_id: HashMap<K, V>,
}
impl<K, V> Default for BoardMemory<K, V> {
fn default() -> Self {
BoardMemory {
filled_on: BoardStamp::default(),
by_id: Default::default(),
}
}
}
impl<K, V> BoardMemory<K, V> {
pub fn of(&mut self, current: &CurrentBoard) -> &mut HashMap<K, V> {
let Self { filled_on, by_id } = self;
if filled_on.is_new(current) {
by_id.clear();
}
by_id
}
}
pub fn track_board(sim: Res<Sim>, mut generation: ResMut<BoardGeneration>) {
if !sim.is_changed() {
return;
}
generation.observe(&sim.0);
}
#[derive(Default)]
pub struct Watch {
identity: BoardIdentity,
scores: [u32; MAX_PLAYERS],
tiers: [u8; MAX_PLAYERS],
posts_by: [usize; MAX_PLAYERS],
posts_at: HashMap<(u8, u8), (PlayerId, Direction)>,
stolen: [[u32; MAX_PLAYERS]; MAX_PLAYERS],
tide_event: Option<(TideEvent, u64)>,
spun_by: Option<PlayerId>,
lure: Option<PlayerId>,
claw: bool,
surging: bool,
golden_call: Option<(u64, u8, u8)>,
over: bool,
crabs: HashMap<u32, Crab>,
in_flight: std::collections::HashSet<u32>,
gulls: HashMap<u32, SeenGull>,
incoming: Vec<IncomingGull>,
}
#[derive(Clone, Copy)]
struct SeenGull {
at: Vec2,
aloft: bool,
}
impl Watch {
fn fired_since(&self, prev: &Watch) -> Option<TideEvent> {
let (event, at) = self.tide_event?;
(prev.tide_event.map(|(_, then)| then) != Some(at)).then_some(event)
}
fn flying(&self) -> usize {
self.gulls.values().filter(|gull| gull.aloft).count()
}
}
impl Watch {
fn of(board: &crate::sim::Board) -> Watch {
let mut tiers = [0u8; MAX_PLAYERS];
for (tier, &score) in tiers.iter_mut().zip(board.scores().iter()) {
*tier = castle_tier(score);
}
Watch {
identity: BoardIdentity::of(board),
scores: *board.scores(),
tiers,
posts_by: std::array::from_fn(|p| board.signpost_count(p as PlayerId)),
posts_at: board
.tiles()
.filter_map(|(x, y, _)| {
let sp = board.signpost_at(x, y)?;
Some(((x, y), (sp.owner, sp.dir)))
})
.collect(),
stolen: std::array::from_fn(|by| {
std::array::from_fn(|victim| board.stolen(by as PlayerId, victim as PlayerId))
}),
tide_event: board.last_event(),
spun_by: board.event_spun_by(),
lure: board.lure().map(|(owner, _)| owner),
claw: board.in_claw_call(),
surging: board.in_surge(),
golden_call: board.golden_call(),
over: board.round_over(),
crabs: board
.crabs()
.iter()
.chain(board.sweeping().iter().map(|flight| &flight.crab))
.map(|c| (c.id, *c))
.collect(),
in_flight: board.sweeping().iter().map(|f| f.crab.id).collect(),
gulls: board
.gulls()
.iter()
.map(|g| {
let seen = SeenGull {
at: layout::creature_pos(board, g.tile, g.dir, g.progress),
aloft: matches!(g.state, GullState::Flying { .. }),
};
(g.id, seen)
})
.collect(),
incoming: board.incoming_gulls().to_vec(),
}
}
}
fn settle_banks(before: &[u32; MAX_PLAYERS], events: &mut [SimEvent]) -> [i64; MAX_PLAYERS] {
let mut banked = [0i64; MAX_PLAYERS];
for event in events.iter() {
if let SimEvent::CrabBanked { owner, points, .. } = event
&& *points > 0
{
banked[usize::from(*owner)] += i64::from(*points);
}
}
for event in events.iter_mut() {
if let SimEvent::CrabBanked { owner, points, .. } = event
&& *points < 0
{
let seat = usize::from(*owner);
let left = i64::from(before[seat]) + banked[seat];
let taken = i64::from(*points).max(-left);
*points = taken as i32;
banked[seat] += taken;
}
}
banked
}
fn crab_departure(board: &crate::sim::Board, prev: &Crab, claw: bool) -> SimEvent {
let pos = layout::creature_pos(board, prev.tile, prev.dir, prev.progress);
let priced = || prev.bank_points(board.crab_value(prev.kind), claw);
let swept = board
.swept_home(prev.id)
.and_then(|owner| Some((owner, board.castle_of(owner)?)));
let (owner, keep, points) = match swept {
Some((owner, (kx, ky))) => (
owner,
layout::tile_center(board, kx, ky),
board.swept_paid(prev.id).unwrap_or_else(priced),
),
None => match board.walked_home(prev.id) {
Some((owner, tile, paid)) => {
let (kx, ky) = board.coords_u8(tile);
(owner, layout::tile_center(board, kx, ky), paid)
}
None => return SimEvent::CrabEaten { pos },
},
};
SimEvent::CrabBanked {
id: prev.id,
owner,
pos,
keep,
points,
kind: prev.kind,
handed: prev.handed,
}
}
fn crab_events(board: &crate::sim::Board, watch: &Watch, events: &mut Vec<SimEvent>) {
for (id, prev) in watch.crabs.iter() {
let in_play = board.crabs().iter().any(|crab| crab.id == *id)
|| board.sweeping().iter().any(|flight| flight.crab.id == *id);
if in_play {
continue;
}
let mut departure = crab_departure(board, prev, watch.claw);
if watch.in_flight.contains(id)
&& let SimEvent::CrabBanked { pos, keep, .. } = &mut departure
{
*pos = *keep;
}
events.push(departure);
}
for crab in board.crabs() {
if watch.crabs.contains_key(&crab.id) {
continue;
}
let (x, y) = board.coords_u8(crab.tile);
if matches!(board.tile_at(x, y), TileKind::Spawner(_)) {
let pos = layout::creature_pos(board, crab.tile, crab.dir, crab.progress);
events.push(SimEvent::CrabSpawned { pos });
if crab.kind == CrabKind::Golden {
events.push(SimEvent::GoldenOut { pos });
}
}
}
}
fn gull_departures(
board: &crate::sim::Board,
prev: &Watch,
next: &Watch,
events: &mut Vec<SimEvent>,
) {
if next.fired_since(prev) == Some(TideEvent::CrabMania) {
return;
}
let castles: Vec<Vec2> = board
.tiles()
.filter(|(_, _, kind)| matches!(kind, TileKind::Castle(_)))
.map(|(x, y, _)| layout::tile_center(board, x, y))
.collect();
for (id, gull) in &prev.gulls {
if next.gulls.contains_key(id) {
continue;
}
let pos = gull.at;
let nearest = castles
.iter()
.copied()
.min_by(|a, b| a.distance_squared(pos).total_cmp(&b.distance_squared(pos)));
let Some(castle) = nearest else {
continue;
};
if castle.distance(pos) > layout::TILE * 1.5 {
events.push(SimEvent::GullFlewOff { pos });
} else if !board.rules().castle_raids {
events.push(SimEvent::GullShooed { pos, castle });
}
}
}
pub fn observe_sim(
sim: Res<Sim>,
generation: Res<BoardGeneration>,
mut watch: Local<Watch>,
mut read_on: Local<Option<u64>>,
mut events: MessageWriter<SimEvent>,
) {
if *read_on != Some(generation.get()) {
*read_on = Some(generation.get());
*watch = Watch::of(&sim.0);
return;
}
if !sim.is_changed() {
return;
}
for event in diff(&sim.0, &mut watch) {
events.write(event);
}
}
pub fn diff(board: &crate::sim::Board, watch: &mut Watch) -> Vec<SimEvent> {
let next = Watch::of(board);
let swapped = watch.identity.is_new(&next.identity);
let events = if swapped {
Vec::new()
} else {
changes(board, watch, &next)
};
*watch = next;
events
}
fn raider(prev: &Watch, next: &Watch, victim: usize) -> Option<PlayerId> {
(0..MAX_PLAYERS)
.map(|by| {
(
by,
next.stolen[by][victim].saturating_sub(prev.stolen[by][victim]),
)
})
.filter(|&(_, took)| took > 0)
.max_by_key(|&(by, took)| (took, std::cmp::Reverse(by)))
.map(|(by, _)| by as PlayerId)
}
fn changes(board: &crate::sim::Board, prev: &Watch, next: &Watch) -> Vec<SimEvent> {
let mut events = Vec::new();
crab_events(board, prev, &mut events);
let banked = settle_banks(&prev.scores, &mut events);
for (seat, (&now, &before)) in next.scores.iter().zip(prev.scores.iter()).enumerate() {
let expected = (i64::from(before) + banked[seat]).max(0);
if i64::from(now) >= expected {
continue;
}
if let Some((x, y)) = board.castle_of(seat as PlayerId) {
events.push(SimEvent::CastleRaided {
owner: seat as PlayerId,
pos: layout::tile_center(board, x, y),
lost: (expected - i64::from(now)) as u32,
by: raider(prev, next, seat),
});
}
}
for gull in &next.incoming {
if !prev.incoming.contains(gull) {
events.push(SimEvent::GullIncoming {
tile: board.coords_u8(gull.tile),
dir: gull.dir,
at: gull.at,
});
}
}
if next.gulls.keys().any(|id| !prev.gulls.contains_key(id)) {
events.push(SimEvent::GullArrived);
}
if next.flying() > prev.flying() {
events.push(SimEvent::GullTookOff);
}
for gull in board.gulls() {
let now_flying = matches!(gull.state, GullState::Flying { .. });
if prev.gulls.get(&gull.id).is_some_and(|seen| seen.aloft) && !now_flying {
events.push(SimEvent::GullLanded {
pos: layout::creature_pos(board, gull.tile, gull.dir, gull.progress),
});
}
}
gull_departures(board, prev, next, &mut events);
for (&(x, y), &(owner, _)) in &next.posts_at {
if prev.posts_at.get(&(x, y)).is_some_and(|&(o, _)| o == owner) {
continue;
}
events.push(SimEvent::SignpostPlaced {
owner,
pos: layout::tile_center(board, x, y),
});
}
for (&(x, y), &(owner, dir)) in &prev.posts_at {
if next.posts_at.get(&(x, y)).is_some_and(|&(o, _)| o == owner) {
continue;
}
let pos = layout::tile_center(board, x, y);
events.push(
if next.posts_by[owner as usize] < prev.posts_by[owner as usize] {
SimEvent::SignpostRemoved { owner, pos }
} else {
SimEvent::SignpostEvicted { owner, pos, dir }
},
);
}
for (seat, (&now, &before)) in next.tiers.iter().zip(prev.tiers.iter()).enumerate() {
if now > before {
events.push(SimEvent::TierUp {
owner: seat as PlayerId,
});
}
}
if let Some(event) = next.fired_since(prev) {
events.push(SimEvent::TideEventFired {
event,
by: next.spun_by,
});
}
let spun = events
.iter()
.any(|event| matches!(event, SimEvent::TideEventFired { .. }));
if board.events_enabled() && !spun {
let refused: Vec<PlayerId> = events
.iter()
.filter_map(|event| {
let SimEvent::CrabBanked {
id,
owner,
kind: CrabKind::Sparkling,
..
} = event
else {
return None;
};
board.swept_home(*id).is_none().then_some(*owner)
})
.collect();
for owner in refused {
events.push(SimEvent::WheelLocked { owner });
}
}
if let Some(owner) = next.lure
&& prev.lure.is_none()
{
events.push(SimEvent::LureStarted { owner });
}
let refused: Vec<(PlayerId, bool)> = events
.iter()
.filter_map(|event| {
let SimEvent::CrabBanked {
id,
owner,
kind: CrabKind::Molting,
..
} = event
else {
return None;
};
board.lure_refused(*id).map(|running| (*owner, running))
})
.collect();
for (owner, running) in refused {
events.push(SimEvent::LureRefused { owner, running });
}
if next.surging && !prev.surging {
events.push(SimEvent::SurgeStarted);
}
if let Some((_, x, y)) = next.golden_call
&& next.golden_call != prev.golden_call
{
events.push(SimEvent::GoldenCalled {
pos: layout::tile_center(board, x, y),
});
}
if next.over && !prev.over {
events.push(SimEvent::RoundEnded);
}
events
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{Board, CrabKind, Direction, Handedness};
fn synced(board: &Board) -> Watch {
let mut watch = Watch::default();
diff(board, &mut watch);
watch
}
#[test]
fn a_raid_names_the_seat_that_steered_it() {
for (arrow, every) in [(Some(0), 1), (Some(0), 40), (Some(1), 1), (None, 7)] {
let mut board = Board::new(7, 5, 0);
board.set_tile(4, 2, TileKind::Castle(1));
board.set_tile(6, 4, TileKind::Castle(0));
board.set_score(1, 30);
if let Some(seat) = arrow {
assert!(board.place_signpost(seat, 1, 2, Direction::Right));
}
board.spawn_gull(0, 2, Direction::Right);
let mut watch = synced(&board);
let mut raids = Vec::new();
for tick in 1..=200 {
board.tick_idle();
if tick % every == 0 {
raids.extend(diff(&board, &mut watch).into_iter().filter_map(|e| {
let SimEvent::CastleRaided { owner, by, .. } = e else {
return None;
};
Some((owner, by))
}));
}
}
assert_eq!(raids, [(1, arrow)], "{arrow:?} every {every}");
}
}
#[test]
fn a_swept_crab_is_remembered_past_the_longest_frame() {
use bevy::time::{Time, Virtual};
let max_delta = Time::<Virtual>::default().max_delta().as_secs_f64();
let steps = (max_delta * f64::from(crate::sim::TICKS_PER_SECOND)).ceil() as u64 + 1;
let fastest = crate::app::replays::PlaybackSpeed::STEPS
.iter()
.copied()
.max()
.map_or(1, u64::from);
let per_step = fastest.max(crate::app::net::CATCH_UP_FRAMES as u64);
assert!(
steps * per_step * 3 / 2 <= crate::sim::SWEEP_MEMORY,
"{steps} steps of {per_step} ticks, and half again, outrun the landing records"
);
}
#[test]
fn a_bank_under_a_claw_call_carries_what_it_did() {
let bank_on = |start: u32| {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(2));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Giant);
board.force_tide_event(TideEvent::RightClaws, 0);
board.set_score(2, start);
let mut watch = synced(&board);
for _ in 0..600 {
board.tick_idle();
let events = diff(&board, &mut watch);
let raided = events
.iter()
.any(|e| matches!(e, SimEvent::CastleRaided { .. }));
if let Some(SimEvent::CrabBanked { points, .. }) = events
.iter()
.find(|e| matches!(e, SimEvent::CrabBanked { .. }))
{
return (*points, raided, board.scores()[2]);
}
}
panic!("the crab never banked");
};
assert_eq!(bank_on(30), (-10, false, 20), "a left claw costs its worth");
assert_eq!(bank_on(3), (-3, false, 0), "no more than there was");
}
#[test]
fn a_sparkling_crab_the_wheel_ignored_is_told() {
let bank_on = |events_on: bool, locked: bool| {
let mut board = Board::new(6, 4, 7);
board.set_events_enabled(events_on);
board.rules_mut().timed_lock = true;
board.set_tile(3, 1, TileKind::Castle(2));
board.spawn_crab(
2,
1,
Direction::Right,
Handedness::Left,
CrabKind::Sparkling,
);
if locked {
board.force_tide_event(TideEvent::SlowDown, 0);
}
let mut watch = synced(&board);
for _ in 0..600 {
board.tick_idle();
let events = diff(&board, &mut watch);
if events
.iter()
.any(|e| matches!(e, SimEvent::CrabBanked { .. }))
{
return events
.into_iter()
.filter(|e| {
matches!(
e,
SimEvent::WheelLocked { .. } | SimEvent::TideEventFired { .. }
)
})
.collect::<Vec<_>>();
}
}
panic!("the crab never banked");
};
assert!(matches!(
bank_on(true, true)[..],
[SimEvent::WheelLocked { owner: 2 }]
));
assert!(matches!(
bank_on(true, false)[..],
[SimEvent::TideEventFired { by: Some(2), .. }]
));
assert!(bank_on(false, false).is_empty());
}
#[test]
fn a_molt_that_starts_no_lure_is_told() {
let bank_after = |before: Option<u32>| {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(2));
if let Some(before) = before {
board.force_lure(0);
for _ in 0..before {
board.tick_idle();
}
}
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Molting);
let mut watch = synced(&board);
for _ in 0..600 {
board.tick_idle();
let events = diff(&board, &mut watch);
if events
.iter()
.any(|e| matches!(e, SimEvent::CrabBanked { .. }))
{
return events
.into_iter()
.filter(|e| {
matches!(
e,
SimEvent::LureRefused { .. } | SimEvent::LureStarted { .. }
)
})
.collect::<Vec<_>>();
}
}
panic!("the crab never banked");
};
assert!(matches!(
bank_after(None)[..],
[SimEvent::LureStarted { owner: 2 }]
));
assert!(matches!(
bank_after(Some(0))[..],
[SimEvent::LureRefused {
owner: 2,
running: true
}]
));
assert!(matches!(
bank_after(Some(crate::sim::LURE_TICKS))[..],
[SimEvent::LureRefused {
owner: 2,
running: false
}]
));
}
#[test]
fn a_molt_refused_mid_lure_says_running_across_a_long_look() {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(2));
let mut probe = board.clone();
probe.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Molting);
let mut walk = 0;
while probe.crabs_banked() == 0 {
probe.tick_idle();
walk += 1;
}
board.force_lure(0);
while board.lure().is_some_and(|(_, left)| left > walk + 4) {
board.tick_idle();
}
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Molting);
let mut watch = synced(&board);
while board.crabs_banked() == 0 {
board.tick_idle();
}
assert!(board.lure().is_some(), "banked while the lure ran");
while board.lure().is_some() {
board.tick_idle();
}
let refused: Vec<_> = diff(&board, &mut watch)
.into_iter()
.filter(|e| matches!(e, SimEvent::LureRefused { .. }))
.collect();
assert!(
matches!(
refused[..],
[SimEvent::LureRefused {
owner: 2,
running: true
}]
),
"{refused:?}"
);
}
#[test]
fn a_lure_is_told_once_and_a_called_event_names_no_spinner() {
let mut board = Board::new(6, 4, 7);
let mut watch = synced(&board);
board.force_lure(1);
board.force_tide_event(TideEvent::SpeedUp, 0);
let events = diff(&board, &mut watch);
assert!(
events
.iter()
.any(|e| matches!(e, SimEvent::LureStarted { owner: 1 })),
"{events:?}"
);
assert!(events.iter().any(|e| matches!(
e,
SimEvent::TideEventFired {
event: TideEvent::SpeedUp,
by: None,
}
)));
board.tick_idle();
let again = diff(&board, &mut watch);
assert!(
!again
.iter()
.any(|e| matches!(e, SimEvent::LureStarted { .. })),
"a running lure is not a new one"
);
}
#[test]
fn bank_and_eaten_classification() {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(2));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Giant);
let mut watch = synced(&board);
let mut banked = None;
for _ in 0..600 {
board.tick_idle();
let events = diff(&board, &mut watch);
if let Some(SimEvent::CrabBanked { owner, points, .. }) = events
.iter()
.find(|e| matches!(e, SimEvent::CrabBanked { .. }))
{
banked = Some((*owner, *points));
break;
}
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::CrabEaten { .. })),
"a banking crab must never read as eaten"
);
}
assert_eq!(
banked,
Some((2, 10)),
"giant banks for its owner at value 10"
);
let mut board = Board::new(6, 4, 7);
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let mut watch = synced(&board);
board.spawn_gull(1, 2, Direction::Right);
let mut eaten = false;
for _ in 0..60 {
board.tick_idle();
let events = diff(&board, &mut watch);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::CrabBanked { .. })),
"an eaten crab must never read as banked"
);
if events
.iter()
.any(|e| matches!(e, SimEvent::CrabEaten { .. }))
{
eaten = true;
break;
}
}
assert!(eaten, "the gull never registered its meal");
}
#[test]
fn a_crab_eaten_in_front_of_a_castle_is_eaten() {
let mut board = Board::new(6, 4, 7);
board.rules_mut().castle_raids = false;
board.set_tile(3, 1, TileKind::Castle(2));
board.spawn_crab(
2,
1,
Direction::Right,
Handedness::Left,
CrabKind::Sparkling,
);
let mut watch = synced(&board);
board.spawn_gull(2, 1, Direction::Right);
board.tick_idle();
assert!(board.crabs().is_empty(), "the gull took it");
assert_eq!(board.scores()[2], 0, "and nobody banked it");
let events = diff(&board, &mut watch);
assert!(
events
.iter()
.any(|e| matches!(e, SimEvent::CrabEaten { .. })),
"{events:?}"
);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::CrabBanked { .. })),
"{events:?}"
);
}
#[test]
fn a_bank_inside_a_long_frame_is_a_bank() {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(2));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Giant);
let mut watch = synced(&board);
while !board.crabs().is_empty() {
board.tick_idle();
}
for _ in 0..10 {
board.tick_idle();
}
let events = diff(&board, &mut watch);
let banked: Vec<_> = events
.iter()
.filter_map(|e| {
if let SimEvent::CrabBanked {
owner,
keep,
points,
..
} = e
{
Some((*owner, *keep, *points))
} else {
None
}
})
.collect();
assert_eq!(
banked,
vec![(2, layout::tile_center(&board, 3, 1), 10)],
"{events:?}"
);
assert_eq!(board.scores()[2], 10);
}
#[test]
fn a_raid_free_castle_is_seen_turning_its_gull_away() {
for raids in [false, true] {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(0));
board.rules_mut().castle_raids = raids;
board.spawn_gull(2, 1, Direction::Right);
let mut watch = synced(&board);
let mut shooed = Vec::new();
for _ in 0..120 {
board.tick_idle();
let events = diff(&board, &mut watch);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::GullFlewOff { .. })),
"raids {raids}: through a castle is not flying off: {events:?}"
);
shooed.extend(
events
.into_iter()
.filter(|e| matches!(e, SimEvent::GullShooed { .. })),
);
if board.gulls().is_empty() {
break;
}
}
assert!(
board.gulls().is_empty(),
"raids {raids}: the gull never left"
);
if raids {
assert!(shooed.is_empty(), "a raid is not a shooing: {shooed:?}");
continue;
}
let castle = layout::tile_center(&board, 3, 1);
match shooed.as_slice() {
[SimEvent::GullShooed { pos, castle: at }] => {
assert_eq!(*at, castle, "the castle it walked into");
assert!(
pos.distance(castle) <= layout::TILE,
"last drawn beside it: {pos}"
);
}
other => panic!("one departure, told once: {other:?}"),
}
}
}
#[test]
fn a_gull_sent_off_by_its_flock_is_seen_flying_off() {
let mut board = Board::new(12, 9, 7);
board.set_tile(1, 1, TileKind::Castle(0));
board.spawn_gull(10, 7, Direction::Up);
for _ in 0..3 {
board.tick_idle();
}
let mut watch = synced(&board);
board.rules_mut().gull_period = 1;
board.rules_mut().gull_cap = 1;
board.rules_mut().gull_turnover = true;
board.tick_idle();
let events = diff(&board, &mut watch);
let flew: Vec<&SimEvent> = events
.iter()
.filter(|e| matches!(e, SimEvent::GullFlewOff { .. }))
.collect();
match flew.as_slice() {
[SimEvent::GullFlewOff { pos }] => {
let stood = layout::tile_center(&board, 10, 7);
assert!(pos.distance(stood) <= layout::TILE, "from {pos}");
}
other => panic!("one gull flew off, told once: {other:?}"),
}
assert!(
!events.iter().any(|e| matches!(
e,
SimEvent::CastleRaided { .. } | SimEvent::GullShooed { .. }
)),
"{events:?}"
);
}
#[test]
fn a_gull_that_takes_a_turned_over_place_is_heard_coming_and_arriving() {
let mut board = Board::new(12, 9, 7);
board.set_tile(1, 1, TileKind::Castle(0));
board.spawn_gull(10, 7, Direction::Up);
while board.ticks() < 50 {
board.tick_idle();
}
let mut watch = synced(&board);
board.rules_mut().gull_period = 50;
board.rules_mut().gull_cap = 1;
board.rules_mut().gull_turnover = true;
board.tick_idle();
let events = diff(&board, &mut watch);
assert!(board.gulls().is_empty(), "the old one is off");
assert!(
events
.iter()
.any(|e| matches!(e, SimEvent::GullFlewOff { .. })),
"{events:?}"
);
let due = 50 + u64::from(crate::sim::GULL_LEAD);
let [incoming] = board.incoming_gulls() else {
panic!("one sent for: {:?}", board.incoming_gulls());
};
let tile = board.coords_u8(incoming.tile);
assert!(
events.iter().any(|e| matches!(
e,
SimEvent::GullIncoming { tile: at_tile, at, .. } if *at == due && *at_tile == tile
)),
"the new gull was not heard coming: {events:?}"
);
while board.ticks() <= due {
assert!(
board.gulls().is_empty(),
"landed early, at {}",
board.ticks()
);
let events = diff(&board, &mut watch);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::GullArrived | SimEvent::GullIncoming { .. })),
"{events:?}"
);
board.tick_idle();
}
let events = diff(&board, &mut watch);
assert_eq!(board.gulls().len(), 1, "landed on the tick it was due");
assert_eq!(board.coords_u8(board.gulls()[0].tile), tile);
assert!(
events.iter().any(|e| matches!(e, SimEvent::GullArrived)),
"the new gull came in unannounced: {events:?}"
);
}
#[test]
fn a_golden_crab_is_announced_once_and_seen_coming_out() {
let mut board = Board::new(9, 7, 11);
board.set_tile(1, 1, TileKind::Castle(0));
board.set_tile(
0,
3,
TileKind::Spawner(crate::sim::Spawner {
dir: Direction::Right,
period: 9,
}),
);
board.rules_mut().golden_every = 600;
board.tick_idle();
let mut watch = synced(&board);
let mut called = Vec::new();
for _ in 0..600 {
board.tick_idle();
called.extend(diff(&board, &mut watch).into_iter().filter(|e| {
matches!(
e,
SimEvent::GoldenCalled { .. } | SimEvent::GoldenOut { .. }
)
}));
}
let hole = layout::tile_center(&board, 0, 3);
match called.as_slice() {
[
SimEvent::GoldenCalled { pos },
SimEvent::GoldenOut { pos: out },
] => {
assert_eq!(*pos, hole);
assert!(
out.distance(hole) < layout::TILE / 2.0,
"out at {out}, hole at {hole}"
);
}
other => panic!("one call and one arrival, told once each: {other:?}"),
}
}
#[test]
fn the_gulls_crab_mania_clears_were_not_shooed() {
let mut board = Board::new(8, 4, 7);
board.set_tile(7, 3, TileKind::Castle(0));
board.rules_mut().castle_raids = false;
board.spawn_gull(0, 0, Direction::Up);
board.spawn_gull(1, 0, Direction::Up);
for _ in 0..3 {
board.tick_idle();
}
let mut watch = synced(&board);
board.force_tide_event(TideEvent::CrabMania, 0);
board.tick_idle();
assert!(board.gulls().is_empty(), "the tide took them");
let events = diff(&board, &mut watch);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::GullShooed { .. })),
"washed away, not turned away: {events:?}"
);
}
#[test]
fn board_swap_is_silent() {
let mut board = Board::new(6, 4, 7);
board.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut watch = synced(&board);
for _ in 0..40 {
board.tick_idle();
diff(&board, &mut watch);
}
let fresh = Board::new(8, 6, 99);
assert!(
diff(&fresh, &mut watch).is_empty(),
"a board swap must not fire stale events"
);
}
#[test]
fn a_pasted_round_further_along_is_still_a_swap() {
let mut board = Board::new(6, 4, 7);
board.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut watch = synced(&board);
for _ in 0..40 {
board.tick_idle();
diff(&board, &mut watch);
}
let mut pasted = Board::new(8, 6, 99);
pasted.set_tile(1, 1, TileKind::Castle(0));
for _ in 0..300 {
pasted.tick_idle();
}
assert!(
pasted.ticks() > watch.identity.ticks,
"the pasted round is further along"
);
assert!(
diff(&pasted, &mut watch).is_empty(),
"a different beach is a swap whatever its clock says"
);
}
#[test]
fn the_board_count_moves_for_a_new_board_and_only_then() {
let mut generation = BoardGeneration::default();
let mut board = Board::new(6, 4, 7);
generation.observe(&board);
let first = generation.get();
for _ in 0..40 {
board.tick_idle();
generation.observe(&board);
}
assert_eq!(generation.get(), first, "the same board ticking on");
let mut pasted = Board::new(8, 6, 99);
for _ in 0..300 {
pasted.tick_idle();
}
generation.observe(&pasted);
let after_paste = generation.get();
assert_ne!(after_paste, first, "a pasted round mid-clock is new");
let rematch = Board::new(8, 6, 99);
generation.observe(&rematch);
let rematch_count = generation.get();
assert_ne!(rematch_count, after_paste, "a clock rolled back is new");
let mut setup = Board::new(8, 6, 99);
assert!(
setup.place_signpost(0, 2, 2, Direction::Up),
"a post goes in"
);
generation.observe(&setup);
assert_eq!(generation.get(), rematch_count, "a post in Setup is not");
let mut other = Board::new(8, 6, 99);
other.set_tile(3, 3, TileKind::Rock);
generation.observe(&other);
assert_ne!(generation.get(), rematch_count, "another unticked board is");
}
#[test]
fn a_signpost_traded_away_at_the_cap_says_so() {
let mut board = Board::new(6, 4, 5);
board.set_signpost_rule(3, crate::sim::CapPolicy::Evict);
for x in 0..3u8 {
assert!(board.place_signpost(0, x, 0, Direction::Up));
}
let mut watch = synced(&board);
assert!(board.place_signpost(0, 5, 3, Direction::Down));
let events = diff(&board, &mut watch);
let evicted: Vec<_> = events
.iter()
.filter_map(|e| match e {
SimEvent::SignpostEvicted { owner, dir, .. } => Some((*owner, *dir)),
SimEvent::CrabBanked { .. }
| SimEvent::CrabEaten { .. }
| SimEvent::CrabSpawned { .. }
| SimEvent::CastleRaided { .. }
| SimEvent::GullIncoming { .. }
| SimEvent::GullArrived
| SimEvent::GullTookOff
| SimEvent::GullLanded { .. }
| SimEvent::GullShooed { .. }
| SimEvent::GullFlewOff { .. }
| SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
| SimEvent::TierUp { .. }
| SimEvent::TideEventFired { .. }
| SimEvent::LureStarted { .. }
| SimEvent::LureRefused { .. }
| SimEvent::SurgeStarted
| SimEvent::GoldenCalled { .. }
| SimEvent::GoldenOut { .. }
| SimEvent::WheelLocked { .. }
| SimEvent::RoundEnded => None,
})
.collect();
assert_eq!(
evicted,
vec![(0, Direction::Up)],
"the traded post and the way it pointed: {events:?}"
);
assert!(
events.iter().any(|e| matches!(
e,
SimEvent::SignpostPlaced { pos, .. } if *pos == layout::tile_center(&board, 5, 3)
)),
"the fourth post went down at (5,3): {events:?}"
);
assert!(board.remove_signpost(0, 1, 0));
let events = diff(&board, &mut watch);
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::SignpostEvicted { .. })),
"removing your own post is not an eviction: {events:?}"
);
assert!(
events.iter().any(|e| matches!(
e,
SimEvent::SignpostRemoved { pos, .. } if *pos == layout::tile_center(&board, 1, 0)
)),
"but it is a removal, at its tile: {events:?}"
);
assert!(board.place_signpost(0, 2, 0, Direction::Left));
let events = diff(&board, &mut watch);
assert!(
!events.iter().any(|e| matches!(
e,
SimEvent::SignpostEvicted { .. }
| SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
)),
"re-pointing is not a post changing hands: {events:?}"
);
}
#[test]
fn one_seat_placing_while_another_pulls_sounds_for_both() {
let mut board = Board::new(6, 4, 11);
assert!(board.place_signpost(1, 4, 2, Direction::Left));
let mut watch = synced(&board);
assert!(board.remove_signpost(1, 4, 2));
assert!(board.place_signpost(0, 1, 1, Direction::Up));
let events = diff(&board, &mut watch);
assert!(
events.iter().any(|e| matches!(
e,
SimEvent::SignpostPlaced { pos, .. } if *pos == layout::tile_center(&board, 1, 1)
)),
"seat 0's placement, at its tile: {events:?}"
);
assert!(
events.iter().any(|e| matches!(
e,
SimEvent::SignpostRemoved { pos, .. } if *pos == layout::tile_center(&board, 4, 2)
)),
"seat 1's removal, at its tile: {events:?}"
);
}
#[test]
fn swap_between_unticked_boards_is_silent() {
let mut large = Board::new(9, 7, 3);
large.spawn_crab(8, 6, Direction::Right, Handedness::Left, CrabKind::Common);
large.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Giant);
let mut watch = synced(&large);
assert_eq!(watch.identity.ticks, 0);
let mut small = Board::new(6, 4, 3);
small.spawn_crab(2, 2, Direction::Left, Handedness::Right, CrabKind::Common);
assert!(
diff(&small, &mut watch).is_empty(),
"a swap between un-ticked boards must be silent"
);
let mut other = Board::new(6, 4, 3);
other.spawn_crab(4, 1, Direction::Up, Handedness::Left, CrabKind::Common);
assert!(
diff(&other, &mut watch).is_empty(),
"a same-sized un-ticked board with other crabs is a swap"
);
assert!(other.place_signpost(0, 1, 1, Direction::Down));
let events = diff(&other, &mut watch);
assert!(
events
.iter()
.any(|e| matches!(e, SimEvent::SignpostPlaced { .. })),
"setup-phase signposts must still fire, got {events:?}"
);
}
#[test]
fn a_crab_the_tide_banked_reads_as_banked_and_not_as_eaten() {
let mut board = Board::new(9, 7, 5);
board.set_tile(7, 5, TileKind::Castle(1));
board.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Common);
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let prev = board.crabs()[0];
assert!(
matches!(
crab_departure(&board, &prev, false),
SimEvent::CrabEaten { .. }
),
"off open sand, and nothing yet says otherwise"
);
board.force_tide_event(TideEvent::Monopoly, 1);
let SimEvent::CrabBanked {
id, owner, keep, ..
} = crab_departure(&board, &prev, false)
else {
panic!("the tide put it in seat 1's keep");
};
assert_eq!(id, prev.id);
assert_eq!(owner, 1, "and the seat that got it is the banker");
assert_eq!(
keep,
layout::tile_center(&board, 7, 5),
"flown to the keep it was banked into, not to the sand it left"
);
}
#[test]
fn a_flying_sweep_banks_at_the_keep_when_each_crab_lands() {
let mut board = Board::new(9, 7, 5);
board.rules_mut().sweep_flight = true;
board.set_tile(7, 5, TileKind::Castle(1));
for x in 1..5 {
board.spawn_crab(x, 1, Direction::Down, Handedness::Left, CrabKind::Common);
}
board.tick_idle();
let mut watch = synced(&board);
board.force_tide_event(TideEvent::Monopoly, 1);
let lifted = diff(&board, &mut watch);
assert!(
!lifted
.iter()
.any(|e| matches!(e, SimEvent::CrabEaten { .. } | SimEvent::CrabBanked { .. })),
"picked up is neither: {lifted:?}"
);
let keep = layout::tile_center(&board, 7, 5);
let mut landed = 0;
while !board.sweeping().is_empty() {
board.tick_idle();
for event in diff(&board, &mut watch) {
if let SimEvent::CrabBanked { owner: 1, pos, .. } = event
&& pos == keep
{
landed += 1;
}
}
}
assert_eq!(landed, 2, "both swept crabs banked at the keep");
}
#[test]
fn a_swept_crab_shows_what_it_paid_not_what_the_clock_says() {
let mut board = Board::new(9, 7, 5);
board.rules_mut().sweep_flight = true;
board.rules_mut().timed_lock = true;
board.rules_mut().sweep_keeps_worth = true;
board.set_tile(7, 5, TileKind::Castle(1));
board.set_score(1, 10);
for x in 1..5 {
board.spawn_crab(x, 1, Direction::Down, Handedness::Left, CrabKind::Common);
}
board.tick_idle();
let mut watch = synced(&board);
board.force_tide_event(TideEvent::Monopoly, 1);
let _ = diff(&board, &mut watch);
board.tick_idle();
let _ = diff(&board, &mut watch);
board.force_tide_event(TideEvent::RightClaws, 0);
let mut shown = 0;
while !board.sweeping().is_empty() {
board.tick_idle();
for event in diff(&board, &mut watch) {
if let SimEvent::CrabBanked {
owner: 1, points, ..
} = event
{
shown += points;
}
}
}
assert!(
board.in_claw_call(),
"Right Claws is running at the landing"
);
assert!(shown > 0, "the floats show a gain");
assert_eq!(10 + shown, board.scores()[1] as i32, "and the gain banked");
}
#[test]
fn a_swept_sparkling_crab_does_not_say_the_wheel_is_locked() {
let mut board = Board::new(9, 7, 5);
board.set_events_enabled(true);
board.rules_mut().sweep_flight = true;
board.set_tile(7, 5, TileKind::Castle(1));
board.spawn_crab(1, 1, Direction::Down, Handedness::Left, CrabKind::Sparkling);
board.spawn_crab(2, 1, Direction::Down, Handedness::Left, CrabKind::Common);
board.tick_idle();
let mut watch = synced(&board);
board.force_tide_event(TideEvent::Monopoly, 1);
let _ = diff(&board, &mut watch);
assert_eq!(
board.sweeping().len(),
1,
"the sparkling crab is in the air"
);
let mut landed = false;
while !board.sweeping().is_empty() {
board.tick_idle();
let events = diff(&board, &mut watch);
landed |= events.iter().any(|e| {
matches!(
e,
SimEvent::CrabBanked {
kind: CrabKind::Sparkling,
..
}
)
});
assert!(
!events
.iter()
.any(|e| matches!(e, SimEvent::WheelLocked { .. })),
"{events:?}"
);
}
assert!(landed, "the sparkling crab banked");
}
#[test]
fn a_sweep_puts_no_deaths_on_the_stream() {
let mut board = Board::new(9, 7, 11);
board.set_tile(7, 5, TileKind::Castle(0));
for x in 1..5u8 {
board.spawn_crab(x, 1, Direction::Right, Handedness::Left, CrabKind::Common);
}
let mut watch = Watch::default();
let _ = diff(&board, &mut watch);
board.force_tide_event(TideEvent::Monopoly, 0);
board.tick_idle();
let events = diff(&board, &mut watch);
let banked = events
.iter()
.filter(|e| matches!(e, SimEvent::CrabBanked { owner: 0, .. }))
.count();
let eaten = events
.iter()
.filter(|e| matches!(e, SimEvent::CrabEaten { .. }))
.count();
assert_eq!(banked, 2, "half of four, every one of them a bank");
assert_eq!(eaten, 0, "and the gulls got nothing: {events:?}");
}
#[test]
fn crab_departure_survives_a_smaller_board() {
let mut large = Board::new(9, 7, 3);
large.spawn_crab(8, 6, Direction::Right, Handedness::Left, CrabKind::Common);
let prev = large.crabs()[0];
let small = Board::new(6, 4, 3);
assert!(matches!(
crab_departure(&small, &prev, false),
SimEvent::CrabEaten { .. }
));
}
#[test]
fn a_crab_banking_across_the_seam_is_a_bank() {
let mut board = Board::new(6, 4, 7);
board.set_wrap(true);
board.set_tile(0, 1, TileKind::Castle(1));
board.spawn_crab(4, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut watch = Watch::default();
diff(&board, &mut watch);
let mut seen = Vec::new();
for _ in 0..600 {
board.tick_idle();
seen.extend(diff(&board, &mut watch));
if board.crabs().is_empty() {
break;
}
}
assert!(board.crabs().is_empty(), "the crab went somewhere");
assert!(
seen.iter()
.any(|e| matches!(e, SimEvent::CrabBanked { owner: 1, .. })),
"into the castle over the seam: {seen:?}"
);
assert!(
!seen.iter().any(|e| matches!(e, SimEvent::CrabEaten { .. })),
"and not into a gull: {seen:?}"
);
}
#[test]
fn a_round_laid_out_again_further_along_is_not_news() {
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(1));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut further = board.clone();
while !further.crabs().is_empty() {
further.tick_idle();
}
assert!(further.scores()[1] > 0, "the crab banked in the gap");
let mut watch = Watch::default();
diff(&board, &mut watch);
let gap = diff(&further, &mut watch);
assert!(!gap.is_empty(), "read as the same board, the gap is news");
let mut app = App::new();
app.add_message::<SimEvent>();
app.insert_resource(Sim(board));
app.init_resource::<BoardGeneration>();
app.add_systems(Update, (track_board, observe_sim).chain());
app.update();
let before = app.world().resource::<BoardGeneration>().get();
app.world_mut()
.resource_mut::<BoardGeneration>()
.laid_out(&further);
app.insert_resource(Sim(further));
app.update();
let after = app.world().resource::<BoardGeneration>().get();
assert_ne!(after, before, "a board laid out is a new board");
let mut messages = app.world_mut().resource_mut::<Messages<SimEvent>>();
let events: Vec<_> = messages.drain().collect();
assert!(events.is_empty(), "and its past is not news: {events:?}");
}
#[test]
fn observe_sim_runs_headless_in_an_app() {
let mut app = App::new();
app.add_message::<SimEvent>();
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(1));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Common);
app.insert_resource(Sim(board));
app.init_resource::<BoardGeneration>();
app.add_systems(Update, (track_board, observe_sim).chain());
app.update();
let mut banked = false;
for _ in 0..600 {
app.world_mut().resource_mut::<Sim>().0.tick_idle();
app.update();
let mut messages = app.world_mut().resource_mut::<Messages<SimEvent>>();
if messages
.drain()
.any(|e| matches!(e, SimEvent::CrabBanked { owner: 1, .. }))
{
banked = true;
break;
}
}
assert!(banked, "the system never reported the bank through the App");
}
#[test]
fn an_untouched_sim_is_not_re_read() {
let mut app = App::new();
app.add_message::<SimEvent>();
let mut board = Board::new(6, 4, 7);
board.set_tile(3, 1, TileKind::Castle(1));
board.spawn_crab(2, 1, Direction::Right, Handedness::Left, CrabKind::Common);
app.insert_resource(Sim(board));
app.init_resource::<BoardGeneration>();
app.add_systems(Update, (track_board, observe_sim).chain());
app.update();
for _ in 0..3 {
app.update();
let mut messages = app.world_mut().resource_mut::<Messages<SimEvent>>();
assert_eq!(
messages.drain().count(),
0,
"a still board reported something happening"
);
}
let mut banked = false;
for _ in 0..600 {
app.world_mut().resource_mut::<Sim>().0.tick_idle();
app.update();
let mut messages = app.world_mut().resource_mut::<Messages<SimEvent>>();
if messages
.drain()
.any(|e| matches!(e, SimEvent::CrabBanked { owner: 1, .. }))
{
banked = true;
break;
}
}
assert!(banked, "the bank went unreported after the idle frames");
}
#[test]
fn tier_up_names_its_owner() {
let mut board = Board::new(6, 4, 7);
let mut watch = synced(&board);
board.set_score(3, 10); board.tick_idle();
let events = diff(&board, &mut watch);
assert!(
events
.iter()
.any(|e| matches!(e, SimEvent::TierUp { owner: 3 })),
"expected TierUp for seat 3, got {events:?}"
);
}
}