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)>,
generation: ResMut<'w, sim_events::BoardGeneration>,
}
impl BoardStage<'_, '_> {
fn lay_out(&mut self, board: &Board) {
self.generation.laid_out(board);
board_render::spawn_static_board(&mut self.commands, board, &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>>,
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>>,
arrivals: Query<'w, 's, Entity, With<creatures::GullArrival>>,
sweeps: Query<'w, 's, Entity, With<creatures::SweepSprite>>,
particles: Query<'w, 's, Entity, With<effects::Particle>>,
hops: Query<'w, 's, Entity, With<effects::Hop>>,
}
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.logs.iter())
.chain(self.crabs.iter())
.chain(self.gulls.iter())
.chain(self.arrivals.iter())
.chain(self.sweeps.iter())
.chain(self.particles.iter())
.chain(self.hops.iter())
{
commands.entity(entity).despawn();
}
}
}
pub(super) fn despawn_board_sprites(mut commands: Commands, sprites: BoardSprites) {
sprites.despawn_all(&mut commands);
}
#[cfg(test)]
pub(crate) fn online_controllers_for_tests(session: &net::OnlineSession) -> Controllers {
online_controllers(session)
}
#[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_couch_match_opens_on_the_lineup() {
use super::versus::RoundOrigin;
let config = crate::app::match_setup::MatchConfig::default();
assert!(RoundOrigin::Configured(&config).opens_on_lineup(true));
assert!(
!RoundOrigin::Configured(&config).opens_on_lineup(false),
"the daily"
);
assert!(!RoundOrigin::Unconfigured.opens_on_lineup(true));
}
#[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());
let mode = teams::in_play(settings.team_mode, &online, None, seats.0);
winner_name(&Sim(board), &seats, mode, &names, settings.tr())
};
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"));
let four = Seats(4);
let mut board = Board::new(5, 5, 0);
for seat in 0..4 {
board.set_tile(seat, 0, crate::sim::TileKind::Castle(seat));
board.set_score(seat, [5, 5, 4, 5][usize::from(seat)]);
}
let pairs = settings::GameSettings {
team_mode: teams::TeamMode::Pairs,
..settings::GameSettings::default()
};
let names = SeatNames(pairs.names.clone());
let live_mode = teams::in_play(pairs.team_mode, &online, None, four.0);
let live = winner_name(&Sim(board.clone()), &four, live_mode, &names, pairs.tr());
assert_eq!(live.as_deref(), Some("P1+P2"));
let mut replay = Replay::new(Level::from_board("T", 3, board.clone()));
replay.teams = teams::TeamMode::Pairs.index() as u8;
let watched_mode = teams::in_play(plain.team_mode, &online, Some(&replay), four.0);
let watched = winner_name(&Sim(board), &four, watched_mode, &names, plain.tr());
assert_eq!(watched.as_deref(), Some("P1+P2"));
}
fn armed(seats: u8, bots: u8) -> match_setup::MatchConfig {
match_setup::MatchConfig {
armed: true,
..match_setup::MatchConfig::with_ai(seats, bots)
}
}
#[test]
fn a_launched_round_is_played_on_the_previewed_seed() {
use match_setup::{Card, MapChoice};
let beaches = match_setup::CustomBeaches::default();
let setup = armed(4, 2);
let card = Card::Map(MapChoice::GenLarge);
let shown = match_setup::card_config(&setup, card, &beaches).expect("a card");
let pictured = match_setup::preview_board(&shown, &beaches, 31337);
let mut launched = setup.clone();
match_setup::pick_card(&mut launched, card, &beaches);
match_setup::seat_the_match(&mut launched, &[], 31337, &beaches, 0);
let mut board = RoundOrigin::Configured(&launched).board(None, &beaches, false, 0);
assert_eq!(board.seed(), 31337);
for _ in 0..match_setup::PREVIEW_TICKS {
board.tick_idle();
}
assert_eq!(board.state_hash(), pictured.state_hash());
let fresh = match_setup::MatchConfig {
seed: None,
..launched
};
let board = RoundOrigin::Configured(&fresh).board(None, &beaches, false, 0);
assert_ne!(board.seed(), 31337);
}
#[test]
fn the_ai_sits_where_step_three_put_it() {
let mut config = armed(4, 0);
config.controllers[1] = SeatController::Ai(BotLevel::Easy);
let table = bot_seats(&config);
assert_eq!(table.0[1], SeatController::Ai(BotLevel::Easy));
assert_eq!(table.0[3], SeatController::Local);
assert_eq!(config.human_seats(), vec![0, 2, 3]);
}
#[test]
fn a_couch_round_on_the_open_ocean_wraps() {
let beaches = match_setup::CustomBeaches::default();
for (map, wraps) in [
(match_setup::MapChoice::GenOcean, true),
(match_setup::MapChoice::GenLarge, false),
(match_setup::MapChoice::Classic, false),
] {
let config = match_setup::MatchConfig { map, ..armed(2, 0) };
let board = RoundOrigin::Configured(&config).board(None, &beaches, false, 0);
assert_eq!(board.wrap(), wraps, "{map:?}");
}
}
#[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.then(|| "Greedy (Ana)".to_string());
session.bots_here = bots_here
.into_iter()
.map(|seat| net::HostedBot {
seat,
id: 0,
name: "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_table_too_big_for_its_beach_still_gets_a_castle_each() {
use crate::app::match_setup::MapChoice;
for map in [
MapChoice::Classic,
MapChoice::GenSmall,
MapChoice::GenClassic,
] {
for seats in [5, 6] {
let config = match_setup::MatchConfig {
map,
..armed(seats, 0)
};
let board =
RoundOrigin::Configured(&config).board(None, &Default::default(), false, 0);
let mut owners: Vec<u8> = board.castle_owners().collect();
owners.sort_unstable();
owners.dedup();
assert_eq!(owners.len(), usize::from(seats), "{seats} on {map:?}");
}
}
}
#[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(creatures::CrabSprite {
id: 1,
kind: crate::sim::CrabKind::Common,
shade: 0.0,
});
app.world_mut().spawn(creatures::GullSprite(1));
app.world_mut()
.spawn(creatures::SweepSprite::new(2, Vec2::ZERO));
app.world_mut().spawn(effects::Particle::default());
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<creatures::CrabSprite>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<creatures::GullSprite>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<creatures::SweepSprite>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<effects::Particle>>()
.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");
}
fn finished_round(dir: &std::path::Path, online: net::Online) -> App {
round_over(dir, online, true)
}
fn round_over(dir: &std::path::Path, online: net::Online, recorded: bool) -> App {
round_over_with(dir, online, recorded, |_| {})
}
fn round_over_with(
dir: &std::path::Path,
online: net::Online,
recorded: bool,
prepare: impl FnOnce(&mut App),
) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::Versus);
app.insert_state(VersusPhase::Running);
let mut board = classic_arena(false, 2);
board.rules_mut().round_length = Some(0);
board.set_score(1, 3);
let mut replay = Replay::new(Level::from_board("Versus", 3, board.clone()));
replay.ai[1] = Some(BotLevel::Hard);
app.insert_resource(Sim(board));
app.insert_resource(Recorder(recorded.then_some(replay)));
app.insert_resource(Seats(2));
app.init_resource::<Controllers>();
let settings = settings::GameSettings::default();
app.insert_resource(SeatNames(settings.names.clone()));
app.insert_resource(settings);
app.insert_resource(online);
app.init_resource::<Playback>();
app.init_resource::<Daily>();
app.init_resource::<ReelThread>();
app.init_resource::<Highlight>();
app.init_resource::<replays::RoundToKeep>();
app.insert_resource(replays::Library {
dir: dir.to_path_buf(),
..replays::Library::default()
});
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<keycaps::KeyCaps>();
app.init_resource::<cursor::LiveSeatKeys>();
app.init_resource::<pause::PauseMenu>();
app.init_resource::<spectators::SpectatorChat>();
app.init_resource::<PendingActions>();
app.init_resource::<rematch::LocalTable>();
app.init_resource::<match_setup::MatchConfig>();
app.add_systems(
Update,
(
check_versus_over.run_if(in_state(VersusPhase::Running)),
replays::keep_input.run_if(in_state(VersusPhase::Over)),
replays::keep_input.run_if(in_state(Screen::Interlude)),
),
);
app.add_systems(OnExit(Screen::Versus), end_versus);
app.add_systems(OnExit(Screen::Interlude), replays::let_the_round_go);
prepare(&mut app);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<VersusPhase>>().get(),
VersusPhase::Over,
"the tide is in"
);
app
}
fn press_keep(app: &mut App) {
let key = app
.world()
.resource::<keycaps::KeyCaps>()
.key_for(replays::KEEP_CAP)
.expect("a key");
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.clear();
keys.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
}
fn scratch_shelf(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pinch-shelf-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn a_finished_round_is_shelved_only_when_kept() {
let dir = scratch_shelf("kept");
let mut app = finished_round(&dir, net::Online::default());
for _ in 0..3 {
app.update();
}
assert!(replays::shelf_in(&dir).is_empty(), "not by itself");
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Offered
);
press_keep(&mut app);
let shelf = replays::shelf_in(&dir);
assert_eq!(shelf.len(), 1, "kept");
assert_eq!(shelf[0].winner, "P2", "filed under who took it");
let kept = Replay::parse(&std::fs::read_to_string(&shelf[0].path).expect("read"))
.expect("a round");
assert_eq!(kept.ai[1], Some(BotLevel::Hard), "with its AI's level");
assert_eq!(kept.playback().scores()[1], 3);
let card = *app.world().resource::<Sim>().0.scores();
assert_eq!(kept.scores, Some(card), "the final board's scores");
let mode = teams::TeamMode::Solo;
assert_eq!(shelf[0].points, Some(teams::top_total(&card, 2, mode)));
assert_eq!(shelf[0].points, Some(3));
press_keep(&mut app);
assert_eq!(replays::shelf_in(&dir).len(), 1, "and only once");
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Kept
);
}
#[test]
fn a_name_ending_in_a_space_is_kept_under_the_name_read_back() {
let dir = scratch_shelf("spaced");
let mut app = round_over_with(&dir, net::Online::default(), true, |app| {
let names = ["Bo!", "Bo ", "", "", "", ""].map(String::from);
app.world_mut()
.resource_mut::<settings::GameSettings>()
.names = names;
app.world_mut().resource_mut::<Controllers>().0[1] = SeatController::Ai(BotLevel::Hard);
app.init_resource::<SeatKinds>();
app.add_systems(Startup, resolve_seat_names);
});
press_keep(&mut app);
let shelf = replays::shelf_in(&dir);
assert_eq!(shelf.len(), 1);
assert_eq!(shelf[0].winner, "Bo", "filed as the recording names it");
assert_eq!(
shelf[0].won_by,
Some(replays::WonBy::Ai(Some(BotLevel::Hard))),
"and tagged as the AI that took it"
);
}
#[test]
fn leaving_the_card_lets_an_unkept_round_go() {
for (way, to) in [("menu", Screen::Menu), ("rematch", Screen::Versus)] {
let dir = scratch_shelf(way);
let mut app = finished_round(&dir, net::Online::default());
app.world_mut().resource_mut::<NextState<Screen>>().set(to);
app.update();
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Nothing,
"{way}: let go"
);
press_keep(&mut app);
assert!(replays::shelf_in(&dir).is_empty(), "{way}: nothing shelved");
}
}
#[test]
fn the_interlude_holds_the_offer_until_it_is_left() {
for (way, after) in [("next round", Screen::Versus), ("menu", Screen::Menu)] {
let dir = scratch_shelf(&format!("interlude-{}", after == Screen::Menu));
let mut app = finished_round(&dir, net::Online::default());
let go = |app: &mut App, to: Screen| {
app.world_mut().resource_mut::<NextState<Screen>>().set(to);
app.update();
};
go(&mut app, Screen::Interlude);
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Offered,
"{way}: still on offer in the interlude"
);
go(&mut app, after);
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Nothing,
"{way}: let go as the interlude is left"
);
press_keep(&mut app);
assert!(replays::shelf_in(&dir).is_empty(), "{way}: nothing shelved");
}
}
#[test]
fn a_joiner_walked_on_keeps_the_round_from_the_interlude() {
use crate::app::tournament::{SeriesLength, Tournament};
use crate::sim::{DEFAULT_DELAY, Lockstep};
use crate::transport::{MatchTerms, UdpTransport};
let dir = scratch_shelf("walked-on");
let joiner = net::Online(Some(net::OnlineSession::new(
UdpTransport::join(("127.0.0.1", 47998)).expect("socket"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
)));
let mut app = round_over_with(&dir, joiner, true, |app| {
app.insert_resource(Tournament::start(SeriesLength::BestOfThree));
app.init_resource::<lobby::Homecoming>();
app.init_resource::<Time>();
app.init_resource::<crate::app::next_map::NextMap>();
app.init_resource::<match_setup::MatchMenu>();
app.add_systems(
Update,
play_input::versus_over_input.run_if(conditions::versus_over),
);
});
let finished = match app.world().resource::<replays::RoundToKeep>() {
replays::RoundToKeep::Offered(round) => round.text.clone(),
other @ (replays::RoundToKeep::Nothing | replays::RoundToKeep::Kept) => {
panic!("the round is offered: {other:?}")
}
};
app.world_mut()
.resource_mut::<net::Online>()
.0
.as_mut()
.expect("at the table")
.next_round = true;
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Interlude,
"walked on by the call"
);
press_keep(&mut app);
press_keep(&mut app);
let shelf = replays::shelf_in(&dir);
assert_eq!(shelf.len(), 1, "kept, and only once");
let kept = std::fs::read_to_string(&shelf[0].path).expect("read");
assert_eq!(kept, finished, "the round just played");
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Kept
);
app.world_mut()
.resource_mut::<NextState<Screen>>()
.set(Screen::Versus);
app.update();
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Nothing
);
assert_eq!(replays::shelf_in(&dir).len(), 1);
}
#[test]
fn host_and_joiner_each_keep_their_own() {
use crate::sim::{DEFAULT_DELAY, Lockstep};
use crate::transport::{MatchTerms, UdpTransport};
let table = |seat: u8| {
let transport = match seat {
0 => UdpTransport::host(0).expect("socket"),
_ => UdpTransport::join(("127.0.0.1", 47999)).expect("socket"),
};
net::Online(Some(net::OnlineSession::new(
transport,
Lockstep::new(seat, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
)))
};
let (host_dir, joiner_dir) = (scratch_shelf("host"), scratch_shelf("joiner"));
let mut host = finished_round(&host_dir, table(0));
let mut joiner = finished_round(&joiner_dir, table(1));
assert!(
host.world()
.resource::<net::Online>()
.0
.as_ref()
.expect("on")
.is_host()
);
for app in [&host, &joiner] {
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Offered,
"both are offered it"
);
}
press_keep(&mut host);
joiner.update();
assert_eq!(replays::shelf_in(&host_dir).len(), 1, "the host kept it");
assert!(
replays::shelf_in(&joiner_dir).is_empty(),
"the joiner did not"
);
press_keep(&mut joiner);
assert_eq!(replays::shelf_in(&joiner_dir).len(), 1, "until it does");
assert_eq!(replays::shelf_in(&host_dir).len(), 1);
}
#[test]
fn a_round_with_no_recording_offers_nothing() {
let dir = scratch_shelf("none");
let mut app = round_over(&dir, net::Online::default(), false);
assert_eq!(
app.world().resource::<replays::RoundToKeep>().offer(),
replays::KeepOffer::Nothing
);
press_keep(&mut app);
assert!(replays::shelf_in(&dir).is_empty());
}
#[test]
fn a_couch_round_names_the_people_picked_into_each_seat() {
let mut app = App::new();
let mut saved = settings::GameSettings::default();
saved.names[1] = "Bo".into();
saved.names[2] = "Cleo".into();
app.insert_resource(saved);
let mut config = match_setup::MatchConfig::default();
config.people.0.swap(1, 2);
app.insert_resource(config);
app.init_resource::<Daily>();
app.init_resource::<net::Online>();
app.init_resource::<Playback>();
app.init_resource::<SeatNames>();
app.init_resource::<SeatKinds>();
app.insert_resource(Seats(4));
app.init_resource::<Controllers>();
app.insert_resource(Recorder(None));
app.add_systems(Update, resolve_seat_names);
app.update();
assert_eq!(app.world().resource::<SeatNames>().0[1..3], ["Cleo", "Bo"]);
app.world_mut().resource_mut::<Daily>().active = true;
app.update();
assert_eq!(app.world().resource::<SeatNames>().0[1..3], ["Bo", "Cleo"]);
}
#[test]
fn a_recorded_round_keeps_each_ai_level() {
let mut app = App::new();
app.init_resource::<settings::GameSettings>();
app.init_resource::<match_setup::MatchConfig>();
app.init_resource::<Daily>();
app.init_resource::<net::Online>();
app.init_resource::<Playback>();
app.init_resource::<SeatNames>();
app.init_resource::<SeatKinds>();
app.insert_resource(Seats(4));
app.insert_resource(Controllers([
SeatController::Local,
SeatController::Ai(BotLevel::Hard),
SeatController::Bot,
SeatController::Ai(BotLevel::Easy),
SeatController::Local,
SeatController::Local,
]));
app.insert_resource(Recorder(Some(Replay::new(Level::from_board(
"Turf War",
3,
classic_arena(false, 4),
)))));
app.add_systems(Update, resolve_seat_names);
app.update();
let replay = app.world().resource::<Recorder>().0.clone().expect("kept");
assert_eq!(
replay.ai,
[
None,
Some(BotLevel::Hard),
None,
Some(BotLevel::Easy),
None,
None
]
);
assert_eq!(replay.kinds[2], crate::sim::SeatKind::Bot);
let back = Replay::parse(&replay.to_text()).expect("reads back");
assert_eq!(back.ai, replay.ai, "and the file says so");
}
#[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_called_round_leaves_the_old_board_where_it_stood() {
use crate::transport::{MatchTerms, UdpTransport};
let mut app = sim_app();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.add_systems(Update, follow_the_called_round);
for _ in 0..50 {
app.world_mut()
.resource_mut::<Sim>()
.0
.tick(&[PlayerAction::None; MAX_PLAYERS]);
}
let mut joiner = net::OnlineSession::new(
UdpTransport::host(0).expect("socket"),
crate::sim::Lockstep::new(1, vec![0, 1], crate::sim::DEFAULT_DELAY),
2,
MatchTerms::default(),
);
joiner.next_round = true;
let commit = joiner.session.next_commit();
app.insert_resource(net::Online(Some(joiner)));
app.world_mut().resource_mut::<PendingActions>().0[1] = PlayerAction::Remove { x: 1, y: 1 };
app.update();
assert_eq!(
app.world().resource::<Sim>().0.ticks(),
50,
"the old board played the new round's frames"
);
let online = app.world().resource::<net::Online>();
let session = online.0.as_ref().expect("the session");
assert_eq!(session.session.frame(), 0, "the new round is untouched");
assert_eq!(
session.session.next_commit(),
commit,
"a key meant for the old board went to the new one"
);
app.update();
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Interlude,
"and on to the round that was called"
);
}
#[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");
}
}