use super::*;
use std::sync::{Arc, OnceLock};
pub(super) fn send_first_load(mut load: MessageWriter<LoadLevel>) {
load.write(LoadLevel { keep_posts: false });
}
pub(super) fn bot_seats(config: &match_setup::MatchConfig) -> [Option<BotLevel>; MAX_PLAYERS] {
let mut bots = [None; MAX_PLAYERS];
if config.armed {
for seat in (config.seats - config.bots)..config.seats {
bots[seat as usize] = Some(config.bot_levels[seat as usize]);
}
}
bots
}
pub(super) fn seat_count(
config: &match_setup::MatchConfig,
playback: bool,
online: Option<u8>,
top_castle_owner: Option<u8>,
pads: u8,
) -> u8 {
let asked = if playback {
top_castle_owner.unwrap_or(1).saturating_add(1)
} else if let Some(seats) = online {
seats
} else if config.armed {
config.seats
} else {
pads.max(2)
};
let seats = asked.clamp(2, MAX_PLAYERS as u8);
debug_assert!(
(2..=MAX_PLAYERS as u8).contains(&seats),
"the clamp let {seats} through"
);
seats
}
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct RoundSource<'w> {
sandbox: Res<'w, Sandbox>,
online: Res<'w, net::Online>,
playback: Res<'w, Playback>,
config: Res<'w, match_setup::MatchConfig>,
beaches: Res<'w, match_setup::CustomBeaches>,
daily: Res<'w, Daily>,
resuming: ResMut<'w, crate::app::Resuming>,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn load_versus(
mut commands: Commands,
art: Res<art::Art>,
mut source: RoundSource,
mut bots: ResMut<Bots>,
pads: Query<&Gamepad>,
mut seats: ResMut<Seats>,
mut recorder: ResMut<Recorder>,
mut sim: ResMut<Sim>,
mut next_vphase: ResMut<NextState<VersusPhase>>,
mut paused: ResMut<Paused>,
mut pending: ResMut<PendingActions>,
mut cursors: Query<(&mut cursor::Cursor, &mut Transform)>,
) {
let RoundSource {
sandbox,
online,
playback,
config,
beaches,
daily,
resuming,
} = &mut source;
let resumed = resuming.0.take();
sim.0 = if let Some(round) = &resumed {
round.board.clone()
} else if let Some((replay, _)) = &playback.0 {
replay.level.board()
} else if let Some(session) = &online.0 {
match_setup::board_from(&session.terms, session.seats, &session.beach)
} else if config.armed {
let seed = if daily.active {
Daily::seed()
} else {
crate::app::clock::fresh_seed()
};
let (w, h) = config.map.size();
let mut board = if config.map == match_setup::MapChoice::Custom {
beaches
.fitting(config.seats)
.get(config.custom)
.map_or_else(
|| generate_arena(seed, config.seats, w, h),
|beach| beach.level.board(),
)
} else if config.map == match_setup::MapChoice::Classic {
classic_arena_seeded(seed, false, config.seats)
} else {
generate_arena(seed, config.seats, w, h)
};
board.set_gull_period(config.gulls.period());
board.set_round_length(Some(config.round.ticks()));
board
} else {
classic_arena(sandbox.0, (pads.iter().count() as u8).max(2))
};
(bots.0, seats.0) = match (&resumed, &online.0) {
(Some(round), _) => (round.bots, round.seats),
(None, session) => {
let seated = session.as_ref().map(|s| s.seats);
let bots = match session {
Some(s) => match_setup::bot_seats_from(&s.terms, s.seats),
None => bot_seats(config),
};
(
bots,
seat_count(
config,
playback.0.is_some(),
seated,
sim.0.castle_owners().max(),
pads.iter().count() as u8,
),
)
}
};
recorder.0 = if playback.0.is_none() && resumed.is_none() {
Some(Replay::new(Level::from_board("Turf War", 3, sim.0.clone())))
} else {
None
};
board_render::spawn_static_board(&mut commands, &sim.0, &art);
board_render::spawn_waterline(&mut commands);
board_render::spawn_water_foam(&mut commands, &art);
pending.0 = [PlayerAction::None; MAX_PLAYERS];
for (mut cur, mut transform) in &mut cursors {
let spots = castle_spots(sim.0.width(), sim.0.height());
let (cx, cy) = spots[usize::from(cur.player).min(spots.len() - 1)];
cur.x = cx.clamp(2, sim.0.width() - 3);
cur.y = cy.clamp(2, sim.0.height() - 3);
transform.translation = layout::tile_center(&sim.0, cur.x, cur.y).extend(layout::z::CURSOR);
}
paused.0 = false;
next_vphase.set(VersusPhase::Running);
}
#[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<'_, '_> {
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);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_load_level(
mut commands: Commands,
mut messages: MessageReader<LoadLevel>,
campaign: Res<Campaign>,
art: Res<art::Art>,
mut pending: ResMut<PendingActions>,
mut sim: ResMut<Sim>,
mut next_phase: ResMut<NextState<Phase>>,
mut paused: ResMut<Paused>,
sprites: BoardSprites,
mut cursors: Query<(&mut cursor::Cursor, &mut Transform)>,
) {
let Some(message) = messages.read().last() else {
return;
};
let mut board = campaign.current().board();
if message.keep_posts {
let old = &sim.0;
for y in 0..old.height().min(board.height()) {
for x in 0..old.width().min(board.width()) {
if let Some(sp) = old.signpost_at(x, y) {
board.place_signpost(sp.owner, x, y, sp.dir);
}
}
}
}
sim.0 = board;
pending.0 = [PlayerAction::None; MAX_PLAYERS];
sprites.despawn_all(&mut commands);
board_render::spawn_static_board(&mut commands, &sim.0, &art);
board_render::spawn_waterline(&mut commands);
board_render::spawn_water_foam(&mut commands, &art);
if let Ok((mut cur, mut transform)) = cursors.single_mut() {
cur.x = sim.0.width() / 2;
cur.y = sim.0.height() / 2;
transform.translation = layout::tile_center(&sim.0, cur.x, cur.y).extend(layout::z::CURSOR);
}
paused.0 = false;
next_phase.set(Phase::Setup);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn end_versus(
mut online: ResMut<net::Online>,
mut playback: ResMut<Playback>,
mut recorder: ResMut<Recorder>,
mut reel_thread: ResMut<ReelThread>,
mut pending: ResMut<PendingActions>,
mut config: ResMut<match_setup::MatchConfig>,
mut bots: ResMut<Bots>,
mut next_vphase: ResMut<NextState<VersusPhase>>,
) {
if !online.0.as_ref().is_some_and(|session| session.next_round) {
online.0 = None;
}
playback.0 = None;
recorder.0 = None;
reel_thread.0 = None;
config.armed = false;
bots.0 = [None; MAX_PLAYERS];
pending.0 = [PlayerAction::None; MAX_PLAYERS];
next_vphase.set(VersusPhase::Running);
}
pub(crate) fn fill_bot_actions(
board: &Board,
bots: &Bots,
actions: &mut [PlayerAction; MAX_PLAYERS],
) {
debug_assert_eq!(actions.len(), bots.0.len(), "a seat with no bot slot");
for (seat, action) in actions.iter_mut().enumerate() {
if let Some(level) = bots.0[seat]
&& matches!(action, PlayerAction::None)
{
*action = bot_action(board, seat as u8, level);
}
}
}
pub(super) fn board_screens(screen: Res<State<Screen>>) -> bool {
match screen.get() {
Screen::Menu => false,
Screen::Puzzle | Screen::Versus | Screen::Editor => true,
Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => false,
}
}
pub(super) fn play_screens(screen: Res<State<Screen>>) -> bool {
matches!(screen.get(), Screen::Puzzle | Screen::Versus)
}
pub(super) fn not_watching(playback: Res<Playback>) -> bool {
playback.0.is_none()
}
pub(super) fn sim_should_run(
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
vphase: Res<State<VersusPhase>>,
editor: Res<editor::EditorState>,
) -> bool {
match screen.get() {
Screen::Menu => false,
Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => false,
Screen::Puzzle => *phase.get() == Phase::Running,
Screen::Versus => *vphase.get() == VersusPhase::Running,
Screen::Editor => editor.testing.is_some(),
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn advance_sim(
mut sim: ResMut<Sim>,
mut pending: ResMut<PendingActions>,
paused: Res<Paused>,
mut online: ResMut<net::Online>,
mut recorder: ResMut<Recorder>,
mut playback: ResMut<Playback>,
speed: Res<crate::app::replays::PlaybackSpeed>,
bots: Res<Bots>,
) {
if paused.0 {
return;
}
if let Some((replay, idx)) = &mut playback.0 {
for _ in 0..speed.0.max(1) {
let Some(actions) = replay.inputs.get(*idx).copied() else {
break;
};
*idx += 1;
sim.0.tick(&actions);
}
return;
}
if let Some(session) = &mut online.0 {
let local = session.session.seat().map(usize::from);
let action = local.map_or(PlayerAction::None, |seat| pending.0[seat]);
let sim = &mut sim.0;
let recorder = &mut recorder.0;
let bots = &*bots;
let committed = session.pump(action, |net| {
if let Some(mut frame_actions) = net.session.advance() {
fill_bot_actions(sim, bots, &mut frame_actions);
sim.tick(&frame_actions);
if let Some(replay) = recorder {
replay.record(frame_actions);
}
net.after_frame(sim.state_hash());
}
});
if let Some(seat) = local
&& (committed || session.session.paused())
{
pending.0[seat] = PlayerAction::None;
}
return;
}
let mut actions = std::mem::take(&mut pending.0);
fill_bot_actions(&sim.0, &bots, &mut actions);
sim.0.tick(&actions);
if let Some(replay) = &mut recorder.0 {
replay.record(actions);
}
}
pub(super) fn check_outcome(
sim: Res<Sim>,
campaign: Res<Campaign>,
mut next_phase: ResMut<NextState<Phase>>,
) {
match campaign.current().outcome(&sim.0) {
PuzzleOutcome::Running => {}
PuzzleOutcome::Won => next_phase.set(Phase::Won),
PuzzleOutcome::Lost => next_phase.set(Phase::Lost),
}
}
pub(super) fn resolve_seat_names(
online: Res<net::Online>,
playback: Res<Playback>,
settings: Res<crate::app::settings::GameSettings>,
mut recorder: ResMut<Recorder>,
mut names: ResMut<SeatNames>,
) {
names.0 = match (&playback.0, &online.0) {
(Some((replay, _)), _) => replay.names.clone(),
(None, Some(session)) => session.names.clone(),
(None, None) => settings.names.clone(),
};
if let Some(replay) = &mut recorder.0 {
replay.names = names.0.clone();
}
}
#[derive(Resource, Default)]
pub struct ReelThread(Option<Arc<OnceLock<Option<String>>>>);
pub(super) fn poll_reel(mut reel_thread: ResMut<ReelThread>, mut highlight: ResMut<Highlight>) {
let Some(answer) = reel_thread.0.as_ref().and_then(|slot| slot.get().cloned()) else {
return;
};
reel_thread.0 = None;
if answer.is_some() {
highlight.0 = answer;
}
}
fn winner_name(
sim: &Sim,
seats: &Seats,
settings: &crate::app::settings::GameSettings,
online: &net::Online,
names: &SeatNames,
) -> String {
let mode = crate::app::teams::in_play(settings, online, seats.0);
let leaders = crate::app::side_panels::leading_seats(sim.0.scores(), seats.0, mode);
match leaders.iter().position(|&led| led) {
Some(seat) => names.label(settings.tr(), seat as u8),
None => "draw".to_string(),
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn check_versus_over(
sim: Res<Sim>,
seats: Res<Seats>,
settings: Res<crate::app::settings::GameSettings>,
online: Res<net::Online>,
seat_names: Res<SeatNames>,
mut recorder: ResMut<Recorder>,
mut highlight: ResMut<Highlight>,
mut reel_thread: ResMut<ReelThread>,
mut next_vphase: ResMut<NextState<VersusPhase>>,
) {
if sim.0.round_over() {
highlight.0 = None;
reel_thread.0 = None;
if let Some(replay) = recorder.0.take() {
let library = crate::app::replays::library_dir();
let text = replay.to_text();
let last = crate::app::replay_path();
match crate::app::paths::write_atomic(&last, &text) {
Ok(()) => info!("replay saved to {}", last.display()),
Err(e) => warn!("could not save replay: {e}"),
}
let stamp = crate::app::clock::now_secs();
let winner = winner_name(&sim, &seats, &settings, &online, &seat_names);
let kept = library.join(crate::app::replays::file_name(stamp, &winner));
if let Err(e) = crate::app::paths::write_atomic(&kept, &text) {
warn!("could not file the replay: {e}");
}
crate::app::replays::prune(settings.replay_cap);
let reel = crate::app::highlight_path();
let answer = Arc::new(OnceLock::new());
reel_thread.0 = Some(Arc::clone(&answer));
std::thread::spawn(move || {
let saved = match crate::highlight::reel(&replay) {
Some(bytes) => match crate::app::paths::write_atomic(&reel, bytes) {
Ok(()) => {
info!("highlight reel saved to {}", reel.display());
Some(reel.display().to_string())
}
Err(e) => {
warn!("could not save the highlight reel: {e}");
None
}
},
None => {
warn!("the round was too short for a highlight reel");
None
}
};
let _ = answer.set(saved);
});
}
next_vphase.set(VersusPhase::Over);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::BotLevel;
#[test]
fn a_kept_round_is_filed_under_whoever_took_it() {
let mut board = crate::sim::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: &crate::app::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 = crate::app::settings::GameSettings::default();
assert_eq!(named([0, 7, 0, 0, 0, 0], &plain), "P2");
assert_eq!(named([7, 7, 0, 0, 0, 0], &plain), "draw");
assert_eq!(named([0, 0, 0, 0, 0, 0], &plain), "draw");
let mut settings = crate::app::settings::GameSettings::default();
settings.names[1] = "Bo".to_string();
assert_eq!(named([0, 7, 0, 0, 0, 0], &settings), "Bo");
}
fn armed(seats: u8, bots: u8) -> match_setup::MatchConfig {
match_setup::MatchConfig {
seats,
bots,
armed: true,
..match_setup::MatchConfig::default()
}
}
#[test]
fn the_ai_takes_the_top_seats() {
let mut config = armed(4, 2);
config.bot_levels[3] = BotLevel::Hard;
config.bot_levels[2] = BotLevel::Easy;
assert_eq!(bot_seats(&config), {
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), [None; MAX_PLAYERS]);
}
#[test]
fn every_entry_path_knows_its_own_seat_count() {
let config = armed(3, 1);
assert_eq!(seat_count(&config, true, None, Some(3), 0), 4);
assert_eq!(
seat_count(&config, true, None, None, 0),
2,
"a castle-less recording still seats two"
);
assert_eq!(seat_count(&config, false, Some(2), None, 0), 2);
assert_eq!(seat_count(&config, false, None, None, 0), 3);
let mut hook = armed(3, 1);
hook.armed = false;
assert_eq!(seat_count(&hook, false, None, None, 0), 2);
assert_eq!(seat_count(&hook, false, None, None, 3), 3);
}
#[test]
fn a_seat_count_never_leaves_the_table() {
let config = armed(3, 1);
let seated = MAX_PLAYERS as u8;
assert_eq!(seat_count(&config, false, Some(200), None, 0), seated);
assert_eq!(seat_count(&config, false, Some(0), None, 0), 2);
let mut hook = armed(3, 1);
hook.armed = false;
assert_eq!(seat_count(&hook, false, None, None, 9), seated);
for online in [None, Some(0), Some(3), Some(255)] {
for pads in [0, 2, 200] {
let seats = seat_count(&hook, false, online, Some(255), pads);
assert!((2..=seated).contains(&seats), "{online:?}/{pads} → {seats}");
}
}
}
#[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 = Bots::default();
bots.0[2] = Some(BotLevel::Normal);
bots.0[3] = Some(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"
);
}
}