mod brush;
mod card;
mod help;
mod history;
mod levels;
mod naming;
mod palette;
mod playtest;
mod resize;
mod setting;
mod share;
mod shelf;
mod symmetry;
mod templates;
mod validate;
pub use brush::Brush;
pub use card::update_settings_card;
pub use help::{editor_card_input, show_editor_card};
pub use levels::{LevelList, editor_levels_input, show_level_list};
pub use palette::{CARD_MARGIN, CARD_W, EditorUi, spawn_editor_ui, update_editor_palette};
pub use playtest::{
Playtest, TestPhase, editor_test_input, seat_test_bots, seat_test_partner, update_test_banner,
};
pub(crate) use playtest::{prompt as test_prompt, status as test_status};
pub use setting::Setting;
pub use share::{level_from_code, taken_from_elsewhere};
pub use shelf::{Shelved, custom_dir, legacy_save_path, shelve};
pub use symmetry::Symmetry;
pub use validate::poll_solver;
use brush::{paint, stroke_onto};
use naming::type_a_name;
use symmetry::{Layer, Unfair};
use validate::{Validation, arena_report, start_check};
use crate::app::cursor::Cursor;
use crate::app::gamepad::LastDevice;
use crate::app::i18n::{Tr, fill};
use crate::app::session::BoardSprites;
use crate::app::settings::GameSettings;
use crate::app::{Screen, Sim};
use crate::sim::{Board, Direction, Goal, Level, LevelKind, Placement};
use bevy::prelude::*;
const ARROWS: [(KeyCode, Direction); 4] = [
(KeyCode::ArrowUp, Direction::Up),
(KeyCode::ArrowDown, Direction::Down),
(KeyCode::ArrowLeft, Direction::Left),
(KeyCode::ArrowRight, Direction::Right),
];
pub(crate) struct EditorKeys {
pub moves: [KeyCode; 4],
pub walls: [(KeyCode, Direction); 4],
pub place: [(KeyCode, Direction); 4],
pub remove: KeyCode,
}
impl EditorKeys {
pub(crate) fn of(settings: &GameSettings) -> Self {
let map = crate::app::cursor::keymap(settings, 0, settings.commit);
let moves = map.move_keys();
let walls = match ARROWS.iter().any(|(key, _)| moves.contains(key)) {
true => map.places,
false => ARROWS,
};
Self {
moves,
walls,
place: map.places,
remove: map.remove,
}
}
fn shadows(&self, key: KeyCode) -> bool {
self.moves.contains(&key) || self.walls.iter().any(|(wall, _)| *wall == key)
}
fn command_press<'a>(
&'a self,
keys: &'a ButtonInput<KeyCode>,
) -> impl Fn(KeyCode) -> bool + 'a {
move |key| keys.just_pressed(key) && !self.shadows(key)
}
pub(crate) fn command(&self, command: Command) -> KeyCode {
match self.shadows(command.letter()) {
true => command.stand_in(),
false => command.letter(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Command {
Check,
Gulls,
Wrap,
Turn,
Undo,
}
impl Command {
pub(crate) const ALL: [Command; 5] = [
Command::Check,
Command::Gulls,
Command::Wrap,
Command::Turn,
Command::Undo,
];
fn letter(self) -> KeyCode {
match self {
Command::Check => KeyCode::KeyV,
Command::Gulls => KeyCode::KeyK,
Command::Wrap => KeyCode::KeyO,
Command::Turn => KeyCode::KeyT,
Command::Undo => KeyCode::KeyZ,
}
}
fn stand_in(self) -> KeyCode {
match self {
Command::Check => KeyCode::F9,
Command::Gulls => KeyCode::F7,
Command::Wrap => KeyCode::F8,
Command::Turn => KeyCode::F10,
Command::Undo => KeyCode::F12,
}
}
fn slot(self) -> &'static str {
match self {
Command::Check => "check",
Command::Gulls => "gulls",
Command::Wrap => "wrap",
Command::Turn => "turn",
Command::Undo => "undo",
}
}
}
pub(crate) const HELP_KEY: KeyCode = KeyCode::F1;
pub(crate) const NEXT_SETTING: KeyCode = KeyCode::KeyN;
pub(crate) fn command_key(settings: &GameSettings, command: Command) -> KeyCode {
EditorKeys::of(settings).command(command)
}
pub(crate) fn wall_key(settings: &GameSettings, dir: Direction) -> Option<KeyCode> {
EditorKeys::of(settings)
.walls
.iter()
.find(|(_, side)| *side == dir)
.map(|(key, _)| *key)
}
pub(crate) fn spell_block(caps: &crate::app::keycaps::KeyCaps, keys: [KeyCode; 4]) -> String {
let labels = keys.map(|key| caps.label(key));
match labels.iter().all(|label| label.chars().count() == 1) {
true => labels.concat(),
false => labels.join("/"),
}
}
fn block_order(group: [(KeyCode, Direction); 4]) -> [KeyCode; 4] {
[
Direction::Up,
Direction::Left,
Direction::Down,
Direction::Right,
]
.map(|dir| {
group
.iter()
.find(|(_, d)| *d == dir)
.map(|(key, _)| *key)
.expect("a key for every way")
})
}
pub(crate) fn spell_editor_keys(
text: &str,
tr: &Tr,
caps: &crate::app::keycaps::KeyCaps,
settings: &GameSettings,
) -> String {
let keys = EditorKeys::of(settings);
let block = |group: [(KeyCode, Direction); 4]| -> String {
match group == ARROWS {
true => tr.ed_arrow_keys.to_string(),
false => spell_block(caps, block_order(group)),
}
};
let [up, down, left, right] = keys.moves;
let moves = spell_block(caps, [up, left, down, right]);
let remove = if keys.remove == KeyCode::Space {
tr.ed_space_key.to_string()
} else {
caps.label(keys.remove)
};
let commands = Command::ALL.map(|command| (command.slot(), caps.label(keys.command(command))));
let mut slots = vec![
("moves", moves),
("walls", block(keys.walls)),
("place", block(keys.place)),
("remove", remove),
("help", shifted(caps, HELP_KEY)),
];
slots.extend(commands);
let slots: Vec<(&str, &str)> = slots.iter().map(|(k, v)| (*k, v.as_str())).collect();
fill(text, &slots)
}
pub(crate) fn shifted(caps: &crate::app::keycaps::KeyCaps, key: KeyCode) -> String {
format!("Shift+{}", caps.label(key))
}
fn dial_press(
pressed: &impl Fn(KeyCode) -> bool,
keys: &ButtonInput<KeyCode>,
caps: &crate::app::keycaps::KeyCaps,
up: bool,
) -> bool {
let (us, pad, sign) = if up {
(KeyCode::Equal, KeyCode::NumpadAdd, '+')
} else {
(KeyCode::Minus, KeyCode::NumpadSubtract, '-')
};
pressed(us) || pressed(pad) || caps.pressed_caps(keys, sign).any(pressed)
}
const EDITOR_BOARD: (u8, u8) = (12, 9);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Offer {
Open,
Overwrite,
Leave,
}
impl Offer {
fn warning(self, tr: &Tr, device: LastDevice) -> String {
use crate::app::gamepad::scheme::{Does, button_name};
let (keys, pad, layer, does) = match self {
Offer::Open => (tr.ed_open_confirm, tr.pad.open_confirm, None, Does::Open),
Offer::Overwrite => (tr.ed_save_taken, tr.pad.save_taken, Some(true), Does::Save),
Offer::Leave => (
tr.ed_leave_confirm,
tr.pad.leave_confirm,
Some(false),
Does::Menu,
),
};
let face = match layer {
Some(layer) => crate::app::gamepad::Face::Editor { layer },
None => crate::app::gamepad::Face::LevelList,
};
match (device, button_name(&face, does, tr)) {
(LastDevice::Pad, Some(button)) => fill(pad, &[("b", &button)]),
_ => keys.to_string(),
}
}
fn key(self) -> KeyCode {
match self {
Offer::Open => KeyCode::Enter,
Offer::Overwrite => KeyCode::F2,
Offer::Leave => KeyCode::Escape,
}
}
}
fn ask_first(state: &mut EditorState, offer: Offer, at_stake: bool, prompt: String) -> bool {
if at_stake && state.offer != Some(offer) {
state.offer = Some(offer);
state.feedback = prompt;
return false;
}
state.offer = None;
true
}
fn has_work(state: &EditorState, board: &Board) -> bool {
let blank = Board::new(board.width(), board.height(), board.seed());
level_here(state, board, "x").to_text() != level_here(state, &blank, "x").to_text()
}
fn unsaved_work(state: &EditorState, board: &Board) -> bool {
has_work(state, board) && state.filed.as_deref() != Some(filed_text(state, board).as_str())
}
pub(super) fn filed_text(state: &EditorState, board: &Board) -> String {
let mut level = level_here(state, board, &state.name);
level.solution.clear();
level.to_text()
}
#[derive(Resource, Default)]
pub struct EditorState {
pub posts: u8,
pub second_hand: Option<u8>,
pub kind: LevelKind,
pub goal: Goal,
pub name: String,
pub mode: Mode,
pub brush: Brush,
pub feedback: String,
solver: Option<Validation>,
checked: Option<String>,
tables: Vec<u8>,
roll: u8,
solution: Vec<Placement>,
mine: std::collections::HashSet<std::path::PathBuf>,
opened: Option<(std::path::PathBuf, String)>,
offer: Option<Offer>,
filed: Option<String>,
pub setting: Setting,
pub symmetry: Symmetry,
hole_period: Option<u32>,
stroke: Option<Stroke>,
history: history::History,
dirty: bool,
}
#[derive(Clone, Debug)]
struct Stroke {
from: (u8, u8),
last: (u8, u8),
done: Vec<(u8, u8)>,
recorded: bool,
}
#[derive(Default, Debug)]
pub enum Mode {
#[default]
Painting,
Naming { was: String },
JustClosed,
Levels(Box<LevelList>),
Card(EditorCard),
Testing(Box<Playtest>),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EditorCard {
Help,
Tips,
}
impl Mode {
#[cfg(test)]
pub fn testing(bench: Board) -> Mode {
let level = Level::from_board("Test", 3, bench.clone());
let mut test = Playtest::of(bench, level);
test.phase = TestPhase::Running;
Mode::Testing(Box::new(test))
}
}
impl EditorState {
pub fn is_naming(&self) -> bool {
matches!(self.mode, Mode::Naming { .. })
}
pub fn has_keys(&self) -> bool {
matches!(
self.mode,
Mode::Naming { .. } | Mode::Levels(_) | Mode::Card(_)
)
}
pub fn card(&self) -> Option<EditorCard> {
match self.mode {
Mode::Card(card) => Some(card),
Mode::Painting
| Mode::Naming { .. }
| Mode::JustClosed
| Mode::Levels(_)
| Mode::Testing(_) => None,
}
}
pub fn is_testing(&self) -> bool {
matches!(self.mode, Mode::Testing(_))
}
pub fn test(&self) -> Option<&Playtest> {
match &self.mode {
Mode::Testing(test) => Some(test),
Mode::Painting
| Mode::Naming { .. }
| Mode::JustClosed
| Mode::Levels(_)
| Mode::Card(_) => None,
}
}
pub fn test_running(&self) -> bool {
self.test().is_some_and(|test| test.phase.runs())
}
fn edited(&mut self, board: &mut Board) -> bool {
if let Some(stroke) = &mut self.stroke {
stroke.restart();
}
self.edit(board, false)
}
fn edit(&mut self, board: &mut Board, merge: bool) -> bool {
self.dirty = true;
settle_facings(self, board);
if self.history.is_now(&history::snapshot(self, board)) {
return false;
}
if !self.solution.is_empty() {
self.solution.clear();
self.checked = None;
}
self.drop_check();
let after = history::snapshot(self, board);
self.history.record(after, merge);
true
}
fn begin_history(&mut self, board: &Board) {
self.history = history::History::starting_at(history::snapshot(self, board));
}
fn mirror_tile(
&self,
board: &mut Board,
at: (u8, u8),
layer: Layer,
) -> Result<Vec<(u8, u8)>, Unfair> {
let seats = self.kind == LevelKind::Arena;
if seats && let Some(why) = symmetry::unfair(board, self.symmetry, at, layer) {
return Err(why);
}
Ok(symmetry::mirror_tile(board, self.symmetry, seats, at))
}
fn drop_check(&mut self) {
if self.solver.take().is_some() {
self.feedback.clear();
}
}
fn start_afresh(&mut self, tr: &Tr, board: &Board) {
let Self {
posts: _,
second_hand: _,
kind: _,
goal,
name,
mode: _,
brush: _,
feedback: _,
solver: _,
checked,
tables,
roll,
solution,
mine,
opened,
offer: _,
filed,
setting: _,
symmetry,
hole_period,
stroke,
history: _,
dirty: _,
} = self;
*name = shelf::fresh_name(tr);
*goal = Goal::default();
*hole_period = None;
*stroke = None;
*checked = None;
tables.clear();
*roll = 0;
solution.clear();
mine.clear();
*opened = None;
*filed = None;
*symmetry = Symmetry::Off;
self.begin_history(board);
}
}
fn settle_facings(state: &EditorState, board: &mut Board) {
let settled = level_here(state, board, &state.name).board();
board.face_as(&settled);
}
#[derive(Resource, Default)]
pub struct EditorOpening(pub Option<Opening>);
#[derive(Clone, Debug)]
pub struct Opening {
pub level: Level,
pub path: Option<std::path::PathBuf>,
pub test: bool,
}
pub fn opening_from_file(path: &std::path::Path) -> Option<Opening> {
let text = std::fs::read_to_string(path).ok()?;
let level = Level::parse(&text).ok()?;
Some(Opening {
level,
path: Some(path.to_path_buf()),
test: false,
})
}
pub fn enter_editor(
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
mut opening: ResMut<EditorOpening>,
) {
let tr = settings.tr();
*state = EditorState {
posts: 3,
feedback: tr.ed_fresh_sand.into(),
dirty: true, ..default()
};
sim.0 = Board::new(EDITOR_BOARD.0, EDITOR_BOARD.1, 0xED17);
state.start_afresh(tr, &sim.0);
if settings.editor_tips {
state.mode = Mode::Card(EditorCard::Tips);
}
let dev = crate::app::dev::editor_level().and_then(|path| {
let opened = opening_from_file(&path);
if opened.is_none() {
warn!("PINCH_EDITOR_LEVEL: {} does not read", path.display());
}
opened
});
let Some(Opening { level, path, test }) = opening.0.take().or(dev) else {
return;
};
sim.0 = level.board();
match path {
Some(path) => levels::open_saved(&mut state, &level, path, &sim.0, tr),
None => {
share::take_level(&mut state, &level, &sim.0, tr);
state.feedback = fill(tr.ed_opened, &[("name", &state.name)]);
}
}
if test {
playtest::begin(&mut sim, &mut state);
}
}
pub fn exit_editor(
mut state: ResMut<EditorState>,
mut controllers: ResMut<crate::app::Controllers>,
) {
*state = EditorState::default();
*controllers = crate::app::Controllers::default();
}
pub fn editor_has_keys(state: Res<EditorState>) -> bool {
state.has_keys()
}
pub fn editor_on_card(state: Res<EditorState>) -> bool {
state.card().is_some()
}
pub fn editor_on_levels(state: Res<EditorState>) -> bool {
matches!(state.mode, Mode::Levels(_))
}
pub fn editor_testing(state: Res<EditorState>) -> bool {
state.is_testing()
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Workbench<'w> {
sim: ResMut<'w, Sim>,
state: ResMut<'w, EditorState>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct BoardSwap<'w, 's> {
commands: Commands<'w, 's>,
sprites: BoardSprites<'w, 's>,
cursors: Query<'w, 's, (&'static mut Cursor, &'static mut Transform)>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct EditorNews<'w> {
saved: MessageWriter<'w, crate::app::LevelSaved>,
shared: MessageWriter<'w, crate::app::CodeShared>,
taken: MessageWriter<'w, crate::app::CodeTaken>,
}
fn replace_board(board: Board, sim: &mut Sim, state: &mut EditorState, swap: &mut BoardSwap) {
swap_board(board, sim, state, &mut swap.commands, &swap.sprites);
state.drop_check();
state.edited(&mut sim.0);
crate::app::cursor::center_on(&sim.0, &mut swap.cursors);
}
fn put_back(text: &str, sim: &mut Sim, state: &mut EditorState, swap: &mut BoardSwap) {
let Ok(level) = Level::parse(text) else {
return;
};
swap_board(level.board(), sim, state, &mut swap.commands, &swap.sprites);
state.posts = level.hands().map_or(level.posts, |[p1, _]| p1);
state.second_hand = level.hands().map(|[_, p2]| p2);
state.kind = level.kind;
state.goal = level.goal;
state.solution.clone_from(&level.solution);
if let Some(period) = setting::first_hole_period(&sim.0) {
state.hole_period = Some(period);
}
state.drop_check();
move_cursors(&sim.0, &mut swap.cursors, (0, 0));
}
fn swap_board(
board: Board,
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: &BoardSprites,
) -> Board {
state.dirty = true;
state.stroke = None;
sprites.despawn_all(commands);
std::mem::replace(&mut sim.0, board)
}
fn shift_held(keys: &ButtonInput<KeyCode>) -> bool {
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight)
}
fn move_cursors(
board: &Board,
cursors: &mut Query<(&mut Cursor, &mut Transform)>,
(dx, dy): (i16, i16),
) {
let clamp = |at: u8, by: i16, size: u8| -> u8 {
u8::try_from((i16::from(at) + by).clamp(0, i16::from(size) - 1)).unwrap_or(0)
};
for (mut cursor, mut transform) in cursors {
cursor.x = clamp(cursor.x, dx, board.width());
cursor.y = clamp(cursor.y, dy, board.height());
transform.translation = crate::app::layout::tile_center(board, cursor.x, cursor.y)
.extend(crate::app::layout::z::CURSOR);
}
}
pub fn editor_input(
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
settings: Res<GameSettings>,
device: Res<LastDevice>,
bench: Workbench,
mut swap: BoardSwap,
) {
let Workbench { mut sim, mut state } = bench;
if state.is_naming() {
type_a_name(&mut typed, &keys, &mut state, settings.tr());
return;
}
typed.clear();
if matches!(
state.mode,
Mode::Levels(_) | Mode::Card(_) | Mode::JustClosed
) {
return;
}
let editor_keys = EditorKeys::of(&settings);
let pressed = editor_keys.command_press(&keys);
if let Some(offer) = state.offer
&& keys
.get_just_pressed()
.any(|&k| k != offer.key() && k != KeyCode::Escape)
{
state.offer = None;
}
if pressed(KeyCode::F5) {
let board = &sim.0;
let was = (board.width(), board.height());
let (w, h) = resize::next_size(was, !shift_held(&keys));
let resize::Resized { board, shift, cut } = resize::resized(board, (w, h));
let moved = state
.symmetry
.moves_off_centre((w ^ was.0) & 1 == 1, (h ^ was.1) & 1 == 1);
if moved {
state.symmetry = Symmetry::Off;
}
swap_board(
board,
&mut sim,
&mut state,
&mut swap.commands,
&swap.sprites,
);
state.edited(&mut sim.0);
move_cursors(&sim.0, &mut swap.cursors, shift);
let tr = settings.tr();
let said = match cut {
true => tr.ed_resized_cut,
false => tr.ed_resized,
};
state.feedback = fill(said, &[("w", &w.to_string()), ("h", &h.to_string())]);
if moved {
state.feedback = format!("{} - {}", tr.ed_mirror_moved, state.feedback);
}
return;
}
if pressed(HELP_KEY) && shift_held(&keys) {
state.mode = Mode::Card(EditorCard::Help);
state.offer = None;
return;
}
if pressed(KeyCode::F1) {
state.mode = Mode::Naming {
was: state.name.clone(),
};
state.feedback = match *device {
LastDevice::Pad => settings.tr().pad.steam_keyboard.to_string(),
LastDevice::Keyboard => settings.tr().ed_naming.to_string(),
};
return;
}
let Some((cursor, _)) = swap.cursors.iter().next() else {
return;
};
let (x, y) = (cursor.x, cursor.y);
let board = &mut sim.0;
for (key, dir) in editor_keys.walls {
if keys.just_pressed(key) {
if board.on_open_rim(x, y, dir) {
state.feedback = settings.tr().ed_wrap_on.to_string();
continue;
}
let present = board.wall_at(x, y, dir);
board.set_wall(x, y, dir, !present);
symmetry::mirror_wall(board, state.symmetry, (x, y), dir);
state.edited(board);
}
}
let tr = settings.tr();
if pressed(editor_keys.command(Command::Turn)) && turn_here(&state, &mut board.clone(), x, y) {
let layer = match brush::creature_at(board, x, y) {
true => Layer::Creatures,
false => Layer::Ground,
};
let mut tries = 0;
let turned = paint_fairly(&mut state, board, (x, y), layer, tr, |state, trial| {
tries += 1;
turn_here(state, trial, x, y) && tries < Direction::ALL.len()
});
if turned.is_some() {
state.edited(board);
}
}
let period = setting::hole_period(&state);
for brush in Brush::ALL {
if pressed(brush.key()) {
state.brush = brush;
press(&mut state, board, (x, y), period, tr);
}
}
if pressed(KeyCode::Tab) {
state.stroke = None;
let at = Brush::ALL
.iter()
.position(|b| *b == state.brush)
.unwrap_or(0);
let step = if shift_held(&keys) {
Brush::ALL.len() - 1
} else {
1
};
state.brush = Brush::ALL[(at + step) % Brush::ALL.len()];
}
if pressed(KeyCode::Space) {
press(&mut state, board, (x, y), period, tr);
}
drag(&mut state, board, &editor_keys, &keys, (x, y), tr);
}
fn press(state: &mut EditorState, board: &mut Board, at: (u8, u8), period: u32, tr: &Tr) {
state.stroke = None;
let (fair, refused) = press_fairly(state, board, at, period);
if let Some(why) = refused {
state.feedback = unfair_line(why, tr);
}
if let Some(Fair { written, skipped }) = fair {
let recorded = state.edited(board);
if !skipped {
state.stroke = Some(Stroke::at(at, written, recorded));
}
}
}
fn press_fairly(
state: &EditorState,
board: &mut Board,
at: (u8, u8),
period: u32,
) -> (Option<Fair>, Option<Unfair>) {
let brush = state.brush;
let tries = match brush {
Brush::Hole => Direction::ALL.len() + 1,
Brush::Castle => crate::sim::MAX_PLAYERS + 1,
Brush::Crab | Brush::Gull => Direction::ALL.len(),
Brush::Sand | Brush::Rock | Brush::Log | Brush::Weed | Brush::Pool => 1,
};
let creature = matches!(brush, Brush::Crab | Brush::Gull);
let mut tried = 0;
try_fairly(state, board, at, Layer::Both, |_, trial| {
tried += 1;
match (creature, tried) {
(true, 2..) => turn_round(trial, at),
(_, _) => paint(trial, at.0, at.1, brush, period),
}
tried < tries
})
}
fn turn_round(board: &mut Board, (x, y): (u8, u8)) {
if let Some(dir) = brush::facing(board, x, y) {
brush::face(board, x, y, dir.right());
}
}
struct Fair {
written: Vec<(u8, u8)>,
skipped: bool,
}
fn paint_fairly(
state: &mut EditorState,
board: &mut Board,
at: (u8, u8),
layer: Layer,
tr: &Tr,
change: impl FnMut(&EditorState, &mut Board) -> bool,
) -> Option<Fair> {
let (fair, refused) = try_fairly(state, board, at, layer, change);
if let Some(why) = refused {
state.feedback = unfair_line(why, tr);
}
fair
}
fn try_fairly(
state: &EditorState,
board: &mut Board,
at: (u8, u8),
layer: Layer,
mut change: impl FnMut(&EditorState, &mut Board) -> bool,
) -> (Option<Fair>, Option<Unfair>) {
let mut trial = board.clone();
let mut refused = None;
loop {
let again = change(state, &mut trial);
match state.mirror_tile(&mut trial, at, layer) {
Ok(written) => {
*board = trial;
let skipped = refused.is_some();
return (Some(Fair { written, skipped }), refused);
}
Err(why) => {
refused = Some(why);
if !again {
return (None, refused);
}
}
}
}
}
fn unfair_line(why: Unfair, tr: &Tr) -> String {
match why {
Unfair::Line => tr.ed_mirror_line.to_string(),
Unfair::Across => tr.ed_mirror_across.to_string(),
Unfair::SeatTwice => fill(
tr.ed_mirror_seats,
&[
("p", &crate::app::seat_label(tr, 4)),
("q", &crate::app::seat_label(tr, 5)),
],
),
}
}
impl Stroke {
fn at(tile: (u8, u8), copies: Vec<(u8, u8)>, recorded: bool) -> Self {
let mut done = copies;
done.push(tile);
Stroke {
from: tile,
last: tile,
done,
recorded,
}
}
fn restart(&mut self) {
if self.done.contains(&self.last) {
self.from = self.last;
}
self.recorded = false;
}
}
fn drag(
state: &mut EditorState,
board: &mut Board,
editor_keys: &EditorKeys,
keys: &ButtonInput<KeyCode>,
here: (u8, u8),
tr: &Tr,
) {
let letter = state.brush.key();
let held =
keys.pressed(KeyCode::Space) || (keys.pressed(letter) && !editor_keys.shadows(letter));
let Some(mut stroke) = state.stroke.take().filter(|_| held) else {
return;
};
if stroke.last == here {
state.stroke = Some(stroke);
return;
}
stroke.last = here;
if !stroke.done.contains(&here) {
let brush = state.brush;
let from = stroke.from;
let written = paint_fairly(state, board, here, Layer::Both, tr, |_, trial| {
stroke_onto(trial, from, here, brush);
false
});
if let Some(Fair { written, .. }) = written {
stroke.done.push(here);
stroke.done.extend(written);
stroke.recorded |= state.edit(board, stroke.recorded);
}
}
state.stroke = Some(stroke);
}
fn turn_here(state: &EditorState, board: &mut Board, x: u8, y: u8) -> bool {
let Some(mut dir) = brush::facing(board, x, y) else {
return false;
};
for _ in 0..3 {
dir = dir.right();
let mut trial = board.clone();
brush::face(&mut trial, x, y, dir);
settle_facings(state, &mut trial);
if brush::facing(&trial, x, y) == Some(dir) {
*board = trial;
return true;
}
}
false
}
pub(super) fn level_here(state: &EditorState, board: &Board, name: &str) -> Level {
let mut snapshot = board.clone();
let hands = (state.kind == LevelKind::Puzzle)
.then(|| state.second_hand.map(|p2| [state.posts, p2]))
.flatten();
snapshot.rules_mut().claw_hands = hands;
let posts = hands.map_or(state.posts, |[p1, p2]| p1.saturating_add(p2));
if state.kind == LevelKind::Puzzle {
snapshot.set_signpost_rule(posts, crate::sim::CapPolicy::Reject);
snapshot.rules_mut().castle_raids = false;
} else {
let stock = crate::sim::Rules::default();
snapshot.set_signpost_rule(stock.signpost_cap, stock.cap_policy);
snapshot.rules_mut().castle_raids = stock.castle_raids;
}
let tables = state
.tables
.iter()
.copied()
.filter(|&seats| Level::table_fits(&snapshot, seats))
.collect();
let roll = match Level::roll_fits(&snapshot, state.roll) {
true => state.roll,
false => 0,
};
let mut level = Level::from_board(name, posts, snapshot).with_kind(state.kind);
level.goal = state.goal;
level.tables = tables;
level.roll = roll;
level.solution.clone_from(&state.solution);
Level::parse(&level.to_text()).unwrap_or(level)
}
pub(super) fn checked_level(state: &EditorState, board: &Board) -> Level {
let mut level = level_here(state, board, "Custom");
level.solution.clear();
level
}
pub(super) fn is_checked(state: &EditorState, board: &Board) -> bool {
state.checked.as_deref() == Some(checked_level(state, board).to_text().as_str())
}
fn turn_settings(
state: &mut EditorState,
board: &mut Board,
editor_keys: &EditorKeys,
pressed: &impl Fn(KeyCode) -> bool,
(keys, caps): (&ButtonInput<KeyCode>, &crate::app::keycaps::KeyCaps),
) {
let shift = shift_held(keys);
if pressed(NEXT_SETTING) {
state.setting = Setting::neighbour(state, !shift);
}
let mut turns = Vec::new();
if dial_press(pressed, keys, caps, true) {
turns.push((Setting::chosen(state), true));
}
if dial_press(pressed, keys, caps, false) {
turns.push((Setting::chosen(state), false));
}
let rows = Setting::rows(state.kind);
for (command, row) in [
(Command::Gulls, Setting::Gulls),
(Command::Wrap, Setting::Wrap),
] {
if pressed(editor_keys.command(command)) && rows.contains(&row) {
state.setting = row;
turns.push((row, !shift));
}
}
for (row, forward) in turns {
if setting::step(state, board, row, forward) {
state.edited(board);
}
}
}
pub fn editor_commands(
keyboard: crate::app::keycaps::Keyboard,
(settings, device): (Res<GameSettings>, Res<LastDevice>),
bench: Workbench,
mut clipboard: ResMut<Clipboard>,
news: EditorNews,
mut next_screen: ResMut<NextState<Screen>>,
mut swap: BoardSwap,
) {
let Workbench { mut sim, mut state } = bench;
let crate::app::keycaps::Keyboard { keys, caps, .. } = keyboard;
let EditorNews {
mut saved,
mut shared,
taken: _,
} = news;
if matches!(state.mode, Mode::JustClosed) {
state.mode = Mode::Painting;
return;
}
let tr = settings.tr();
let editor_keys = EditorKeys::of(&settings);
let pressed = editor_keys.command_press(&keys);
if pressed(KeyCode::F3) {
share::copy_level(&mut state, &sim.0, &mut clipboard, tr, &mut shared);
}
if pressed(KeyCode::F4) {
state.mode = Mode::Levels(Box::new(LevelList::read()));
state.offer = None;
state.feedback.clear();
return;
}
if pressed(editor_keys.command(Command::Undo)) {
let redo = shift_held(&keys);
let back = match redo {
true => state.history.redo(),
false => state.history.undo(),
};
state.feedback = match (back, redo) {
(Some(text), _) => {
put_back(&text, &mut sim, &mut state, &mut swap);
match redo {
true => tr.ed_redone,
false => tr.ed_undone,
}
}
(None, true) => tr.ed_no_redo,
(None, false) => tr.ed_no_undo,
}
.into();
}
let board = &mut sim.0;
turn_settings(&mut state, board, &editor_keys, &pressed, (&keys, &caps));
if pressed(editor_keys.command(Command::Check)) {
if state.kind == LevelKind::Arena {
state.feedback = arena_report(board, tr);
} else if state.solver.is_some() {
state.feedback = tr.ed_already_validating.into();
} else {
start_check(&mut state, board);
state.feedback = tr.ed_validating.into();
}
}
if pressed(KeyCode::F6) {
state.kind = match state.kind {
LevelKind::Puzzle => LevelKind::Arena,
LevelKind::Arena => LevelKind::Puzzle,
};
state.goal = Goal::default();
state.edited(board);
state.feedback = match state.kind {
LevelKind::Puzzle => tr.ed_now_puzzle.into(),
LevelKind::Arena => tr.ed_now_arena.into(),
};
}
if pressed(KeyCode::F2) {
shelf::save_level(&mut state, board, (tr, *device), &mut saved);
}
if crate::app::menu_ui::enter(&keys) {
playtest::start(&mut sim, &mut state, &mut swap);
}
if pressed(KeyCode::Escape) && state.offer.is_some_and(|offer| offer != Offer::Leave) {
state.offer = None;
state.feedback.clear();
} else if pressed(KeyCode::Escape) {
let at_stake = unsaved_work(&state, &sim.0);
if ask_first(
&mut state,
Offer::Leave,
at_stake,
Offer::Leave.warning(tr, *device),
) {
next_screen.set(Screen::Menu);
}
}
}
pub(super) fn recover(
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: &BoardSprites,
tr: &Tr,
) {
playtest::end_playtest(sim, state, commands, sprites);
state.offer = None;
state.drop_check();
state.edited(&mut sim.0);
state.feedback = tr.ed_failed.into();
}
pub fn rebuild_statics(
mut commands: Commands,
sim: Res<Sim>,
art: Res<crate::app::art::Art>,
mut state: ResMut<EditorState>,
statics: Query<Entity, With<crate::app::board_render::BoardStatic>>,
) {
if !state.dirty {
return;
}
state.dirty = false;
for entity in &statics {
commands.entity(entity).despawn();
}
crate::app::board_render::spawn_static_board(&mut commands, &sim.0, &art);
spawn_bench_marks(&mut commands, &sim.0, &state, &art);
}
#[derive(Component)]
pub struct SolutionGhost;
fn spawn_bench_marks(
commands: &mut Commands,
board: &Board,
state: &EditorState,
art: &crate::app::art::Art,
) {
use crate::app::board_render::{BoardStatic, FENCE_DEPTH};
use crate::app::layout::{self, TILE};
let oversample = layout::MAX_ZOOM;
let ink = crate::app::palette::DARK_INK.with_alpha(0.6);
let label = |commands: &mut Commands, text: String, at: Vec2| {
commands.spawn((
BoardStatic,
Text2d::new(text),
crate::app::menu_ui::display_font(18.0 * oversample),
TextColor(ink),
Transform::from_translation(at.extend(layout::z::WALL))
.with_scale(Vec3::splat(1.0 / oversample)),
));
};
let rim = TILE / 2.0 + FENCE_DEPTH + 12.0;
for x in 0..board.width() {
let top = layout::tile_center(board, x, 0);
label(commands, x.to_string(), top + Vec2::new(0.0, rim));
}
for y in 0..board.height() {
let left = layout::tile_center(board, 0, y);
label(commands, y.to_string(), left - Vec2::new(rim, 0.0));
}
if state.is_testing() {
return;
}
spawn_mirror_lines(commands, board, state.symmetry);
let size = Some(Vec2::splat(layout::SIGN_SIZE));
for &(x, y, dir, seat) in &state.solution {
if x >= board.width() || y >= board.height() {
continue;
}
let color = crate::app::palette::player_color(seat).lighter(0.2);
commands
.spawn((
BoardStatic,
SolutionGhost,
Sprite {
image: art.sign_shape.clone(),
color: color.with_alpha(0.22),
custom_size: size,
..default()
},
Transform::from_translation(
layout::tile_center(board, x, y).extend(layout::z::SIGNPOST - 0.1),
)
.with_rotation(layout::dir_rotation(dir)),
))
.with_child((
Sprite {
image: art.sign_paint.clone(),
color: color.with_alpha(0.5),
custom_size: size,
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
));
}
}
#[derive(Component)]
pub struct MirrorLine;
fn spawn_mirror_lines(commands: &mut Commands, board: &Board, symmetry: Symmetry) {
use crate::app::board_render::BoardStatic;
use crate::app::layout::{self, TILE};
let first = layout::tile_center(board, 0, 0);
let last = layout::tile_center(board, board.width() - 1, board.height() - 1);
let centre = (first + last) / 2.0;
let (w, h) = (
f32::from(board.width()) * TILE,
f32::from(board.height()) * TILE,
);
let ink = crate::app::palette::player_color(0).with_alpha(0.45);
let mut line = |size: Vec2, turn: f32| {
commands.spawn((
BoardStatic,
MirrorLine,
Sprite {
color: ink,
custom_size: Some(size),
..default()
},
Transform::from_translation(centre.extend(layout::z::SIGNPOST - 0.2))
.with_rotation(Quat::from_rotation_z(turn)),
));
};
let (down, across, mark) = match symmetry {
Symmetry::Off => (false, false, false),
Symmetry::LeftRight => (true, false, false),
Symmetry::TopBottom => (false, true, false),
Symmetry::Both => (true, true, false),
Symmetry::HalfTurn => (false, false, true),
};
if down {
line(Vec2::new(4.0, h), 0.0);
}
if across {
line(Vec2::new(w, 4.0), 0.0);
}
if mark {
line(Vec2::splat(TILE * 0.3), std::f32::consts::FRAC_PI_4);
}
}
#[cfg(test)]
mod tests {
use super::shelf::{ascii_era_path, save_path};
use super::*;
use crate::sim::{CrabKind, Direction, Handedness, TileKind};
fn sand() -> Board {
Board::new(6, 5, 3)
}
pub(super) fn bench_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<crate::app::LevelSaved>();
app.add_message::<crate::app::CodeShared>();
app.add_message::<crate::app::CodeTaken>();
app.add_message::<bevy::input::keyboard::KeyboardInput>();
app.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<crate::app::keycaps::KeyCaps>();
app.init_resource::<crate::app::cursor::LiveSeatKeys>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<crate::app::gamepad::PadFace>();
app.init_resource::<crate::app::gamepad::PadSeats>();
app.init_resource::<LastDevice>();
app.init_resource::<crate::app::Controllers>();
app.init_resource::<EditorOpening>();
app
}
pub(super) fn tap(app: &mut App, key: KeyCode) {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
app.update();
}
fn tap_and_settle(app: &mut App, key: KeyCode) {
tap(app, key);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.update();
}
#[test]
fn a_saved_level_carries_the_name_it_was_given() {
let level = Level::from_board("Gull Alley", 3, sand());
assert_eq!(level.name, "Gull Alley");
let back = Level::parse(&level.to_text()).expect("round trip");
assert_eq!(back.name, "Gull Alley");
}
#[test]
fn the_level_certified_is_the_level_saved() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
board.spawn_gull(3, 3, Direction::Right);
board.remove_gulls_at(3, 3);
board.spawn_gull(4, 4, Direction::Right);
board.remove_gulls_at(4, 4);
board.spawn_gull(1, 3, Direction::Right);
let state = EditorState {
posts: 3,
kind: LevelKind::Puzzle,
..EditorState::default()
};
let level = level_here(&state, &board, "Stage");
let saved = Level::parse(&level.to_text()).expect("round trip");
assert_eq!(
level.board().state_hash(),
saved.board().state_hash(),
"the board certified is the board saved"
);
assert!(
!level.board().rules().castle_raids,
"a stage plays without raids"
);
assert!(level.to_text().contains("raids: off"));
}
#[test]
fn a_beach_from_the_editor_keeps_its_raids() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(5, 4, TileKind::Castle(1));
let state = EditorState {
posts: 3,
kind: LevelKind::Arena,
..EditorState::default()
};
let beach = level_here(&state, &board, "Beach");
assert!(beach.board().rules().castle_raids);
assert!(!beach.to_text().contains("raids:"));
}
#[test]
fn a_stage_turned_beach_plays_with_raids() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(5, 4, TileKind::Castle(1));
let puzzle = EditorState {
posts: 3,
kind: LevelKind::Puzzle,
..EditorState::default()
};
let pasted = Level::parse(&level_here(&puzzle, &board, "Stage").to_text())
.expect("reads back")
.board();
assert!(!pasted.rules().castle_raids, "the stage's own rule");
let arena = EditorState {
kind: LevelKind::Arena,
..puzzle
};
let beach = level_here(&arena, &pasted, "Beach");
let rules = *beach.board().rules();
let stock = crate::sim::Rules::default();
assert!(rules.castle_raids, "a beach raids");
assert_eq!(
(rules.signpost_cap, rules.cap_policy),
(stock.signpost_cap, stock.cap_policy),
"and plays the stock rule, not the stage's grant"
);
assert!(!beach.to_text().contains("raids:"));
}
#[test]
fn the_toggle_sets_the_kind_and_the_rule() {
use crate::sim::CapPolicy;
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(5, 4, TileKind::Castle(1));
let state = |kind| EditorState {
posts: 3,
kind,
..EditorState::default()
};
let puzzle = level_here(&state(LevelKind::Puzzle), &board, "Stage");
assert_eq!(puzzle.kind, LevelKind::Puzzle, "two castles, still a stage");
assert_eq!(
puzzle.board().signpost_rule(),
(3, CapPolicy::Reject),
"a stage grants what it grants"
);
let arena = level_here(&state(LevelKind::Arena), &board, "Beach");
assert_eq!(arena.kind, LevelKind::Arena);
assert_eq!(
arena.board().signpost_rule(),
board.signpost_rule(),
"a beach keeps the versus rule"
);
assert_eq!(arena.board().signpost_rule().1, CapPolicy::Evict);
let back = Level::parse(&arena.to_text()).expect("round trip");
assert_eq!(back.kind, LevelKind::Arena);
assert_eq!(back.board().signpost_rule(), arena.board().signpost_rule());
}
#[test]
fn a_resize_keeps_the_level_and_z_takes_edits_back() {
use crate::app::i18n::EN;
let (mut app, cursor) = painting_app((1, 1));
{
let world = app.world_mut();
let board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().begin_history(&board);
}
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
let size = |app: &App| {
let board = &app.world().resource::<Sim>().0;
(board.width(), board.height())
};
let feedback = |app: &App| app.world().resource::<EditorState>().feedback.clone();
hold(&mut app, KeyCode::KeyR);
step_to(&mut app, cursor, (2, 1));
step_to(&mut app, cursor, (3, 1));
tap(&mut app, KeyCode::Space);
let sand = size(&app);
tap(&mut app, KeyCode::F5);
assert_eq!(
size(&app),
(9, 7),
"up from 6x5, off the list, to its first"
);
let (dx, dy) = (1, 1);
for x in 1..=3 {
assert_eq!(tile(&app, x + dx, 1 + dy), TileKind::Rock, "x {x}");
}
let at = app.world().get::<Cursor>(cursor).expect("cursor");
assert_eq!((at.x, at.y), (3 + dx, 1 + dy), "the cursor kept its tile");
assert_eq!(
feedback(&app),
fill(EN.ed_resized, &[("w", "9"), ("h", "7")])
);
tap(&mut app, KeyCode::KeyZ);
assert_eq!(size(&app), sand, "Z takes the resize back");
assert_eq!(feedback(&app), EN.ed_undone);
tap(&mut app, KeyCode::KeyZ);
for x in 1..=3 {
assert_eq!(tile(&app, x, 1), TileKind::Empty, "the stroke went whole");
}
tap(&mut app, KeyCode::KeyZ);
assert_eq!(feedback(&app), EN.ed_no_undo);
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::ShiftLeft);
keys.press(KeyCode::KeyZ);
app.update();
assert_eq!(feedback(&app), EN.ed_redone);
assert_eq!(tile(&app, 2, 1), TileKind::Rock);
assert_eq!(size(&app), sand);
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::ShiftLeft);
keys.press(KeyCode::F5);
app.update();
assert_eq!(size(&app), (20, 13));
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(0, 0, TileKind::Rock);
tap(&mut app, KeyCode::F5);
assert_eq!(size(&app), (9, 7));
assert_eq!(
feedback(&app),
fill(EN.ed_resized_cut, &[("w", "9"), ("h", "7")])
);
}
#[test]
fn leaving_with_an_unsaved_level_asks_twice() {
let mut app = bench_app();
app.insert_state(Screen::Editor);
app.add_systems(Update, (editor_input, editor_commands).chain());
app.add_systems(OnExit(Screen::Editor), exit_editor);
let name = "Leave Probe";
app.world_mut().resource_mut::<EditorState>().name = name.to_string();
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
tap_and_settle(&mut app, KeyCode::Escape);
assert_eq!(screen(&app), Screen::Editor, "the first press only asks");
assert_eq!(
app.world().resource::<EditorState>().feedback,
crate::app::i18n::EN.ed_leave_confirm
);
assert!(app.world().resource::<Sim>().0.wrap(), "the level stays");
tap_and_settle(&mut app, KeyCode::F6);
tap_and_settle(&mut app, KeyCode::Escape);
assert_eq!(screen(&app), Screen::Editor, "asked again");
tap_and_settle(&mut app, KeyCode::Escape);
assert_eq!(screen(&app), Screen::Menu, "the second press leaves");
app.insert_state(Screen::Editor);
app.update();
let mut state = app.world_mut().resource_mut::<EditorState>();
state.name = name.to_string();
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
let path = save_path(name);
let _ = std::fs::remove_file(&path);
tap_and_settle(&mut app, KeyCode::F2);
assert!(std::fs::read_to_string(&path).is_ok_and(|text| text.contains(name)));
tap_and_settle(&mut app, KeyCode::Escape);
assert_eq!(screen(&app), Screen::Menu, "nothing unsaved to ask about");
let _ = std::fs::remove_file(&path);
}
#[test]
fn saving_over_another_levels_file_asks_twice() {
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands));
let name = "Пляж Overwrite Probe";
app.world_mut().resource_mut::<EditorState>().name = name.to_string();
let path = save_path(name);
crate::app::paths::write_atomic(&path, "name: Their Beach\n").expect("wrote");
let on_disk = || std::fs::read_to_string(&path).expect("reads");
tap(&mut app, KeyCode::F2);
assert_eq!(on_disk(), "name: Their Beach\n", "the first press asks");
assert_eq!(
app.world().resource::<EditorState>().feedback,
fill(
crate::app::i18n::EN.ed_save_taken,
&[("name", "Their Beach")]
),
"naming the level it would replace"
);
tap(&mut app, KeyCode::KeyO);
tap(&mut app, KeyCode::F2);
assert_eq!(on_disk(), "name: Their Beach\n", "asked again");
tap(&mut app, KeyCode::F2);
assert!(on_disk().contains(name), "the second press saves");
app.world_mut().resource_mut::<Sim>().0.set_wrap(false);
tap(&mut app, KeyCode::F2);
assert!(
app.world().resource::<EditorState>().offer.is_none(),
"no question about its own file"
);
let _ = std::fs::remove_file(&path);
let name = "Café Legacy Probe";
let legacy = ascii_era_path(name);
assert!(legacy.ends_with("caf-legacy-probe.txt"), "{legacy:?}");
crate::app::paths::write_atomic(&legacy, format!("name: {name}\n")).expect("wrote");
app.world_mut().resource_mut::<EditorState>().name = name.to_string();
tap(&mut app, KeyCode::F2);
assert!(save_path(name).exists(), "saved under its whole name");
assert!(!legacy.exists(), "and the old file tidied away");
let _ = std::fs::remove_file(save_path(name));
}
#[test]
fn a_namesake_saved_elsewhere_is_asked_about() {
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands));
let name = "Namesake Probe";
app.world_mut().resource_mut::<EditorState>().name = name.to_string();
let path = save_path(name);
let theirs = format!("name: {name}\nold: true\n");
crate::app::paths::write_atomic(&path, &theirs).expect("wrote");
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
tap(&mut app, KeyCode::F2);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), theirs);
assert_eq!(
app.world().resource::<EditorState>().offer,
Some(Offer::Overwrite),
"the first press asks"
);
tap(&mut app, KeyCode::F2);
assert!(
!std::fs::read_to_string(&path)
.expect("reads")
.contains("old: true")
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_fresh_bench_takes_a_name_no_saved_level_has() {
let lang = crate::app::i18n::Lang::It;
let tr = lang.tr();
let settings = GameSettings {
language: lang,
..GameSettings::default()
};
let mut app = bench_app();
app.insert_resource(settings);
app.insert_state(Screen::Editor);
app.add_systems(OnEnter(Screen::Editor), enter_editor);
app.add_systems(Update, (editor_input, editor_commands).chain());
let base = tr.ed_default_name;
let second = format!("{base} 2");
let third = format!("{base} 3");
for name in [base, &second, &third] {
let _ = std::fs::remove_file(save_path(name));
}
crate::app::paths::write_atomic(&save_path(base), "name: x\n").expect("wrote");
app.update();
let name = |app: &App| app.world().resource::<EditorState>().name.clone();
assert_eq!(name(&app), second, "the default is taken");
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
tap(&mut app, KeyCode::F2);
let saved = std::fs::read_to_string(save_path(&second)).expect("saved");
{
let world = app.world_mut();
let board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().start_afresh(tr, &board);
}
assert_eq!(name(&app), third, "a fresh map, a fresh name");
assert!(app.world().resource::<EditorState>().mine.is_empty());
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(1, 1, TileKind::Rock);
tap(&mut app, KeyCode::F2);
assert_eq!(
std::fs::read_to_string(save_path(&second)).expect("still there"),
saved,
"the level saved before the fresh map is left alone"
);
assert!(save_path(&third).exists());
app.world_mut().resource_mut::<EditorState>().name = second.clone();
tap(&mut app, KeyCode::F2);
assert_eq!(
app.world().resource::<EditorState>().offer,
Some(Offer::Overwrite)
);
for name in [base, &second, &third] {
let _ = std::fs::remove_file(save_path(name));
}
}
#[test]
fn a_fresh_map_or_a_kind_flip_forgets_a_pasted_goal() {
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands).chain());
let goal = |app: &App| app.world().resource::<EditorState>().goal;
app.world_mut().resource_mut::<EditorState>().goal = Goal::Bank(4);
app.world_mut()
.resource_mut::<EditorState>()
.start_afresh(&crate::app::i18n::EN, &sand());
assert_eq!(goal(&app), Goal::AllCrabs, "a fresh map");
app.world_mut().resource_mut::<EditorState>().goal = Goal::Survive;
tap(&mut app, KeyCode::F6);
assert_eq!(goal(&app), Goal::AllCrabs, "F6");
}
fn paste_on_f4(
code: Res<TestCode>,
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
bench: Workbench,
mut news: EditorNews,
mut swap: BoardSwap,
) {
let Workbench { mut sim, mut state } = bench;
if keys.just_pressed(KeyCode::F4) {
share::paste_level(
&mut state,
&mut sim,
crate::share::decode(&code.0),
(settings.tr(), LastDevice::Keyboard),
&mut news.taken,
&mut swap,
);
}
}
#[derive(Resource)]
struct TestCode(String);
fn code_of(text: &str) -> TestCode {
TestCode(crate::share::encode(
crate::share::Kind::Level,
text.as_bytes(),
))
}
#[test]
fn a_pasted_level_saves_with_its_own_lines() {
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands, paste_on_f4).chain());
let beach = "name: Seats Probe\nposts: 3\nkind: arena\nwrap: on\nseats: 4 6\nroll: 3\n\
spawner: 1,1 R 60\nmap:\n\
+ + + + + + +\n 0 1 2 3 4 5 \n+ + + + + + +\n . . . . . . \n+ + + + + + +\n";
app.insert_resource(code_of(beach));
tap(&mut app, KeyCode::F4);
let path = save_path("Seats Probe");
let _ = std::fs::remove_file(&path);
tap(&mut app, KeyCode::F2);
let filed = std::fs::read_to_string(&path).expect("saved");
assert!(filed.contains("seats: 4 6"), "{filed}");
assert!(filed.contains("roll: 3"), "{filed}");
let _ = std::fs::remove_file(&path);
let mut board = sand();
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Down, Handedness::Left, CrabKind::Common);
let mut stage = Level::from_board("Solution Probe", 1, board);
stage.solution = vec![(1, 2, Direction::Right, 0)];
app.insert_resource(code_of(&stage.to_text()));
tap(&mut app, KeyCode::F4);
let path = save_path("Solution Probe");
let _ = std::fs::remove_file(&path);
tap(&mut app, KeyCode::F2);
let filed = std::fs::read_to_string(&path).expect("saved");
assert!(filed.contains("solution: 1,2 R"), "{filed}");
tap(&mut app, KeyCode::KeyO);
tap(&mut app, KeyCode::F2);
let filed = std::fs::read_to_string(&path).expect("saved");
assert!(!filed.contains("solution:"), "an edit lets it go: {filed}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_paste_checks_the_level_the_bench_will_save() {
let mut app = bench_app();
app.add_systems(Update, paste_on_f4);
let mut board = sand();
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let code = Level::from_board("Raids Probe", 1, board).to_text();
assert!(!code.contains("raids: off"), "the code leaves raids on");
app.insert_resource(code_of(&code));
tap(&mut app, KeyCode::F4);
let world = app.world();
let state = world.resource::<EditorState>();
let checking = &state.solver.as_ref().expect("a check").level;
let bench = checked_level(state, &world.resource::<Sim>().0).to_text();
assert_eq!(checking, &bench);
assert!(checking.contains("raids: off"), "{checking}");
}
#[test]
fn a_wall_in_front_of_a_creature_turns_it_on_the_bench() {
let mut board = sand();
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
board.spawn_gull(3, 3, Direction::Right);
let mut app = bench_app();
app.insert_resource(Sim(board));
let mut cursor = Cursor::seated(0);
(cursor.x, cursor.y) = (2, 2);
let cursor = app.world_mut().spawn((cursor, Transform::default())).id();
app.add_systems(Update, editor_input);
tap(&mut app, KeyCode::ArrowRight);
app.world_mut().get_mut::<Cursor>(cursor).expect("cursor").x = 3;
app.world_mut().get_mut::<Cursor>(cursor).expect("cursor").y = 3;
tap(&mut app, KeyCode::ArrowRight);
let world = app.world();
let (state, bench) = (world.resource::<EditorState>(), &world.resource::<Sim>().0);
assert_ne!(bench.crabs()[0].dir, Direction::Right, "the crab turned");
assert_ne!(bench.gulls()[0].dir, Direction::Right, "the gull turned");
let filed = level_here(state, bench, "Facing");
let read = filed.board();
assert_eq!(read.crabs()[0].dir, bench.crabs()[0].dir);
assert_eq!(read.gulls()[0].dir, bench.gulls()[0].dir);
assert_eq!(
level_here(state, &read, "Facing").to_text(),
filed.to_text(),
"read back, the same level"
);
}
#[test]
fn a_save_names_its_file_and_warns() {
use crate::app::i18n::EN;
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands, poll_solver).chain());
let name = "Warning Probe";
app.world_mut().resource_mut::<EditorState>().name = name.into();
app.world_mut().resource_mut::<Sim>().0.spawn_crab(
1,
1,
Direction::Right,
Handedness::Left,
CrabKind::Common,
);
let path = save_path(name);
let _ = std::fs::remove_file(&path);
tap(&mut app, KeyCode::F2);
let said = app.world().resource::<EditorState>().feedback.clone();
let file = path.file_name().expect("a file").to_string_lossy();
let filed = fill(EN.ed_saved_to, &[("file", &file)]);
assert_eq!(said, format!("{} - {filed}", EN.ed_puzzle_no_castle));
assert!(!said.contains('/'), "no folders: {said}");
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(4, 1, TileKind::Castle(0));
tap(&mut app, KeyCode::F2);
let said = app.world().resource::<EditorState>().feedback.clone();
let done = format!("{} - {filed}", EN.ed_solvable_free);
assert!(
said == format!("{} - {filed}", EN.ed_validating) || said == done,
"{said}"
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
loop {
app.update();
if app.world().resource::<EditorState>().solver.is_none() {
break;
}
std::thread::yield_now();
}
assert_eq!(
app.world().resource::<EditorState>().feedback,
format!("{} - {filed}", EN.ed_solvable_free)
);
tap(&mut app, KeyCode::F2);
assert_eq!(app.world().resource::<EditorState>().feedback, filed);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_save_check_writes_the_solution_it_finds() {
let mut app = bench_app();
app.add_systems(Update, (editor_input, editor_commands, poll_solver).chain());
let name = "Save Check Probe";
{
let mut state = app.world_mut().resource_mut::<EditorState>();
state.name = name.into();
state.posts = 1;
}
{
let board = &mut app.world_mut().resource_mut::<Sim>().0;
board.set_tile(3, 1, TileKind::Castle(0));
for x in 0..6 {
board.set_wall(x, 2, Direction::Down, true);
if x != 3 {
board.set_wall(x, 2, Direction::Up, true);
}
}
board.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
}
let path = save_path(name);
let _ = std::fs::remove_file(&path);
tap(&mut app, KeyCode::F2);
assert!(
!std::fs::read_to_string(&path)
.expect("saved")
.contains("solution:")
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
while app.world().resource::<EditorState>().solver.is_some() {
app.update();
std::thread::yield_now();
}
let text = std::fs::read_to_string(&path).expect("saved");
assert!(text.contains("solution: 3,2 U"), "{text}");
let state = app.world().resource::<EditorState>();
let board = &app.world().resource::<Sim>().0;
assert!(!unsaved_work(state, board), "and is filed");
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_posts_dial_is_inert_on_a_beach() {
let mut app = bench_app();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().posts = 3;
tap(&mut app, KeyCode::Equal);
assert_eq!(app.world().resource::<EditorState>().posts, 4, "a stage");
tap(&mut app, KeyCode::F6); tap(&mut app, KeyCode::Equal);
tap(&mut app, KeyCode::Minus);
assert_eq!(app.world().resource::<EditorState>().posts, 4, "untouched");
tap(&mut app, KeyCode::F6); tap(&mut app, KeyCode::Equal);
assert_eq!(app.world().resource::<EditorState>().posts, 5, "waiting");
}
#[test]
fn the_playtest_plays_the_level_the_file_will_hold() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
board.spawn_gull(3, 3, Direction::Right);
board.remove_gulls_at(3, 3);
board.spawn_gull(1, 3, Direction::Right);
let editing = board.state_hash();
let mut app = bench_app();
app.insert_resource(Sim(board.clone()));
app.insert_resource(EditorState {
posts: 3,
name: "Stage".into(),
..EditorState::default()
});
app.world_mut()
.spawn((Cursor::seated(0), Transform::default()));
app.add_systems(
Update,
(
editor_commands.run_if(|state: Res<EditorState>| !state.is_testing()),
editor_test_input.run_if(editor_testing),
)
.chain(),
);
tap(&mut app, KeyCode::Enter);
let state = app.world().resource::<EditorState>();
assert!(state.is_testing());
let played = &app.world().resource::<Sim>().0;
let filed = level_here(state, &board, "Stage").board();
assert_eq!(played.state_hash(), filed.state_hash(), "the file's level");
assert!(!played.rules().castle_raids, "a stage plays without raids");
assert_ne!(played.state_hash(), editing, "not the board as it stood");
tap(&mut app, KeyCode::Escape);
assert!(!app.world().resource::<EditorState>().is_testing());
assert_eq!(
app.world().resource::<Sim>().0.state_hash(),
editing,
"the board being edited comes back as it was"
);
}
#[test]
fn a_playtest_redraws_its_creatures_both_ways() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Giant);
board.spawn_crab(3, 2, Direction::Right, Handedness::Right, CrabKind::Common);
let mut app = bench_app();
app.insert_resource(Sim(board));
app.insert_resource(EditorState {
posts: 3,
name: "Stage".into(),
..EditorState::default()
});
app.world_mut()
.spawn((Cursor::seated(0), Transform::default()));
app.add_systems(
Update,
(
editor_commands.run_if(|state: Res<EditorState>| !state.is_testing()),
editor_test_input.run_if(editor_testing),
)
.chain(),
);
let draw = |app: &mut App| {
let world = app.world_mut();
let crab = crate::app::creatures::CrabSprite {
id: 1,
kind: CrabKind::Common,
shade: 0.0,
};
world.spawn(crab);
world.spawn(crate::app::creatures::GullSprite(0));
};
let drawn = |app: &mut App| {
let world = app.world_mut();
let crabs = world
.query::<&crate::app::creatures::CrabSprite>()
.iter(world)
.count();
let gulls = world
.query::<&crate::app::creatures::GullSprite>()
.iter(world)
.count();
crabs + gulls
};
draw(&mut app);
tap(&mut app, KeyCode::Enter);
assert!(app.world().resource::<EditorState>().is_testing());
assert_eq!(drawn(&mut app), 0, "the edited board's sprites went");
draw(&mut app);
tap(&mut app, KeyCode::Escape);
assert!(!app.world().resource::<EditorState>().is_testing());
assert_eq!(drawn(&mut app), 0, "and so did the playtest's");
}
#[test]
fn a_resize_drops_the_check_on_the_old_beach() {
let mut app = bench_app();
app.insert_resource(EditorState {
solver: Some(Validation::pending()),
..EditorState::default()
});
app.add_systems(Update, editor_input);
tap(&mut app, KeyCode::F5);
let width = app.world().resource::<Sim>().0.width();
assert_ne!(width, sand().width(), "the blank beach was resized");
assert!(app.world().resource::<EditorState>().solver.is_none());
}
#[test]
fn the_flock_dial_steps_from_the_board_it_is_on() {
use setting::GULL_PERIODS;
let mut app = bench_app();
app.add_systems(Update, editor_commands);
let period = |app: &mut App, was: u32| {
app.world_mut()
.resource_mut::<Sim>()
.0
.rules_mut()
.gull_period = was;
tap(app, KeyCode::KeyK);
app.world().resource::<Sim>().0.rules().gull_period
};
assert_eq!(period(&mut app, 0), GULL_PERIODS[1]);
assert_eq!(period(&mut app, GULL_PERIODS[1]), GULL_PERIODS[2]);
assert_eq!(period(&mut app, GULL_PERIODS[3]), 0, "and round to off");
assert_eq!(
period(&mut app, 333),
GULL_PERIODS[2],
"off the dial, beside it"
);
}
#[test]
fn committing_a_name_does_not_start_a_playtest() {
let mut app = bench_app();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().mode = Mode::JustClosed;
let press = |app: &mut App| tap(app, KeyCode::Enter);
press(&mut app);
let state = app.world().resource::<EditorState>();
assert!(!state.is_testing(), "the naming Enter began a playtest");
assert!(
matches!(state.mode, Mode::Painting),
"the latch is for one frame"
);
press(&mut app);
assert!(
app.world().resource::<EditorState>().is_testing(),
"the next Enter is a real one"
);
}
#[test]
fn escape_in_the_name_box_puts_the_old_name_back() {
let mut app = bench_app();
app.insert_state(Screen::Editor);
app.add_systems(
Update,
(editor_input, editor_commands.run_if(not(editor_has_keys))).chain(),
);
app.world_mut().resource_mut::<EditorState>().name = "Rock Pool".into();
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
tap_and_settle(&mut app, KeyCode::F1);
assert!(app.world().resource::<EditorState>().is_naming());
app.world_mut().resource_mut::<EditorState>().name = "Rock Po".into();
tap_and_settle(&mut app, KeyCode::Escape);
let state = app.world().resource::<EditorState>();
assert_eq!(state.name, "Rock Pool", "Escape abandons the edit");
assert!(matches!(state.mode, Mode::Painting));
assert_eq!(state.offer, None, "and is not also a press to leave");
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Editor
);
tap_and_settle(&mut app, KeyCode::F1);
app.world_mut().resource_mut::<EditorState>().name = " Tide Line ".into();
tap_and_settle(&mut app, KeyCode::Enter);
let state = app.world().resource::<EditorState>();
assert_eq!(state.name, "Tide Line", "Enter commits, tidied");
assert!(!state.is_testing(), "and is not also a playtest");
}
#[test]
fn an_edit_drops_a_check_of_the_board_before_it() {
let mut app = bench_app();
app.insert_state(Screen::Editor);
app.add_systems(Update, editor_commands);
let keys = [
KeyCode::KeyO,
KeyCode::KeyK,
KeyCode::Equal,
KeyCode::Minus,
KeyCode::F6,
];
for key in keys {
{
let mut state = app.world_mut().resource_mut::<EditorState>();
state.solver = Some(Validation::pending());
}
tap(&mut app, key);
let state = app.world().resource::<EditorState>();
assert!(state.solver.is_none(), "{key:?} kept the stale check");
}
}
#[test]
fn the_posts_dial_answers_the_keys_that_say_plus_and_minus() {
let mut app = bench_app();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().posts = 3;
let posts_after = |app: &mut App, key: KeyCode| {
tap(app, key);
app.world().resource::<EditorState>().posts
};
assert_eq!(posts_after(&mut app, KeyCode::NumpadAdd), 4);
assert_eq!(posts_after(&mut app, KeyCode::NumpadSubtract), 3);
assert_eq!(
posts_after(&mut app, KeyCode::Equal),
4,
"the US pair still works"
);
assert_eq!(posts_after(&mut app, KeyCode::Minus), 3);
assert_eq!(
posts_after(&mut app, KeyCode::BracketRight),
3,
"QWERTY's ]"
);
app.world_mut()
.resource_mut::<crate::app::keycaps::KeyCaps>()
.force(Some(crate::app::keycaps::Layout::Qwertz));
assert_eq!(
posts_after(&mut app, KeyCode::BracketRight),
4,
"QWERTZ's +"
);
assert_eq!(posts_after(&mut app, KeyCode::Slash), 3, "QWERTZ's -");
assert_eq!(posts_after(&mut app, KeyCode::Digit6), 3, "a 6 on QWERTZ");
app.world_mut()
.resource_mut::<crate::app::keycaps::KeyCaps>()
.force(Some(crate::app::keycaps::Layout::Azerty));
assert_eq!(posts_after(&mut app, KeyCode::Digit6), 2, "AZERTY's -");
let mut learned = crate::app::keycaps::KeyCaps::default();
learned.learn(KeyCode::KeyW, 'z');
app.insert_resource(learned);
assert_eq!(
posts_after(&mut app, KeyCode::Digit6),
1,
"AZERTY's -, learned"
);
assert_eq!(
posts_after(&mut app, KeyCode::Digit5),
1,
"the rest of the row"
);
app.world_mut().resource_mut::<EditorState>().posts = 3;
app.world_mut()
.resource_mut::<crate::app::keycaps::KeyCaps>()
.force(Some(crate::app::keycaps::Layout::Qwertz));
app.world_mut().resource_mut::<EditorState>().posts = 9;
assert_eq!(posts_after(&mut app, KeyCode::BracketRight), 9);
assert_eq!(posts_after(&mut app, KeyCode::NumpadAdd), 9);
}
#[test]
fn a_rebound_movement_key_does_not_also_paint() {
use crate::app::binds::Action;
let mut settings = GameSettings::default();
for (action, key) in [
(Action::MoveUp, KeyCode::KeyE),
(Action::MoveLeft, KeyCode::KeyS),
(Action::MoveDown, KeyCode::KeyD),
(Action::MoveRight, KeyCode::KeyF),
] {
settings.binds[0].set(action, key);
}
let mut app = bench_app();
app.insert_resource(settings);
app.world_mut()
.spawn((Cursor::seated(0), Transform::default()));
app.add_systems(Update, editor_input);
tap(&mut app, KeyCode::KeyE);
assert_eq!(
app.world().resource::<Sim>().0.tile_at(0, 0),
TileKind::Empty
);
assert_ne!(app.world().resource::<EditorState>().brush, Brush::Weed);
tap(&mut app, KeyCode::KeyR);
assert_eq!(
app.world().resource::<Sim>().0.tile_at(0, 0),
TileKind::Rock
);
}
#[test]
fn arrows_bound_to_movement_leave_walls_and_playtest_on_the_place_keys() {
use crate::app::binds::Action;
let mut settings = GameSettings::default();
let swapped = [
(Action::MoveUp, KeyCode::ArrowUp),
(Action::MoveDown, KeyCode::ArrowDown),
(Action::MoveLeft, KeyCode::ArrowLeft),
(Action::MoveRight, KeyCode::ArrowRight),
(Action::PlaceUp, KeyCode::KeyW),
(Action::PlaceDown, KeyCode::KeyS),
(Action::PlaceLeft, KeyCode::KeyA),
(Action::PlaceRight, KeyCode::KeyD),
(Action::Remove, KeyCode::KeyX),
];
for (action, key) in swapped {
settings.binds[0].set(action, key);
}
let caps = crate::app::keycaps::KeyCaps::default();
let stock = GameSettings::default();
use crate::app::i18n::EN;
let walls = spell_editor_keys(EN.ed_prompt, &EN, &caps, &stock);
assert!(walls.contains("arrow keys walls"), "{walls}");
let walls = spell_editor_keys(EN.ed_prompt, &EN, &caps, &settings);
assert!(walls.contains("| WASD walls |"), "{walls}");
let test = spell_editor_keys(EN.ed_playtest_prompt, &EN, &caps, &stock);
assert_eq!(
test,
"arrow keys place | Space remove | R restart | Esc end test"
);
let test = spell_editor_keys(EN.ed_playtest_prompt, &EN, &caps, &settings);
assert_eq!(test, "WASD place | X remove | R restart | Esc end test");
let painting = EN.ed_prompt.replacen("WASD", "{moves}", 1);
let moves = spell_editor_keys(&painting, &EN, &caps, &stock);
assert!(
moves.starts_with("WASD move | arrow keys walls |"),
"{moves}"
);
let moves = spell_editor_keys(&painting, &EN, &caps, &settings);
assert!(moves.starts_with("Up/Left/Down/Right move |"), "{moves}");
let mut app = bench_app();
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
app.insert_resource(Sim(board));
app.insert_resource(EditorState {
posts: 3,
name: "Stage".into(),
..EditorState::default()
});
app.insert_resource(settings);
let mut cursor = Cursor::seated(0);
(cursor.x, cursor.y) = (2, 2);
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),
)
.chain(),
);
let wall = |app: &App| app.world().resource::<Sim>().0.wall_at(2, 2, Direction::Up);
tap(&mut app, KeyCode::ArrowUp);
assert!(!wall(&app), "the arrow is P1's move key");
tap(&mut app, KeyCode::KeyW);
assert!(wall(&app), "P1's place key builds the wall");
tap(&mut app, KeyCode::KeyW);
assert!(!wall(&app), "and takes it down");
tap(&mut app, KeyCode::Enter);
assert!(app.world().resource::<EditorState>().is_testing());
let posts = |app: &App| app.world().resource::<Sim>().0.signpost_count(0);
tap(&mut app, KeyCode::ArrowLeft);
assert_eq!(posts(&app), 0, "the arrow is P1's move key");
tap(&mut app, KeyCode::KeyA);
assert_eq!(posts(&app), 1, "P1's place key");
tap(&mut app, KeyCode::Space);
assert_eq!(posts(&app), 1, "Space is not P1's remove key here");
tap(&mut app, KeyCode::KeyX);
assert_eq!(posts(&app), 0, "P1's remove key");
}
#[test]
fn every_command_has_a_key_in_every_binding_scheme() {
use crate::app::binds::Action;
use crate::app::i18n::EN;
let caps = crate::app::keycaps::KeyCaps::default();
let mut ijkl = GameSettings::default();
for (action, key) in [
(Action::MoveUp, KeyCode::ArrowUp),
(Action::MoveDown, KeyCode::ArrowDown),
(Action::MoveLeft, KeyCode::ArrowLeft),
(Action::MoveRight, KeyCode::ArrowRight),
] {
ijkl.binds[0].set(action, key);
}
ijkl.commit = crate::app::settings::CommitScheme::Ijkl;
let mut letters = GameSettings::default();
for (action, key) in [
(Action::MoveUp, KeyCode::KeyV),
(Action::MoveDown, KeyCode::KeyK),
(Action::MoveLeft, KeyCode::KeyO),
(Action::MoveRight, KeyCode::KeyT),
] {
letters.binds[0].set(action, key);
}
let mut undo_taken = GameSettings::default();
undo_taken.binds[0].set(Action::MoveUp, KeyCode::KeyZ);
for settings in [GameSettings::default(), ijkl.clone(), letters, undo_taken] {
let keys = EditorKeys::of(&settings);
let prompt = spell_editor_keys(EN.ed_prompt, &EN, &caps, &settings);
for command in Command::ALL {
let key = keys.command(command);
assert!(!keys.shadows(key), "{command:?} on {key:?}");
let row =
|setting| card::Row::Setting(setting).key(LastDevice::Keyboard, &keys, &caps);
match command {
Command::Check | Command::Turn | Command::Undo => {
let named = format!("{} ", caps.label(key));
assert!(prompt.contains(&named), "{command:?}: {prompt}");
}
Command::Gulls => assert_eq!(row(Setting::Gulls), caps.label(key)),
Command::Wrap => assert_eq!(row(Setting::Wrap), caps.label(key)),
}
}
assert!(!prompt.contains('{'), "every slot filled: {prompt}");
}
let stock = spell_editor_keys(EN.ed_prompt, &EN, &caps, &GameSettings::default());
assert!(
stock.contains("T turn") && stock.contains("V check"),
"{stock}"
);
assert!(!crate::app::binds::bindable(NEXT_SETTING));
let mut app = bench_app();
app.insert_resource(ijkl);
let mut cursor = Cursor::seated(0);
(cursor.x, cursor.y) = (2, 2);
app.world_mut().spawn((cursor, Transform::default()));
app.add_systems(Update, (editor_input, editor_commands).chain());
let period = |app: &App| app.world().resource::<Sim>().0.rules().gull_period;
tap(&mut app, KeyCode::KeyK);
assert_eq!(period(&app), 0, "K is a wall key here");
assert!(
app.world()
.resource::<Sim>()
.0
.wall_at(2, 2, Direction::Down)
);
tap(&mut app, KeyCode::F7);
assert_eq!(period(&app), setting::GULL_PERIODS[1], "F7 steps the gulls");
tap(&mut app, KeyCode::F8);
assert!(
!app.world().resource::<Sim>().0.wrap(),
"O is free, so F8 is not"
);
tap(&mut app, KeyCode::KeyO);
assert!(app.world().resource::<Sim>().0.wrap(), "O wraps");
}
#[test]
fn escape_over_a_question_only_takes_it_back() {
let mut app = bench_app();
app.insert_state(Screen::Editor);
app.add_systems(Update, (editor_input, editor_commands).chain());
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
let name = "Escape Probe";
app.world_mut().resource_mut::<EditorState>().name = name.into();
let path = save_path(name);
let _ = std::fs::remove_file(&path);
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
tap_and_settle(&mut app, KeyCode::F2);
let other = "Escape Probe Other";
crate::app::paths::write_atomic(&save_path(other), "name: Other\n").expect("wrote");
app.world_mut().resource_mut::<EditorState>().name = other.into();
app.world_mut().resource_mut::<EditorState>().filed = None;
tap_and_settle(&mut app, KeyCode::F2);
assert_eq!(
app.world().resource::<EditorState>().offer,
Some(Offer::Overwrite)
);
tap_and_settle(&mut app, KeyCode::Escape);
assert_eq!(screen(&app), Screen::Editor, "still at the bench");
let state = app.world().resource::<EditorState>();
assert_eq!(state.offer, None, "the question is taken back");
assert!(state.feedback.is_empty(), "{}", state.feedback);
let _ = std::fs::remove_file(save_path(other));
let _ = std::fs::remove_file(&path);
}
#[test]
fn f6_flips_the_kind() {
let mut app = bench_app();
app.add_systems(Update, editor_commands);
let press = |app: &mut App| tap(app, KeyCode::F6);
assert_eq!(
app.world().resource::<EditorState>().kind,
LevelKind::Puzzle
);
press(&mut app);
let state = app.world().resource::<EditorState>();
assert_eq!(state.kind, LevelKind::Arena);
assert_eq!(state.feedback, crate::app::i18n::EN.ed_now_arena);
press(&mut app);
assert_eq!(
app.world().resource::<EditorState>().kind,
LevelKind::Puzzle
);
}
fn painting_app(at: (u8, u8)) -> (App, Entity) {
let mut app = bench_app();
let mut cursor = Cursor::seated(0);
(cursor.x, cursor.y) = at;
let cursor = app.world_mut().spawn((cursor, Transform::default())).id();
app.add_systems(Update, (editor_input, editor_commands).chain());
(app, cursor)
}
fn step_to(app: &mut App, cursor: Entity, to: (u8, u8)) {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.clear();
let mut at = app.world_mut().get_mut::<Cursor>(cursor).expect("cursor");
(at.x, at.y) = to;
app.update();
}
fn hold(app: &mut App, key: KeyCode) {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
}
#[test]
fn holding_a_brush_down_paints_a_stroke() {
let (mut app, cursor) = painting_app((1, 1));
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
hold(&mut app, KeyCode::KeyC);
assert_eq!(tile(&app, 1, 1), TileKind::Castle(0));
step_to(&mut app, cursor, (2, 1));
step_to(&mut app, cursor, (3, 1));
for x in 1..=3 {
assert_eq!(tile(&app, x, 1), TileKind::Castle(0), "x {x}");
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::KeyC);
step_to(&mut app, cursor, (4, 1));
assert_eq!(tile(&app, 4, 1), TileKind::Empty, "let go");
tap(&mut app, KeyCode::KeyX);
hold(&mut app, KeyCode::Space);
step_to(&mut app, cursor, (3, 1));
step_to(&mut app, cursor, (2, 1));
for x in 2..=4 {
assert_eq!(tile(&app, x, 1), TileKind::Empty, "x {x}");
}
assert_eq!(tile(&app, 1, 1), TileKind::Castle(0), "short of the stroke");
step_to(&mut app, cursor, (1, 3));
tap(&mut app, KeyCode::KeyB);
hold(&mut app, KeyCode::KeyB);
step_to(&mut app, cursor, (2, 3));
let board = &app.world().resource::<Sim>().0;
let crabs: Vec<_> = board.crabs().iter().map(|c| (c.kind, c.handed)).collect();
assert_eq!(
crabs,
[
(CrabKind::Common, Handedness::Right),
(CrabKind::Common, Handedness::Right)
],
"the second press's crab, twice"
);
}
#[test]
fn t_turns_what_is_under_the_cursor() {
let (mut app, cursor) = painting_app((2, 2));
{
let board = &mut app.world_mut().resource_mut::<Sim>().0;
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
board.set_wall(2, 2, Direction::Down, true);
}
let facing = |app: &App, x, y| brush::facing(&app.world().resource::<Sim>().0, x, y);
tap(&mut app, KeyCode::KeyT);
assert_eq!(facing(&app, 2, 2), Some(Direction::Left), "past the wall");
tap(&mut app, KeyCode::KeyT);
assert_eq!(facing(&app, 2, 2), Some(Direction::Up));
let world = app.world();
let (state, bench) = (world.resource::<EditorState>(), &world.resource::<Sim>().0);
let read = level_here(state, bench, "Turned").board();
assert_eq!(read.crabs()[0].dir, Direction::Up, "the file agrees");
step_to(&mut app, cursor, (4, 1));
tap(&mut app, KeyCode::KeyH);
tap(&mut app, KeyCode::KeyT);
let tile = app.world().resource::<Sim>().0.tile_at(4, 1);
let TileKind::Spawner(hole) = tile else {
panic!("a hole: {tile:?}");
};
assert_eq!(
(hole.dir, hole.period),
(Direction::Down, brush::SPAWNER_PERIOD)
);
step_to(&mut app, cursor, (0, 4));
tap(&mut app, KeyCode::KeyT);
assert_eq!(
app.world().resource::<Sim>().0.tile_at(0, 4),
TileKind::Empty
);
}
#[test]
fn the_card_keys_choose_and_turn_the_settings() {
let (mut app, cursor) = painting_app((1, 1));
let chosen = |app: &App| Setting::chosen(app.world().resource::<EditorState>());
assert_eq!(chosen(&app), Setting::Signposts);
tap(&mut app, NEXT_SETTING);
assert_eq!(chosen(&app), Setting::Players);
tap(&mut app, NEXT_SETTING);
assert_eq!(chosen(&app), Setting::Goal);
tap(&mut app, KeyCode::Equal);
assert_eq!(app.world().resource::<EditorState>().goal, Goal::Survive);
tap(&mut app, NEXT_SETTING);
tap(&mut app, KeyCode::Equal);
assert_eq!(
app.world().resource::<Sim>().0.rules().round_length,
Some(90 * crate::sim::TICKS_PER_SECOND)
);
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::ShiftLeft);
keys.press(NEXT_SETTING);
}
app.update();
assert_eq!(chosen(&app), Setting::Goal);
for _ in 0..3 {
tap(&mut app, NEXT_SETTING);
}
assert_eq!(chosen(&app), Setting::Holes);
tap(&mut app, KeyCode::Equal);
tap(&mut app, KeyCode::KeyH);
let text = {
let world = app.world();
let state = world.resource::<EditorState>();
level_here(state, &world.resource::<Sim>().0, "Card").to_text()
};
assert!(text.contains("goal: survive"), "{text}");
assert!(text.contains("round: 2700"), "{text}");
assert!(text.contains("spawner: 1,1 R 90"), "{text}");
tap(&mut app, KeyCode::F6);
tap(&mut app, KeyCode::Equal);
let tile = app.world().resource::<Sim>().0.tile_at(1, 1);
assert!(
matches!(tile, TileKind::Spawner(s) if s.period == 120),
"{tile:?}"
);
let gulls = app.world().resource::<Sim>().0.rules().gull_period;
tap(&mut app, KeyCode::KeyK);
assert_eq!(app.world().resource::<Sim>().0.rules().gull_period, gulls);
let _ = cursor;
}
#[test]
fn a_found_solution_is_drawn_saved_and_let_go_by_an_edit() {
let mut board = sand();
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Down, Handedness::Left, CrabKind::Common);
let mut app = bench_app();
app.insert_resource(Sim(board));
app.insert_resource(crate::app::art::Art::blank());
let name = "Ghost Probe";
app.insert_resource(EditorState {
posts: 1,
name: name.into(),
..EditorState::default()
});
app.add_systems(
Update,
(editor_commands, poll_solver, rebuild_statics).chain(),
);
let ghosts = |app: &mut App| {
let world = app.world_mut();
world.query::<&SolutionGhost>().iter(world).count()
};
tap(&mut app, KeyCode::KeyV);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
while app.world().resource::<EditorState>().solver.is_some() {
app.update();
std::thread::yield_now();
}
app.update();
let solution = app.world().resource::<EditorState>().solution.clone();
assert!(!solution.is_empty(), "the check found one");
assert_eq!(ghosts(&mut app), solution.len(), "drawn");
let path = save_path(name);
let _ = std::fs::remove_file(&path);
tap(&mut app, KeyCode::F2);
let filed = std::fs::read_to_string(&path).expect("saved");
let (x, y, dir, _) = solution[0];
let line = format!("solution: {x},{y} {}", dir.letter());
assert!(filed.contains(&line), "{filed}");
tap(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<EditorState>().solution.is_empty());
assert_eq!(ghosts(&mut app), 0, "an edit takes them away");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_found_way_through_outlives_no_edits_and_comes_back_with_z() {
let (mut app, _) = painting_app((0, 0));
app.add_systems(Update, poll_solver.after(editor_commands));
{
let world = app.world_mut();
let mut board = sand();
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Down, Handedness::Left, CrabKind::Common);
world.resource_mut::<Sim>().0 = board.clone();
let mut state = world.resource_mut::<EditorState>();
state.posts = 1;
state.begin_history(&board);
}
let solution = |app: &App| app.world().resource::<EditorState>().solution.clone();
tap(&mut app, KeyCode::KeyV);
while app.world().resource::<EditorState>().solver.is_some() {
app.update();
std::thread::yield_now();
}
let found = solution(&app);
assert!(!found.is_empty(), "the check found one");
let filed = filed_text(
app.world().resource::<EditorState>(),
&app.world().resource::<Sim>().0,
);
app.world_mut().resource_mut::<EditorState>().filed = Some(filed);
tap(&mut app, KeyCode::KeyX);
assert_eq!(solution(&app), found, "the eraser on bare sand is no edit");
{
let world = app.world();
let state = world.resource::<EditorState>();
assert!(is_checked(state, &world.resource::<Sim>().0));
assert!(
!unsaved_work(state, &world.resource::<Sim>().0),
"saved before the check"
);
}
tap(&mut app, KeyCode::KeyR);
assert!(solution(&app).is_empty(), "an edit lets it go");
tap(&mut app, KeyCode::KeyZ);
assert_eq!(solution(&app), found, "and Z brings it back");
}
#[test]
fn an_edit_taken_back_by_hand_leaves_the_bench_unchecked() {
let (mut app, _) = painting_app((0, 0));
app.add_systems(Update, poll_solver.after(editor_commands));
{
let world = app.world_mut();
let mut board = sand();
board.set_tile(4, 2, TileKind::Castle(0));
board.spawn_crab(1, 2, Direction::Down, Handedness::Left, CrabKind::Common);
world.resource_mut::<Sim>().0 = board.clone();
let mut state = world.resource_mut::<EditorState>();
state.posts = 1;
state.begin_history(&board);
}
tap(&mut app, KeyCode::KeyV);
while app.world().resource::<EditorState>().solver.is_some() {
app.update();
std::thread::yield_now();
}
let checked = |app: &App| {
let world = app.world();
is_checked(world.resource::<EditorState>(), &world.resource::<Sim>().0)
};
assert!(!app.world().resource::<EditorState>().solution.is_empty());
assert!(checked(&app));
tap(&mut app, KeyCode::KeyR);
tap(&mut app, KeyCode::KeyX);
let state = app.world().resource::<EditorState>();
assert!(state.solution.is_empty(), "the rock let it go");
assert!(!checked(&app), "so a save checks again");
}
#[test]
fn the_mirror_keeps_a_beach_fair() {
let (mut app, cursor) = painting_app((4, 1));
{
let world = app.world_mut();
world.resource_mut::<Sim>().0 = Board::new(9, 7, 3);
let mut state = world.resource_mut::<EditorState>();
state.kind = LevelKind::Arena;
state.symmetry = Symmetry::LeftRight;
}
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
let said = |app: &App| app.world().resource::<EditorState>().feedback.clone();
tap(&mut app, KeyCode::KeyC);
assert_eq!(tile(&app, 4, 1), TileKind::Empty, "no castle on the line");
assert_eq!(said(&app), crate::app::i18n::EN.ed_mirror_line);
let (_, next) = {
let world = app.world();
let state = world.resource::<EditorState>();
palette::under_and_next(
&world.resource::<Sim>().0,
4,
1,
state,
&crate::app::i18n::EN,
)
};
assert_eq!(next, None, "nothing a press would change");
tap(&mut app, KeyCode::KeyH);
assert!(
matches!(tile(&app, 4, 1), TileKind::Spawner(s) if s.dir == Direction::Down),
"a hole on the line faces along it"
);
step_to(&mut app, cursor, (3, 3));
hold(&mut app, KeyCode::KeyC);
step_to(&mut app, cursor, (4, 3));
step_to(&mut app, cursor, (5, 3));
step_to(&mut app, cursor, (6, 3));
assert_eq!(tile(&app, 3, 3), TileKind::Castle(0));
assert_eq!(tile(&app, 5, 3), TileKind::Castle(1));
assert_eq!(tile(&app, 6, 3), TileKind::Castle(0));
assert_eq!(tile(&app, 2, 3), TileKind::Castle(1));
assert_eq!(tile(&app, 4, 3), TileKind::Empty, "the line skipped");
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::KeyC);
step_to(&mut app, cursor, (6, 3));
tap(&mut app, KeyCode::F5);
let state = app.world().resource::<EditorState>();
assert_eq!(state.symmetry, Symmetry::Off);
assert!(
state
.feedback
.starts_with(crate::app::i18n::EN.ed_mirror_moved)
);
}
#[test]
fn the_mirror_keeps_creatures_and_logs_on_its_line_fair() {
let (mut app, cursor) = painting_app((4, 1));
{
let world = app.world_mut();
world.resource_mut::<Sim>().0 = Board::new(9, 7, 3);
let mut state = world.resource_mut::<EditorState>();
state.kind = LevelKind::Arena;
state.symmetry = Symmetry::LeftRight;
}
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
let said = |app: &App| app.world().resource::<EditorState>().feedback.clone();
let facing = |app: &App, x, y| brush::facing(&app.world().resource::<Sim>().0, x, y);
let across = crate::app::i18n::EN.ed_mirror_across;
tap(&mut app, KeyCode::KeyL);
assert_eq!(tile(&app, 4, 1), TileKind::Empty, "no log on the line");
assert_eq!(said(&app), across);
step_to(&mut app, cursor, (4, 3));
tap(&mut app, KeyCode::KeyB);
assert_eq!(facing(&app, 4, 3), Some(Direction::Down), "along the line");
assert_eq!(said(&app), across);
tap(&mut app, KeyCode::KeyT);
assert_eq!(facing(&app, 4, 3), Some(Direction::Up), "past Left");
step_to(&mut app, cursor, (4, 5));
tap(&mut app, KeyCode::KeyG);
assert!(
matches!(facing(&app, 4, 5), Some(Direction::Up | Direction::Down)),
"{:?}",
facing(&app, 4, 5)
);
{
let world = app.world_mut();
let mut sim = world.resource_mut::<Sim>();
let hole = crate::sim::Spawner {
dir: Direction::Right,
period: 60,
};
sim.0.set_tile(4, 4, TileKind::Spawner(hole));
sim.0
.spawn_crab(4, 4, Direction::Down, Handedness::Left, CrabKind::Common);
}
assert_eq!(facing(&app, 4, 4), Some(Direction::Down));
step_to(&mut app, cursor, (4, 4));
tap(&mut app, KeyCode::KeyT);
assert_eq!(facing(&app, 4, 4), Some(Direction::Up), "the crab turned");
}
#[test]
fn undo_and_redo_keep_a_pasted_beachs_tables() {
let (mut app, _) = painting_app((5, 4));
{
let world = app.world_mut();
let mut board = sand();
for (seat, (x, y)) in [(0, 0), (5, 4), (5, 0), (0, 4)].into_iter().enumerate() {
board.set_tile(x, y, TileKind::Castle(seat as u8));
}
world.resource_mut::<Sim>().0 = board.clone();
let mut state = world.resource_mut::<EditorState>();
state.kind = LevelKind::Arena;
state.tables = vec![2, 4];
state.begin_history(&board);
}
let tables = |app: &App| {
let world = app.world();
level_here(
world.resource::<EditorState>(),
&world.resource::<Sim>().0,
"x",
)
.tables
};
assert_eq!(tables(&app), [2, 4]);
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(0, 4, TileKind::Empty);
{
let world = app.world_mut();
let mut board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().edited(&mut board);
world.resource_mut::<Sim>().0 = board;
}
assert_eq!(tables(&app), [2]);
tap(&mut app, KeyCode::KeyZ);
assert_eq!(tables(&app), [2, 4]);
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::ShiftLeft);
keys.press(KeyCode::KeyZ);
}
app.update();
assert_eq!(tables(&app), [2], "redone: the castle is gone again");
app.world_mut()
.resource_mut::<Sim>()
.0
.set_tile(0, 4, TileKind::Castle(3));
assert_eq!(tables(&app), [2, 4], "and back with it");
}
#[test]
fn a_stroke_is_one_step_back_of_its_own() {
let (mut app, cursor) = painting_app((1, 1));
let world = app.world_mut();
let board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().begin_history(&board);
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
tap(&mut app, KeyCode::KeyR);
assert_eq!(tile(&app, 1, 1), TileKind::Rock);
hold(&mut app, KeyCode::KeyR);
step_to(&mut app, cursor, (2, 1));
step_to(&mut app, cursor, (3, 1));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::KeyR);
step_to(&mut app, cursor, (3, 1));
tap(&mut app, KeyCode::KeyZ);
assert_eq!(tile(&app, 2, 1), TileKind::Empty, "the drag undone");
assert_eq!(tile(&app, 1, 1), TileKind::Rock, "the rock before it stays");
hold(&mut app, KeyCode::KeyC);
step_to(&mut app, cursor, (4, 1));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.update();
assert!(
app.world()
.resource::<Sim>()
.0
.wall_at(4, 1, Direction::Right)
);
tap(&mut app, KeyCode::KeyZ);
assert!(
!app.world()
.resource::<Sim>()
.0
.wall_at(4, 1, Direction::Right)
);
assert_eq!(tile(&app, 4, 1), TileKind::Castle(0), "the stroke stays");
}
#[test]
fn a_held_brush_paints_on_past_a_wall() {
let (mut app, cursor) = painting_app((1, 1));
let world = app.world_mut();
let board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().begin_history(&board);
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
let wall = |app: &App| {
app.world()
.resource::<Sim>()
.0
.wall_at(2, 1, Direction::Right)
};
hold(&mut app, KeyCode::KeyR);
step_to(&mut app, cursor, (2, 1));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.update();
assert!(wall(&app));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::ArrowRight);
step_to(&mut app, cursor, (3, 1));
step_to(&mut app, cursor, (4, 1));
assert_eq!(tile(&app, 3, 1), TileKind::Rock, "still painting");
assert_eq!(tile(&app, 4, 1), TileKind::Rock);
tap(&mut app, KeyCode::KeyZ);
assert_eq!(tile(&app, 3, 1), TileKind::Empty, "the drag after the wall");
assert!(wall(&app), "is its own step back");
assert_eq!(tile(&app, 2, 1), TileKind::Rock);
}
#[test]
fn a_refused_brush_letter_ends_the_stroke_before_it() {
let (mut app, cursor) = painting_app((3, 3));
{
let world = app.world_mut();
let mut board = Board::new(9, 7, 3);
board.set_tile(6, 3, TileKind::Castle(0));
board.set_tile(2, 3, TileKind::Castle(1));
world.resource_mut::<Sim>().0 = board;
let mut state = world.resource_mut::<EditorState>();
state.kind = LevelKind::Arena;
state.symmetry = Symmetry::LeftRight;
}
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
tap(&mut app, KeyCode::KeyR);
hold(&mut app, KeyCode::Space);
step_to(&mut app, cursor, (4, 3));
assert_eq!(tile(&app, 4, 3), TileKind::Rock, "a rock on the line");
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyL);
app.update();
assert_eq!(tile(&app, 4, 3), TileKind::Rock, "refused");
step_to(&mut app, cursor, (6, 3));
assert_eq!(tile(&app, 6, 3), TileKind::Castle(0), "left alone");
assert_eq!(tile(&app, 2, 3), TileKind::Castle(1));
}
#[test]
fn a_mirrored_stroke_is_one_edit_on_every_copy() {
let (mut app, cursor) = painting_app((0, 0));
{
let mut state = app.world_mut().resource_mut::<EditorState>();
state.kind = LevelKind::Arena;
state.symmetry = Symmetry::Both;
}
let begin = |app: &mut App| {
let world = app.world_mut();
let board = world.resource::<Sim>().0.clone();
world.resource_mut::<EditorState>().begin_history(&board);
};
begin(&mut app);
let tile = |app: &App, x, y| app.world().resource::<Sim>().0.tile_at(x, y);
hold(&mut app, KeyCode::KeyC);
step_to(&mut app, cursor, (1, 0));
for (x, y, seat) in [
(0, 0, 0),
(1, 0, 0),
(5, 0, 2),
(4, 0, 2),
(0, 4, 3),
(5, 4, 1),
] {
assert_eq!(tile(&app, x, y), TileKind::Castle(seat), "({x},{y})");
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::KeyC);
step_to(&mut app, cursor, (1, 1));
tap(&mut app, KeyCode::ArrowRight);
let walls = |app: &App| {
let board = &app.world().resource::<Sim>().0;
[
board.wall_at(1, 1, Direction::Right),
board.wall_at(4, 1, Direction::Left),
board.wall_at(1, 3, Direction::Right),
board.wall_at(4, 3, Direction::Left),
]
};
assert_eq!(walls(&app), [true; 4]);
tap(&mut app, KeyCode::KeyZ);
assert_eq!(walls(&app), [false; 4], "the wall and its copies in one");
assert_eq!(tile(&app, 5, 4), TileKind::Castle(1));
tap(&mut app, KeyCode::KeyZ);
for (x, y) in [(0, 0), (1, 0), (5, 0), (4, 0), (0, 4), (5, 4)] {
assert_eq!(tile(&app, x, y), TileKind::Empty, "({x},{y}) undone");
}
}
}