use super::{BoardSwap, EditorState, Mode, level_here, swap_board};
use crate::app::gamepad::scheme::Does;
use crate::app::i18n::{Tr, fill};
use crate::app::settings::GameSettings;
use crate::app::{Controllers, SeatController, Sim};
use crate::sim::{Board, BotLevel, Level, LevelKind, PuzzleOutcome, TICKS_PER_SECOND};
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TestPhase {
Setup,
Running,
Won,
Lost,
Match,
MatchOver,
}
impl TestPhase {
pub const ALL: [TestPhase; 6] = [
TestPhase::Setup,
TestPhase::Running,
TestPhase::Won,
TestPhase::Lost,
TestPhase::Match,
TestPhase::MatchOver,
];
pub fn runs(self) -> bool {
match self {
TestPhase::Running | TestPhase::Match => true,
TestPhase::Setup | TestPhase::Won | TestPhase::Lost | TestPhase::MatchOver => false,
}
}
}
#[derive(Debug)]
pub struct Playtest {
bench: Board,
pub(super) level: Level,
start: Board,
pub phase: TestPhase,
seats: u8,
fresh: bool,
from_menu: bool,
}
const TEST_BOT: BotLevel = BotLevel::Normal;
impl Playtest {
pub(super) fn of(bench: Board, level: Level) -> Playtest {
let (start, seats, phase) = match level.kind {
LevelKind::Puzzle => (level.board(), 1, TestPhase::Setup),
LevelKind::Arena => {
let castles = level.table_seats().unwrap_or(1);
let config = crate::app::match_setup::MatchConfig::default();
let table = (2..=castles).rev().find(|&seats| level.fits(seats));
let (mut board, seats) = match table {
Some(seats) => (level.table_board(level.board().seed(), seats), seats),
None => (level.board(), castles),
};
crate::app::match_setup::apply_terms(
&mut board,
config.gulls,
config.round,
config.posts,
);
(board, seats, TestPhase::Match)
}
};
Playtest {
bench,
level,
start,
phase,
seats,
fresh: true,
from_menu: false,
}
}
pub fn controllers(&self) -> Controllers {
let mut table = Controllers::default();
if self.level.kind == LevelKind::Arena {
for seat in 1..usize::from(self.seats) {
if let Some(slot) = table.0.get_mut(seat) {
*slot = SeatController::Ai(TEST_BOT);
}
}
}
table
}
pub fn undecided(&self, board: &Board) -> bool {
self.level.kind == LevelKind::Arena || self.level.outcome(board) == PuzzleOutcome::Running
}
}
pub(super) fn start(sim: &mut Sim, state: &mut EditorState, swap: &mut BoardSwap) {
let level = level_here(state, &sim.0, &state.name);
let test = Playtest::of(sim.0.clone(), level);
swap_board(
test.start.clone(),
sim,
state,
&mut swap.commands,
&swap.sprites,
);
state.feedback.clear();
state.mode = Mode::Testing(Box::new(test));
}
pub(super) fn begin(sim: &mut Sim, state: &mut EditorState) {
let level = level_here(state, &sim.0, &state.name);
let mut test = Playtest::of(sim.0.clone(), level);
test.from_menu = true;
sim.0 = test.start.clone();
state.dirty = true;
state.mode = Mode::Testing(Box::new(test));
}
pub(super) const RUN_KEY: KeyCode = KeyCode::Enter;
pub(super) const RETRY_CAP: char = 'R';
pub(super) const END_KEY: KeyCode = KeyCode::Escape;
pub fn editor_test_input(
keyboard: crate::app::keycaps::Keyboard,
(pads, seat_pads): (
crate::app::gamepad::face::PadsOnFace,
crate::app::gamepad::SeatPads,
),
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
swap: BoardSwap,
mut next_screen: ResMut<NextState<crate::app::Screen>>,
) {
let BoardSwap {
mut commands,
sprites,
cursors,
} = swap;
let keys = &keyboard.keys;
if !cursors.iter().any(|(c, _)| c.player == 0) {
return;
}
if keys.just_pressed(END_KEY) || pads.pressed(Does::EndTest) {
if let Some(from_menu) = end_playtest(&mut sim, &mut state, &mut commands, &sprites) {
state.feedback = settings.tr().ed_back.into();
if from_menu {
next_screen.set(crate::app::Screen::Menu);
}
}
return;
}
let Mode::Testing(test) = &mut state.mode else {
return;
};
if std::mem::take(&mut test.fresh) {
return;
}
test.phase = match test.phase {
TestPhase::Running => match test.level.outcome(&sim.0) {
PuzzleOutcome::Running => TestPhase::Running,
PuzzleOutcome::Won => TestPhase::Won,
PuzzleOutcome::Lost => TestPhase::Lost,
},
TestPhase::Match if sim.0.round_over() => TestPhase::MatchOver,
phase @ (TestPhase::Setup
| TestPhase::Won
| TestPhase::Lost
| TestPhase::Match
| TestPhase::MatchOver) => phase,
};
let placing = matches!(test.phase, TestPhase::Setup | TestPhase::Match);
if placing {
let hands: Vec<&crate::app::cursor::Cursor> = cursors.iter().map(|(c, _)| c).collect();
let commit =
settings.commit_in_play(crate::app::cursor::has_partner(hands.iter().copied()));
let players: Vec<u8> = hands.iter().map(|c| c.player).collect();
for hand in hands.iter().filter(|c| c.player < 2) {
let seat = crate::app::cursor::stage_seat(&sim.0, hand);
let map = crate::app::cursor::keymap(&settings, hand.player, commit);
let pad = |does| seat_pads.pressed(&settings, &players, hand.player, does);
for (key, dir) in map.places {
if keys.just_pressed(key) || pad(Does::Place(dir)) {
let _ = sim.0.place_signpost(seat, hand.x, hand.y, dir);
}
}
if keys.just_pressed(map.remove) || pad(Does::Remove) {
let _ = sim.0.remove_signpost(seat, hand.x, hand.y);
}
}
}
let run = crate::app::menu_ui::enter(keys) || pads.pressed(Does::Start);
if test.phase == TestPhase::Setup && run {
test.start = sim.0.clone();
test.phase = TestPhase::Running;
return;
}
let retry = keyboard.global(RETRY_CAP) || pads.pressed(Does::Reset);
if retry && test.phase != TestPhase::Setup {
test.phase = match test.level.kind {
LevelKind::Puzzle => TestPhase::Setup,
LevelKind::Arena => TestPhase::Match,
};
let start = test.start.clone();
swap_board(start, &mut sim, &mut state, &mut commands, &sprites);
}
}
pub(super) fn end_playtest(
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: &crate::app::session::BoardSprites,
) -> Option<bool> {
if !state.is_testing() {
return None;
}
let Mode::Testing(test) = std::mem::take(&mut state.mode) else {
return None;
};
swap_board(test.bench, sim, state, commands, sprites);
Some(test.from_menu)
}
pub fn seat_test_partner(
mut commands: Commands,
state: Res<EditorState>,
art: Res<crate::app::art::Art>,
cursors: Query<(Entity, &crate::app::cursor::Cursor)>,
) {
let wanted = match &state.mode {
Mode::Testing(test) => test.level.is_coop(),
Mode::Painting
| Mode::Naming { .. }
| Mode::JustClosed
| Mode::Levels(_)
| Mode::Card(_) => false,
};
let partner = cursors.iter().find(|(_, c)| c.player == 1);
match (wanted, partner) {
(true, None) => crate::app::cursor::cursor_sprite(&mut commands, &art, 1),
(false, Some((entity, _))) => commands.entity(entity).despawn(),
(true, Some(_)) | (false, None) => {}
}
}
pub fn seat_test_bots(state: Res<EditorState>, mut controllers: ResMut<Controllers>) {
let table = match &state.mode {
Mode::Testing(test) => test.controllers(),
Mode::Painting
| Mode::Naming { .. }
| Mode::JustClosed
| Mode::Levels(_)
| Mode::Card(_) => Controllers::default(),
};
if *controllers != table {
*controllers = table;
}
}
pub(crate) fn status(tr: &Tr, test: &Playtest, board: &Board) -> String {
match test.level.kind {
LevelKind::Puzzle => crate::app::hud::puzzle_status(tr, &test.level, board),
LevelKind::Arena => {
let mut parts: Vec<String> = (0..test.seats)
.map(|seat| {
format!(
"{} {}",
crate::app::seat_label(tr, seat),
board.scores()[usize::from(seat)]
)
})
.collect();
if let Some(left) = board.remaining_ticks() {
let secs = left.div_ceil(u64::from(TICKS_PER_SECOND));
parts.push(fill(tr.ed_secs, &[("s", &secs.to_string())]));
}
parts.join(" | ")
}
}
}
pub(crate) fn prompt(tr: &Tr, test: &Playtest, settings: &GameSettings) -> String {
match test.phase {
TestPhase::Setup if test.level.is_coop() => match settings.stock_legend(true) {
true => crate::app::name_the_pair(tr, tr.ed_test_setup_coop),
false => tr.ed_test_setup_coop_custom.to_string(),
},
phase @ (TestPhase::Setup
| TestPhase::Running
| TestPhase::Won
| TestPhase::Lost
| TestPhase::Match
| TestPhase::MatchOver) => solo_prompt(tr, phase).to_string(),
}
}
fn solo_prompt(tr: &Tr, phase: TestPhase) -> &'static str {
match phase {
TestPhase::Setup => tr.ed_test_setup,
TestPhase::Match => tr.ed_playtest_prompt,
TestPhase::Running | TestPhase::Won | TestPhase::Lost | TestPhase::MatchOver => {
tr.ed_test_retry
}
}
}
pub(crate) fn banner(tr: &Tr, test: &Playtest, board: &Board) -> Option<String> {
let every_crab = test.level.goal.saves_every_crab();
match test.phase {
TestPhase::Setup | TestPhase::Running | TestPhase::Match => None,
TestPhase::Won => Some(
match every_crab {
true => tr.all_safe,
false => tr.goal_met,
}
.to_string(),
),
TestPhase::Lost => Some(
crate::app::results::loss_words(tr, test.level.goal)
.0
.into(),
),
TestPhase::MatchOver => {
let scores = &board.scores()[..usize::from(test.seats)];
let best = scores.iter().copied().max().unwrap_or(0);
let leaders: Vec<usize> = (0..scores.len()).filter(|&s| scores[s] == best).collect();
Some(match leaders.as_slice() {
[seat] => fill(tr.wins, &[("p", &crate::app::seat_label(tr, *seat as u8))]),
_ => tr.replay_draw.to_uppercase(),
})
}
}
}
#[derive(Component)]
pub struct TestBanner;
pub(super) fn spawn_banner(commands: &mut Commands) {
commands
.spawn((
super::EditorUi,
Node {
position_type: PositionType::Absolute,
top: Val::Px(84.0),
left: Val::Px(0.0),
right: Val::Px(0.0),
justify_content: JustifyContent::Center,
..default()
},
))
.with_children(|wrap| {
wrap.spawn((
TestBanner,
Text::new(String::new()),
crate::app::menu_ui::display_font(34.0),
TextColor(crate::app::palette::GOLD),
Node {
display: Display::None,
padding: UiRect::axes(Val::Px(22.0), Val::Px(8.0)),
border_radius: BorderRadius::all(Val::Px(14.0)),
..default()
},
BackgroundColor(crate::app::palette::PILL_FILL),
));
});
}
pub fn update_test_banner(
state: Res<EditorState>,
sim: Res<Sim>,
settings: Res<GameSettings>,
mut banners: Query<(&mut Text, &mut Node), With<TestBanner>>,
) {
let said = match &state.mode {
Mode::Testing(test) => banner(settings.tr(), test, &sim.0),
Mode::Painting
| Mode::Naming { .. }
| Mode::JustClosed
| Mode::Levels(_)
| Mode::Card(_) => None,
};
for (mut text, mut node) in &mut banners {
crate::app::menu_ui::set_shown(&mut node, said.is_some());
crate::app::menu_ui::set_text(&mut text, said.as_deref().unwrap_or_default());
}
}
#[cfg(test)]
mod tests {
use super::super::tests::{bench_app, tap};
use super::super::{Cursor, editor_commands, editor_input, editor_testing};
use super::*;
use crate::sim::{CrabKind, Direction, Handedness, TileKind};
fn test_app(board: Board, at: (u8, u8)) -> App {
let mut app = bench_app();
app.insert_resource(Sim(board));
app.world_mut().resource_mut::<EditorState>().posts = 2;
let mut cursor = Cursor::seated(0);
(cursor.x, cursor.y) = at;
app.world_mut().spawn((cursor, Transform::default()));
let editing = |state: Res<EditorState>| !state.is_testing();
app.add_systems(
Update,
(
(editor_input, editor_commands).chain().run_if(editing),
editor_test_input.run_if(editor_testing),
seat_test_bots,
)
.chain(),
);
app
}
fn phase(app: &App) -> Option<TestPhase> {
app.world()
.resource::<EditorState>()
.test()
.map(|test| test.phase)
}
#[test]
fn a_coop_playtest_places_each_hand_on_its_own_keys() {
let mut board = Board::new(6, 5, 3);
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let mut app = test_app(board, (2, 2));
app.world_mut().resource_mut::<GameSettings>().commit =
crate::app::settings::CommitScheme::Ijkl;
{
let mut state = app.world_mut().resource_mut::<EditorState>();
state.posts = 1;
state.second_hand = Some(1);
}
let mut partner = Cursor::seated(1);
(partner.x, partner.y) = (3, 2);
app.world_mut().spawn((partner, Transform::default()));
tap(&mut app, KeyCode::Enter);
assert_eq!(phase(&app), Some(TestPhase::Setup));
let posts = |app: &App, seat| app.world().resource::<Sim>().0.signpost_count(seat);
tap(&mut app, KeyCode::KeyI);
assert_eq!(posts(&app, 0), 0, "I is P2's key, not P1's place key");
tap(&mut app, KeyCode::ArrowUp);
assert_eq!(posts(&app, 0), 1, "P1 places on the arrows");
tap(&mut app, KeyCode::Numpad8);
assert_eq!(posts(&app, 1), 1, "P2 on the numpad, for P2");
let state = app.world().resource::<EditorState>();
let test = state.test().expect("a test");
let tr = &crate::app::i18n::EN;
let stock = GameSettings::default();
assert_eq!(
prompt(tr, test, &stock),
crate::app::name_the_pair(tr, tr.ed_test_setup_coop)
);
let one_hand = app.world().resource::<GameSettings>();
assert_eq!(
prompt(tr, test, one_hand),
crate::app::name_the_pair(tr, tr.ed_test_setup_coop),
"the partner sets the one-hand preset aside"
);
let mut rebound = GameSettings::default();
rebound.binds[1].set(crate::app::binds::Action::PlaceUp, KeyCode::Digit9);
assert_eq!(prompt(tr, test, &rebound), tr.ed_test_setup_coop_custom);
}
#[test]
fn a_stage_is_set_up_run_won_and_retried() {
let mut board = Board::new(6, 5, 3);
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let mut app = test_app(board, (2, 2));
tap(&mut app, KeyCode::Enter);
assert_eq!(phase(&app), Some(TestPhase::Setup), "not run at once");
tap(&mut app, KeyCode::ArrowRight);
let posts = |app: &App| app.world().resource::<Sim>().0.signpost_count(0);
assert_eq!(posts(&app), 1, "an arrow set up");
tap(&mut app, KeyCode::Enter);
assert_eq!(phase(&app), Some(TestPhase::Running));
assert!(app.world().resource::<EditorState>().test_running());
for _ in 0..600 {
let mut sim = app.world_mut().resource_mut::<Sim>();
let test_level = Level::from_board("x", 2, sim.0.clone());
if test_level.outcome(&sim.0) != PuzzleOutcome::Running {
break;
}
sim.0.tick_idle();
}
app.update();
assert_eq!(phase(&app), Some(TestPhase::Won));
assert!(!app.world().resource::<EditorState>().test_running());
let banner = {
let state = app.world().resource::<EditorState>();
banner(
&crate::app::i18n::EN,
state.test().expect("a test"),
&Board::new(1, 1, 0),
)
};
assert_eq!(banner.as_deref(), Some(crate::app::i18n::EN.all_safe));
tap(&mut app, KeyCode::KeyR);
assert_eq!(phase(&app), Some(TestPhase::Setup), "back to the setup");
assert_eq!(posts(&app), 1, "with the arrow it ran with");
tap(&mut app, KeyCode::Escape);
assert_eq!(phase(&app), None);
assert_eq!(posts(&app), 0, "the bench as it was");
}
#[test]
fn a_playtest_holds_a_running_checks_answer() {
use super::super::validate::{Validation, Verdict, poll_solver};
let mut board = Board::new(6, 5, 3);
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let mut app = test_app(board, (2, 2));
app.add_systems(Update, poll_solver);
let found = vec![(2, 2, Direction::Right, 0)];
app.world_mut().resource_mut::<EditorState>().solver = Some(Validation::pending());
tap(&mut app, KeyCode::Enter);
assert_eq!(phase(&app), Some(TestPhase::Setup));
let answer = Verdict {
outcome: crate::sim::SolveOutcome::Found(found.clone()),
needs: None,
shared: None,
coop: false,
};
{
let state = app.world().resource::<EditorState>();
let check = state.solver.as_ref().expect("still running");
*check.slot.lock().unwrap() = Some(answer);
}
app.update();
let state = app.world().resource::<EditorState>();
assert!(state.solver.is_some(), "the answer waits");
assert!(state.solution.is_empty());
tap(&mut app, KeyCode::Escape);
let state = app.world().resource::<EditorState>();
assert!(state.solver.is_none(), "taken once the bench is back");
assert_eq!(state.solution, found);
}
#[test]
fn a_test_from_the_menu_ends_at_the_menu() {
use crate::app::Screen;
use bevy::ecs::system::RunSystemOnce;
let mut board = Board::new(9, 7, 3);
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(8, 6, TileKind::Castle(1));
let level = Level::from_board("Menu Beach", 3, board.clone()).with_kind(LevelKind::Arena);
let mut app = test_app(board, (2, 2));
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
for test in [true, false] {
app.world_mut()
.resource_mut::<NextState<Screen>>()
.set(Screen::Editor);
app.update();
assert_eq!(screen(&app), Screen::Editor);
app.world_mut()
.resource_mut::<super::super::EditorOpening>()
.0 = Some(super::super::Opening {
level: level.clone(),
path: None,
test,
});
let _ = app.world_mut().run_system_once(super::super::enter_editor);
if !test {
tap(&mut app, KeyCode::Enter);
}
assert!(app.world().resource::<EditorState>().is_testing());
tap(&mut app, KeyCode::Escape);
let back = match test {
true => Screen::Menu,
false => Screen::Editor,
};
assert_eq!(screen(&app), back, "opened to test: {test}");
}
}
#[test]
fn a_menu_test_a_fault_ends_does_not_send_the_next_to_the_menu() {
use crate::app::Screen;
use bevy::ecs::system::RunSystemOnce;
let mut board = Board::new(9, 7, 3);
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(8, 6, TileKind::Castle(1));
let level = Level::from_board("Menu Beach", 3, board.clone()).with_kind(LevelKind::Arena);
let mut app = test_app(board, (2, 2));
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
app.world_mut()
.resource_mut::<NextState<Screen>>()
.set(Screen::Editor);
app.update();
app.world_mut()
.resource_mut::<super::super::EditorOpening>()
.0 = Some(super::super::Opening {
level,
path: None,
test: true,
});
let _ = app.world_mut().run_system_once(super::super::enter_editor);
assert!(app.world().resource::<EditorState>().is_testing());
let _ = app.world_mut().run_system_once(
|mut sim: ResMut<Sim>,
mut state: ResMut<EditorState>,
mut commands: Commands,
sprites: crate::app::session::BoardSprites| {
super::super::recover(
&mut sim,
&mut state,
&mut commands,
&sprites,
&crate::app::i18n::EN,
);
},
);
assert!(!app.world().resource::<EditorState>().is_testing());
tap(&mut app, KeyCode::Enter);
assert!(app.world().resource::<EditorState>().is_testing());
tap(&mut app, KeyCode::Escape);
app.update();
assert_eq!(screen(&app), Screen::Editor, "back at the bench");
}
#[test]
fn a_beach_is_tested_at_the_largest_table_it_names() {
let mut board = Board::new(9, 7, 3);
for (seat, (x, y)) in [(0, 0), (8, 6), (8, 0), (0, 6), (4, 0), (4, 6)]
.into_iter()
.enumerate()
{
board.set_tile(x, y, TileKind::Castle(seat as u8));
}
let mut level = Level::from_board("Six", 3, board.clone()).with_kind(LevelKind::Arena);
level.tables = vec![2, 4];
assert!(!level.board().events_enabled(), "the file says nothing");
let test = Playtest::of(board, level);
assert!(
test.start.events_enabled(),
"the wheel turns, as in a real round"
);
assert_eq!(test.seats, 4);
assert_eq!(
test.start.table_seats(),
Some(4),
"castles 5 and 6 put away"
);
}
#[test]
fn a_beach_is_played_against_the_ai() {
let mut board = Board::new(9, 7, 3);
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(8, 6, TileKind::Castle(1));
board.set_tile(8, 0, TileKind::Castle(2));
board.spawn_crab(4, 3, Direction::Right, Handedness::Left, CrabKind::Common);
let mut app = test_app(board, (2, 2));
app.world_mut().resource_mut::<EditorState>().kind = LevelKind::Arena;
tap(&mut app, KeyCode::Enter);
assert_eq!(phase(&app), Some(TestPhase::Match));
let controllers = *app.world().resource::<Controllers>();
assert_eq!(controllers.0[0], SeatController::Local);
assert_eq!(controllers.ai_count(), 2, "{controllers:?}");
assert!(app.world().resource::<Sim>().0.remaining_ticks().is_some());
tap(&mut app, KeyCode::Escape);
assert_eq!(
*app.world().resource::<Controllers>(),
Controllers::default()
);
}
}