use super::*;
mod puzzle;
mod tick;
mod versus;
pub(super) use puzzle::*;
pub(crate) use tick::*;
pub(super) use versus::*;
fn cursor_home(board: &Board, player: u8) -> (u8, u8) {
let (w, h) = (board.width(), board.height());
let spots = castle_spots(w, h);
let (cx, cy) = board
.castle_of(player)
.unwrap_or_else(|| spots[usize::from(player).min(spots.len() - 1)]);
let inset = |len: u8| 2.min(len.saturating_sub(1) / 2);
(
cx.clamp(inset(w), w - 1 - inset(w)),
cy.clamp(inset(h), h - 1 - inset(h)),
)
}
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct Play<'w> {
sim: ResMut<'w, Sim>,
pending: ResMut<'w, PendingActions>,
paused: ResMut<'w, Paused>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct BoardStage<'w, 's> {
commands: Commands<'w, 's>,
art: Res<'w, art::Art>,
cursors: Query<'w, 's, (&'static mut cursor::Cursor, &'static mut Transform)>,
}
impl BoardStage<'_, '_> {
fn lay_out(&mut self, board: &Board) {
board_render::spawn_static_board(&mut self.commands, board, &self.art);
board_render::spawn_waterline(&mut self.commands);
board_render::spawn_water_foam(&mut self.commands, &self.art);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct BoardSprites<'w, 's> {
statics: Query<'w, 's, Entity, With<board_render::BoardStatic>>,
posts: Query<'w, 's, Entity, With<board_render::SignpostSprite>>,
castles: Query<'w, 's, Entity, With<board_render::CastleSprite>>,
water: Query<'w, 's, Entity, With<board_render::Waterline>>,
foam: Query<'w, 's, Entity, With<board_render::WaterFoam>>,
logs: Query<'w, 's, Entity, With<board_render::TurnstileSprite>>,
crabs: Query<'w, 's, Entity, With<creatures::CrabSprite>>,
gulls: Query<'w, 's, Entity, With<creatures::GullSprite>>,
}
impl BoardSprites<'_, '_> {
pub(super) fn despawn_all(&self, commands: &mut Commands) {
for entity in self
.statics
.iter()
.chain(self.posts.iter())
.chain(self.castles.iter())
.chain(self.water.iter())
.chain(self.foam.iter())
.chain(self.logs.iter())
.chain(self.crabs.iter())
.chain(self.gulls.iter())
{
commands.entity(entity).despawn();
}
}
}
pub(super) fn despawn_board_sprites(mut commands: Commands, sprites: BoardSprites) {
sprites.despawn_all(&mut commands);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{BotLevel, Level, Replay};
use bevy::prelude::*;
#[derive(Component)]
struct Marker;
fn sim_app() -> App {
let mut app = App::new();
app.insert_resource(Sim(classic_arena(false, 2)));
app.init_resource::<PendingActions>();
app.init_resource::<Paused>();
app.init_resource::<net::Online>();
app.init_resource::<Recorder>();
app.init_resource::<Playback>();
app.init_resource::<replays::PlaybackSpeed>();
app.init_resource::<Controllers>();
app.init_resource::<bot_seats::BotSeats>();
app.init_resource::<awards::RoundTally>();
app.add_systems(Update, advance_sim);
app
}
#[test]
fn a_kept_round_is_filed_under_whoever_took_it() {
let mut board = Board::new(5, 5, 0);
board.set_tile(0, 0, crate::sim::TileKind::Castle(0));
board.set_tile(4, 4, crate::sim::TileKind::Castle(1));
let seats = Seats(2);
let online = net::Online::default();
let named = |scores: [u32; MAX_PLAYERS], settings: &settings::GameSettings| {
let mut board = board.clone();
for (seat, score) in scores.iter().enumerate() {
board.set_score(seat as u8, *score);
}
let names = SeatNames(settings.names.clone());
winner_name(&Sim(board), &seats, settings, &online, &names)
};
let plain = settings::GameSettings::default();
assert_eq!(named([0, 7, 0, 0, 0, 0], &plain).as_deref(), Some("P2"));
assert_eq!(named([7, 7, 0, 0, 0, 0], &plain), None);
assert_eq!(named([0, 0, 0, 0, 0, 0], &plain), None);
let mut settings = settings::GameSettings::default();
settings.names[1] = "Bo".to_string();
assert_eq!(named([0, 7, 0, 0, 0, 0], &settings).as_deref(), Some("Bo"));
}
fn armed(seats: u8, bots: u8) -> match_setup::MatchConfig {
match_setup::MatchConfig {
seats,
bots,
armed: true,
..match_setup::MatchConfig::default()
}
}
#[test]
fn the_daily_seats_one_human_against_three_hard_bots() {
let daily = match_setup::MatchConfig::daily();
assert!(daily.armed, "ready to launch as it is");
let table = bot_seats(&daily);
assert_eq!(table.0[0], SeatController::Local, "the player's chair");
assert_eq!(
&table.0[1..4],
&[SeatController::Ai(BotLevel::Hard); 3],
"three fierce rivals"
);
assert!(
table.0[4..].iter().all(|c| *c == SeatController::Local),
"and no more chairs"
);
let player = match_setup::MatchConfig::default();
assert_ne!(player.seats, daily.seats, "the player's own is untouched");
}
#[test]
fn a_seat_a_bot_drives_online_is_not_the_players() {
let session = |seat: u8, bot: bool, bots_here: Vec<u8>| {
let mut session = net::OnlineSession::new(
crate::transport::UdpTransport::host(0).expect("socket"),
crate::sim::Lockstep::new(seat, vec![0, 1, 2], crate::sim::DEFAULT_DELAY),
3,
crate::transport::MatchTerms::default(),
);
session.bot = bot;
session.bots_here = bots_here
.into_iter()
.map(|seat| (seat, 0, "Greedy".to_string()))
.collect();
session
};
let table = online_controllers(&session(1, false, vec![]));
assert_eq!(table.0[1], SeatController::Local);
assert_eq!(table.0[0], SeatController::Remote);
let table = online_controllers(&session(1, true, vec![]));
assert_eq!(
table.0[1],
SeatController::Bot,
"the bot's, not the person's"
);
assert_eq!(table.0[0], SeatController::Remote);
let table = online_controllers(&session(0, false, vec![2]));
assert_eq!(table.0[0], SeatController::Local);
assert_eq!(table.0[2], SeatController::Bot);
assert_eq!(table.0[1], SeatController::Remote);
}
#[test]
fn a_cursor_opens_on_the_board_whatever_its_size() {
for (w, h) in [(1, 1), (2, 1), (3, 3), (4, 5), (5, 5), (12, 9), (20, 13)] {
let board = Board::new(w, h, 0);
for player in 0..MAX_PLAYERS as u8 {
let (x, y) = cursor_home(&board, player);
assert!(
x < w && y < h,
"seat {player} opened at ({x},{y}) on a {w}x{h} board"
);
}
}
let big = Board::new(12, 9, 0);
assert_eq!(
cursor_home(&big, 0),
(2, 2),
"two in from the host's corner"
);
assert_eq!(cursor_home(&big, 1), (9, 6), "and from the far one");
}
#[test]
fn the_ai_takes_the_top_seats() {
let mut config = armed(4, 2);
config.controllers[3] = SeatController::Ai(BotLevel::Hard);
config.controllers[2] = SeatController::Ai(BotLevel::Easy);
assert_eq!(bot_seats(&config).levels(), {
let mut want = [None; MAX_PLAYERS];
want[2] = Some(BotLevel::Easy);
want[3] = Some(BotLevel::Hard);
want
});
let mut idle = armed(4, 3);
idle.armed = false;
assert_eq!(bot_seats(&idle), Controllers::default());
}
#[test]
fn a_bots_seat_is_saved_as_the_ai_that_stands_in_for_it() {
let mut config = armed(3, 2);
config.controllers[2] = SeatController::Bot;
config.controllers[1] = SeatController::Ai(BotLevel::Hard);
let table = bot_seats(&config);
assert_eq!(table.0[2], SeatController::Bot);
let saved = table.levels();
assert_eq!(saved[0], None, "the person stays a person");
assert_eq!(saved[1], Some(BotLevel::Hard));
assert_eq!(
saved[2],
Some(bot_seats::STAND_IN),
"the bot's seat is the stand-in AI's"
);
let resumed = Controllers::from_levels(saved);
assert_eq!(resumed.0[2], SeatController::Ai(bot_seats::STAND_IN));
}
fn recorded(seats: u8) -> Replay {
Replay::new(Level::from_board(
"Turf War",
3,
classic_arena(false, seats),
))
}
fn online_at(seats: u8) -> net::OnlineSession {
net::OnlineSession::new(
crate::transport::UdpTransport::host(0).expect("socket"),
crate::sim::Lockstep::new(0, vec![0, 1], crate::sim::DEFAULT_DELAY),
seats,
crate::transport::MatchTerms::default(),
)
}
#[test]
fn every_entry_path_knows_its_own_seat_count() {
let config = armed(3, 1);
let replay = recorded(4);
let board = replay.level.board();
assert_eq!(RoundOrigin::Replay(&replay).table(&config, &board, 0).1, 4);
let bare = Board::new(4, 4, 1);
assert_eq!(
RoundOrigin::Replay(&replay).table(&config, &bare, 0).1,
2,
"a castle-less recording still seats two"
);
let session = online_at(2);
assert_eq!(RoundOrigin::Online(&session).table(&config, &bare, 0).1, 2);
assert_eq!(
RoundOrigin::Configured(&config).table(&config, &bare, 0).1,
3
);
assert_eq!(RoundOrigin::Unconfigured.table(&config, &bare, 0).1, 2);
assert_eq!(RoundOrigin::Unconfigured.table(&config, &bare, 3).1, 3);
}
#[test]
fn the_daily_is_built_from_the_day_it_is_handed() {
let daily = match_setup::MatchConfig::daily();
let built = |day| {
RoundOrigin::Configured(&daily)
.board(Some(day), &Default::default(), false, 0)
.state_hash()
};
assert_eq!(built(20_000), built(20_000), "one beach for everybody");
assert_ne!(built(20_000), built(20_001), "and a fresh one tomorrow");
}
#[test]
fn the_arrows_dial_is_the_rule_the_round_plays() {
use crate::app::match_setup::MapChoice;
for map in [MapChoice::Classic, MapChoice::GenLarge] {
for posts in match_setup::POSTS_RANGE {
let config = match_setup::MatchConfig {
map,
posts,
..armed(2, 1)
};
let board =
RoundOrigin::Configured(&config).board(None, &Default::default(), false, 0);
assert_eq!(
board.signpost_rule(),
(posts, crate::sim::CapPolicy::Evict),
"{map:?}"
);
}
}
let board = RoundOrigin::Online(&online_at(2)).board(None, &Default::default(), false, 0);
assert_eq!(
board.signpost_rule().0,
crate::sim::MAX_SIGNPOSTS_PER_PLAYER as u8
);
}
#[test]
fn a_seat_count_never_leaves_the_table() {
let config = armed(3, 1);
let seated = MAX_PLAYERS as u8;
let bare = Board::new(4, 4, 1);
assert_eq!(
RoundOrigin::Online(&online_at(200))
.table(&config, &bare, 0)
.1,
seated
);
assert_eq!(
RoundOrigin::Online(&online_at(0))
.table(&config, &bare, 0)
.1,
2
);
assert_eq!(RoundOrigin::Unconfigured.table(&config, &bare, 9).1, seated);
for asked in [0, 1, 3, 6, 7, 200, 255] {
assert!((2..=seated).contains(&clamp_seats(asked)), "{asked}");
}
}
#[test]
fn only_a_round_played_from_its_first_tick_is_recorded() {
let config = armed(3, 1);
let replay = recorded(2);
let mut caught_up = online_at(2);
assert!(RoundOrigin::Configured(&config).recorded());
assert!(RoundOrigin::Unconfigured.recorded());
assert!(RoundOrigin::Online(&caught_up).recorded());
assert!(!RoundOrigin::Replay(&replay).recorded());
caught_up.catch_up.board = Some(Board::new(4, 4, 1));
assert!(!RoundOrigin::Online(&caught_up).recorded());
let resumed = suspend::Suspended {
seats: 3,
bots: [None; MAX_PLAYERS],
board: Board::new(4, 4, 1),
};
let origin = RoundOrigin::Resumed(Box::new(resumed));
assert!(!origin.recorded());
assert_eq!(
origin.table(&config, &Board::new(4, 4, 1), 0).1,
3,
"its own table"
);
}
#[test]
fn the_ai_fill_is_identical_on_every_peer() {
use crate::sim::{Direction, classic_arena_seeded};
let mut here = classic_arena_seeded(0x51DE, false, 4);
let mut there = classic_arena_seeded(0x51DE, false, 4);
let mut bots = Controllers::default();
bots.0[2] = SeatController::Ai(BotLevel::Normal);
bots.0[3] = SeatController::Ai(BotLevel::Hard);
let pressed = PlayerAction::Place {
x: 4,
y: 4,
dir: Direction::Up,
};
for frame in 0..600 {
let mut wire = [PlayerAction::None; MAX_PLAYERS];
if frame == 30 {
wire[1] = pressed;
}
let (mut mine, mut theirs) = (wire, wire);
fill_bot_actions(&here, &bots, &mut mine);
fill_bot_actions(&there, &bots, &mut theirs);
assert_eq!(mine, theirs, "peers disagreed on an AI seat at {frame}");
assert_eq!(mine[0], PlayerAction::None, "seat 1 has no AI to fill");
if frame == 30 {
assert_eq!(mine[1], pressed, "a human's press was overwritten");
}
here.tick(&mine);
there.tick(&theirs);
assert_eq!(
here.state_hash(),
there.state_hash(),
"the boards diverged at frame {frame}"
);
}
assert!(
here.scores()[2] > 0 || here.scores()[3] > 0,
"no AI seat banked anything, so the fill proved nothing"
);
}
#[test]
fn the_teardown_leaves_no_board_sprite_behind() {
use crate::app::{board_render, creatures};
let mut app = App::new();
let survivor = app.world_mut().spawn(Marker).id();
app.world_mut().spawn(board_render::BoardStatic);
app.world_mut().spawn(board_render::Waterline(0));
app.world_mut().spawn(board_render::WaterFoam(0));
app.world_mut().spawn(creatures::CrabSprite {
id: 1,
kind: crate::sim::CrabKind::Common,
shade: 0.0,
});
app.world_mut().spawn(creatures::GullSprite(1));
let before = app.world().entities().len();
assert!(before > 5, "the fixture did not spawn");
app.add_systems(Update, despawn_board_sprites);
app.update();
assert_eq!(
app.world_mut()
.query_filtered::<Entity, With<board_render::BoardStatic>>()
.iter(app.world())
.count(),
0,
"the static beach survived the teardown"
);
for left in [
app.world_mut()
.query_filtered::<Entity, With<board_render::Waterline>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<board_render::WaterFoam>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<creatures::CrabSprite>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<creatures::GullSprite>>()
.iter(app.world())
.count(),
] {
assert_eq!(left, 0, "a category of board sprite survived the teardown");
}
assert!(
app.world().get_entity(survivor).is_ok(),
"the teardown took something that was not a board sprite"
);
}
#[test]
fn an_abandoned_seat_turns_ai_on_the_frame_the_table_agreed_on() {
let bots = Controllers::from_levels([None, None, Some(BotLevel::Normal), None, None, None]);
let abandoned = [(1u8, 300u32)];
let at = |seat, frame| ai_holding(&bots, &abandoned, BotLevel::Hard, seat, frame);
assert_eq!(at(1, 299), None, "299 was still theirs to play");
assert_eq!(at(1, 300), Some(BotLevel::Hard), "and 300 is the AI's");
assert_eq!(at(1, 900), Some(BotLevel::Hard), "and every frame after");
assert_eq!(
at(2, 0),
Some(BotLevel::Normal),
"the launch AI, from the start"
);
assert_eq!(
at(2, 900),
Some(BotLevel::Normal),
"and all the way through"
);
assert_eq!(at(0, 900), None, "and a seat nobody left is left be");
}
#[test]
fn a_recorded_round_plays_back_to_the_board_it_left() {
let mut app = sim_app();
app.world_mut().resource_mut::<Controllers>().0[0] = SeatController::Ai(BotLevel::Normal);
app.world_mut().resource_mut::<Controllers>().0[1] = SeatController::Ai(BotLevel::Hard);
let start = app.world().resource::<Sim>().0.clone();
app.world_mut().resource_mut::<Recorder>().0 =
Some(Replay::new(Level::from_board("Turf War", 3, start)));
for _ in 0..40 {
app.update();
}
let board = &app.world().resource::<Sim>().0;
let replay = app
.world()
.resource::<Recorder>()
.0
.as_ref()
.expect("recording");
assert_eq!(board.ticks(), 40);
assert_eq!(replay.inputs.len(), 40, "one input a tick");
assert_eq!(
replay.playback().state_hash(),
board.state_hash(),
"and played back, the same beach"
);
}
#[test]
fn a_paused_round_does_not_advance() {
let mut app = sim_app();
app.world_mut().resource_mut::<Paused>().0 = true;
let before = app.world().resource::<Sim>().0.ticks();
app.update();
assert_eq!(
app.world().resource::<Sim>().0.ticks(),
before,
"a paused beach kept walking"
);
app.world_mut().resource_mut::<Paused>().0 = false;
app.update();
assert!(
app.world().resource::<Sim>().0.ticks() > before,
"and never started again"
);
}
#[test]
fn the_transport_speed_is_how_many_frames_a_replay_eats() {
for speed in [1u8, 2, 4] {
let mut app = sim_app();
let level = Level::from_board("Turf War", 3, classic_arena(false, 2));
let mut replay = Replay::new(level);
for _ in 0..40 {
replay.record([PlayerAction::None; MAX_PLAYERS]);
}
app.world_mut().insert_resource(Playback(Some((replay, 0))));
app.world_mut()
.insert_resource(replays::PlaybackSpeed(speed));
app.update();
let (_, idx) = app
.world()
.resource::<Playback>()
.0
.as_ref()
.expect("still watching");
assert_eq!(
*idx,
usize::from(speed),
"at {speed}x the recording moved {idx} frames in one tick"
);
}
}
#[test]
fn a_replay_stops_when_the_recording_runs_out() {
let mut app = sim_app();
let level = Level::from_board("Turf War", 3, classic_arena(false, 2));
let mut replay = Replay::new(level);
replay.record([PlayerAction::None; MAX_PLAYERS]);
app.world_mut().insert_resource(Playback(Some((replay, 0))));
app.world_mut().insert_resource(replays::PlaybackSpeed(4));
for _ in 0..5 {
app.update();
}
let (replay, idx) = app
.world()
.resource::<Playback>()
.0
.as_ref()
.expect("still watching");
assert_eq!(*idx, replay.inputs.len(), "it read past the last frame");
}
}