mod brush;
mod palette;
mod validate;
pub use brush::Brush;
pub use palette::{EditorUi, spawn_editor_ui, update_editor_palette};
pub use validate::poll_solver;
use brush::paint;
use validate::{Validation, arena_report, orphan_warning, start_validation};
use crate::app::cursor::Cursor;
use crate::app::i18n::fill;
use crate::app::session::BoardSprites;
use crate::app::settings::GameSettings;
use crate::app::{Screen, Sim};
use crate::sim::{Board, Direction, Level, LevelKind};
use bevy::prelude::*;
const ARROWS: [(KeyCode, Direction); 4] = [
(KeyCode::ArrowUp, Direction::Up),
(KeyCode::ArrowDown, Direction::Down),
(KeyCode::ArrowLeft, Direction::Left),
(KeyCode::ArrowRight, Direction::Right),
];
const EDITOR_BOARD: (u8, u8) = (12, 9);
const EDITOR_SIZES: [(u8, u8); 4] = [(9, 7), (12, 9), (16, 11), (20, 13)];
const GULL_PERIODS: [u32; 4] = [0, 480, 240, 120];
fn next_gull_period(period: u32) -> u32 {
let at = GULL_PERIODS.iter().position(|&p| p == period).unwrap_or(0);
GULL_PERIODS[(at + 1) % GULL_PERIODS.len()]
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Offer {
Resize,
Paste,
}
impl Offer {
fn key(self) -> KeyCode {
match self {
Offer::Resize => KeyCode::F5,
Offer::Paste => KeyCode::F4,
}
}
}
pub fn legacy_save_path() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom.txt")
}
pub fn custom_dir() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom")
}
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 is_new_here(path: &std::path::Path) -> bool {
!path.exists()
}
fn write_level(path: &std::path::Path, text: &str) -> std::io::Result<bool> {
let is_new = !path.exists();
crate::app::paths::write_atomic(path, text)?;
Ok(is_new)
}
pub fn save_path(name: &str) -> std::path::PathBuf {
custom_dir().join(format!(
"{}.txt",
crate::app::paths::safe_stem(name, 40, "level")
))
}
#[derive(Resource, Default)]
pub struct EditorState {
pub posts: u8,
pub kind: LevelKind,
pub name: String,
pub mode: Mode,
pub brush: Brush,
pub feedback: String,
solver: Option<Validation>,
mine: std::collections::HashSet<String>,
offer: Option<Offer>,
dirty: bool,
}
#[derive(Default, Debug)]
pub enum Mode {
#[default]
Painting,
Naming,
JustNamed,
Testing(Box<Board>),
}
impl EditorState {
pub fn is_naming(&self) -> bool {
matches!(self.mode, Mode::Naming)
}
pub fn is_testing(&self) -> bool {
matches!(self.mode, Mode::Testing(_))
}
}
fn type_a_name(
typed: &mut MessageReader<bevy::input::keyboard::KeyboardInput>,
keys: &ButtonInput<KeyCode>,
state: &mut EditorState,
tr: &crate::app::i18n::Tr,
) {
use crate::app::typing::{Keystroke, keystrokes};
let ends = [KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::Escape];
for stroke in keystrokes(typed, &ends) {
match stroke {
Keystroke::Erase => {
state.name.pop();
}
Keystroke::Char(ch) if state.name.chars().count() < NAME_MAX => state.name.push(ch),
Keystroke::Char(_) | Keystroke::Done(_) => {}
}
}
if crate::app::menu_ui::enter(keys) || keys.just_pressed(KeyCode::Escape) {
state.name = tidy_name(&state.name, tr);
state.mode = Mode::JustNamed;
state.feedback.clear();
}
}
const NAME_MAX: usize = 28;
fn tidy_name(raw: &str, tr: &crate::app::i18n::Tr) -> String {
let capped: String = raw.trim().chars().take(NAME_MAX).collect();
match capped.trim_end() {
"" => tr.ed_default_name.to_string(),
name => name.to_string(),
}
}
pub fn editor_naming(state: Res<EditorState>) -> bool {
state.is_naming()
}
pub fn enter_editor(
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
) {
*state = EditorState {
posts: 3,
name: settings.tr().ed_default_name.to_string(),
feedback: settings.tr().ed_fresh_sand.into(),
dirty: true, ..default()
};
sim.0 = Board::new(EDITOR_BOARD.0, EDITOR_BOARD.1, 0xED17);
}
pub fn exit_editor(mut state: ResMut<EditorState>) {
*state = EditorState::default();
}
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,
commands: &mut Commands,
sprites: &BoardSprites,
cursors: &mut Query<(&mut Cursor, &mut Transform)>,
) {
swap_board(board, sim, state, commands, sprites);
state.solver = None;
crate::app::cursor::center_on(&sim.0, cursors);
}
fn swap_board(
board: Board,
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: &BoardSprites,
) -> Board {
state.dirty = true;
sprites.despawn_all(commands);
std::mem::replace(&mut sim.0, board)
}
pub fn editor_input(
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
settings: Res<GameSettings>,
bench: Workbench,
swap: BoardSwap,
) {
let Workbench { mut sim, mut state } = bench;
let BoardSwap {
mut commands,
sprites,
mut cursors,
} = swap;
if state.is_naming() {
type_a_name(&mut typed, &keys, &mut state, settings.tr());
return;
}
typed.clear();
if let Some(offer) = state.offer
&& keys.get_just_pressed().any(|&k| k != offer.key())
{
state.offer = None;
}
if keys.just_pressed(KeyCode::F5) {
let board = &sim.0;
let now = (board.width(), board.height());
let at = EDITOR_SIZES.iter().position(|&s| s == now).unwrap_or(1);
let (w, h) = EDITOR_SIZES[(at + 1) % EDITOR_SIZES.len()];
let tr = settings.tr();
if state.offer != Some(Offer::Resize) && has_work(&state, board) {
state.offer = Some(Offer::Resize);
state.feedback = fill(
tr.ed_resize_confirm,
&[("w", &w.to_string()), ("h", &h.to_string())],
);
return;
}
state.offer = None;
replace_board(
Board::new(w, h, 0xED17),
&mut sim,
&mut state,
&mut commands,
&sprites,
&mut cursors,
);
state.feedback = fill(
tr.ed_resized,
&[("w", &w.to_string()), ("h", &h.to_string())],
);
return;
}
if keys.just_pressed(KeyCode::F1) {
state.mode = Mode::Naming;
state.feedback = settings.tr().ed_naming.to_string();
return;
}
let Some((cursor, _)) = cursors.iter().next() else {
return;
};
let (x, y) = (cursor.x, cursor.y);
let board = &mut sim.0;
for (key, dir) in ARROWS {
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);
state.dirty = true;
}
}
for brush in Brush::ALL {
if keys.just_pressed(brush.key()) {
state.brush = brush;
paint(board, x, y, brush);
state.dirty = true;
}
}
if keys.just_pressed(KeyCode::Tab) {
let at = Brush::ALL
.iter()
.position(|b| *b == state.brush)
.unwrap_or(0);
let step = if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
Brush::ALL.len() - 1
} else {
1
};
state.brush = Brush::ALL[(at + step) % Brush::ALL.len()];
}
if keys.just_pressed(KeyCode::Space) {
paint(board, x, y, state.brush);
state.dirty = true;
}
}
fn level_from(
pasted: Option<(crate::share::Kind, Vec<u8>)>,
tr: &crate::app::i18n::Tr,
) -> Result<Level, String> {
let text =
crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Level, tr.code_level_bad)?;
Level::parse(&text).map_err(|e| fill(tr.code_level_bad, &[("e", &e)]))
}
fn is_from_elsewhere(pasted: &Level, bench: &Level, shelf: &[Level]) -> bool {
let text = pasted.to_text();
text != bench.to_text() && shelf.iter().all(|own| own.to_text() != text)
}
fn take_level(state: &mut EditorState, level: &Level, tr: &crate::app::i18n::Tr) {
state.posts = level.posts;
state.kind = level.kind;
state.name = tidy_name(&level.name, tr);
state.mine.remove(&state.name);
}
pub(super) fn level_here(state: &EditorState, board: &Board, name: &str) -> Level {
let mut snapshot = board.clone();
if state.kind == LevelKind::Puzzle {
snapshot.set_signpost_rule(state.posts, crate::sim::CapPolicy::Reject);
snapshot.set_castle_raids(false);
}
let level = Level::from_board(name, state.posts, snapshot).with_kind(state.kind);
Level::parse(&level.to_text()).unwrap_or(level)
}
pub fn editor_commands(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
bench: Workbench,
mut clipboard: ResMut<Clipboard>,
news: EditorNews,
mut next_screen: ResMut<NextState<Screen>>,
swap: BoardSwap,
) {
let Workbench { mut sim, mut state } = bench;
let EditorNews {
mut saved,
mut shared,
mut taken,
} = news;
let BoardSwap {
mut commands,
sprites,
mut cursors,
} = swap;
if matches!(state.mode, Mode::JustNamed) {
state.mode = Mode::Painting;
return;
}
let tr = settings.tr();
if keys.just_pressed(KeyCode::F3) {
let level = level_here(&state, &sim.0, &state.name);
let (feedback, took) = crate::app::codes::copy_counted(
&mut clipboard,
tr,
crate::share::Kind::Level,
level.to_text().as_bytes(),
tr.code_copied,
);
state.feedback = feedback;
if took {
shared.write(crate::app::CodeShared);
}
}
if keys.just_pressed(KeyCode::F4) {
match level_from(crate::app::codes::paste(&mut clipboard), tr) {
Ok(_) if state.offer != Some(Offer::Paste) && has_work(&state, &sim.0) => {
state.offer = Some(Offer::Paste);
state.feedback = tr.ed_paste_confirm.into();
}
Ok(level) => {
state.offer = None;
let bench = level_here(&state, &sim.0, &state.name);
let shelf = crate::app::campaign::load_custom_levels();
if is_from_elsewhere(&level, &bench, &shelf) {
taken.write(crate::app::CodeTaken);
}
take_level(&mut state, &level, tr);
replace_board(
level.board(),
&mut sim,
&mut state,
&mut commands,
&sprites,
&mut cursors,
);
if level.kind == LevelKind::Arena {
state.feedback = arena_report(&sim.0, tr);
} else {
start_validation(&mut state, level);
state.feedback = tr.code_level_checking.into();
}
}
Err(complaint) => {
state.offer = None;
state.feedback = complaint;
}
}
}
let board = &mut sim.0;
if state.kind == LevelKind::Puzzle {
if keys.just_pressed(KeyCode::Equal) {
state.posts = (state.posts + 1).min(9);
}
if keys.just_pressed(KeyCode::Minus) {
state.posts = state.posts.saturating_sub(1);
}
}
if keys.just_pressed(KeyCode::KeyO) {
let wrap = !board.wrap();
board.set_wrap(wrap);
state.dirty = true;
state.feedback = if wrap {
tr.ed_wrap_on.into()
} else {
tr.ed_wrap_off.into()
};
}
if keys.just_pressed(KeyCode::KeyK) {
let period = next_gull_period(board.gull_period());
board.set_gull_period(period);
state.feedback = if period == 0 {
tr.ed_gulls_off.into()
} else {
let secs = period as f32 / crate::sim::TICKS_PER_SECOND as f32;
fill(tr.ed_gulls_every, &[("s", &format!("{secs}"))])
};
}
if keys.just_pressed(KeyCode::KeyV) {
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 {
let level = level_here(&state, board, "Custom");
start_validation(&mut state, level);
state.feedback = tr.ed_validating.into();
}
}
if keys.just_pressed(KeyCode::F6) {
state.kind = match state.kind {
LevelKind::Puzzle => LevelKind::Arena,
LevelKind::Arena => LevelKind::Puzzle,
};
state.feedback = match state.kind {
LevelKind::Puzzle => tr.ed_now_puzzle.into(),
LevelKind::Arena => tr.ed_now_arena.into(),
};
}
if keys.just_pressed(KeyCode::F2) {
let level = level_here(&state, board, &state.name);
let path = save_path(&state.name);
let stranger = !is_new_here(&path) && !state.mine.contains(&state.name);
state.feedback = match write_level(&path, &level.to_text()) {
Ok(is_new) => {
if is_new {
saved.write(crate::app::LevelSaved);
}
let named = state.name.clone();
state.mine.insert(named);
let filed = fill(tr.ed_saved_to, &[("path", &path.display().to_string())]);
let orphans = orphan_warning(&state, &level, tr);
let notes = [stranger.then_some(tr.ed_saved_over), orphans.as_deref()];
notes
.iter()
.flatten()
.fold(filed, |line, note| format!("{line} - {note}"))
}
Err(e) => fill(tr.ed_save_failed, &[("e", &e.to_string())]),
};
}
if crate::app::menu_ui::enter(&keys) {
let played = level_here(&state, board, &state.name).board();
let snapshot = swap_board(played, &mut sim, &mut state, &mut commands, &sprites);
state.mode = Mode::Testing(Box::new(snapshot));
state.feedback = tr.ed_playtest_prompt.into();
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
}
}
pub fn editor_test_input(
keys: Res<ButtonInput<KeyCode>>,
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut state: ResMut<EditorState>,
swap: BoardSwap,
) {
let BoardSwap {
mut commands,
sprites,
cursors,
} = swap;
let Some((cursor, _)) = cursors.iter().next() else {
return;
};
for (key, dir) in ARROWS {
if keys.just_pressed(key) {
let _ = sim.0.place_signpost(0, cursor.x, cursor.y, dir);
}
}
if keys.just_pressed(KeyCode::Space) {
let _ = sim.0.remove_signpost(0, cursor.x, cursor.y);
}
if keys.just_pressed(KeyCode::Escape)
&& let Mode::Testing(snapshot) = std::mem::take(&mut state.mode)
{
swap_board(*snapshot, &mut sim, &mut state, &mut commands, &sprites);
state.feedback = settings.tr().ed_back.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);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{CrabKind, Direction, Handedness, TileKind};
fn sand() -> Board {
Board::new(6, 5, 3)
}
#[test]
fn different_names_are_different_files() {
assert_ne!(save_path("First Beach"), save_path("Second Beach"));
assert!(
save_path("Gull Alley").ends_with("gull-alley.txt"),
"{:?}",
save_path("Gull Alley")
);
}
#[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().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().castle_raids());
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 saving_the_same_level_again_does_not_build_a_second_one() {
let dir = std::env::temp_dir().join(format!("pinch-built-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a directory to save into");
let path = dir.join("my-beach.txt");
assert!(write_level(&path, "one").expect("wrote"), "a new level");
assert!(
!write_level(&path, "two").expect("wrote"),
"the same level again"
);
assert!(
!write_level(&path, "three").expect("wrote"),
"and again, however long the key is held"
);
assert_eq!(
std::fs::read_to_string(&path).expect("reads"),
"three",
"every save still lands; only the counting stops"
);
std::fs::remove_file(&path).expect("removed");
assert!(write_level(&path, "four").expect("wrote"), "built afresh");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resizing_a_beach_with_a_level_on_it_asks_twice() {
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.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_message::<bevy::input::keyboard::KeyboardInput>();
app.add_systems(Update, editor_input);
let 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 size = |app: &mut App| {
let board = &app.world().resource::<Sim>().0;
(board.width(), board.height())
};
let first = size(&mut app);
tap(&mut app, KeyCode::F5);
assert_ne!(size(&mut app), first, "an empty beach cycles straight");
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
let built = size(&mut app);
tap(&mut app, KeyCode::F5);
assert_eq!(size(&mut app), built, "the level is still there");
assert_eq!(
app.world().resource::<EditorState>().offer,
Some(Offer::Resize)
);
tap(&mut app, KeyCode::F5);
assert_ne!(size(&mut app), built, "and the second press takes it");
assert_eq!(app.world().resource::<EditorState>().offer, None);
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
let built = size(&mut app);
tap(&mut app, KeyCode::F5);
assert_eq!(
app.world().resource::<EditorState>().offer,
Some(Offer::Resize)
);
tap(&mut app, KeyCode::KeyO);
assert!(
app.world().resource::<EditorState>().offer.is_none(),
"another key takes the offer back"
);
tap(&mut app, KeyCode::F5);
assert_eq!(size(&mut app), built, "so this press only offers again");
}
#[test]
fn the_posts_dial_is_inert_on_a_beach() {
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.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().posts = 3;
let tap = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
};
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 = 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.insert_resource(Sim(board.clone()));
app.insert_resource(EditorState {
posts: 3,
name: "Stage".into(),
..EditorState::default()
});
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
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 tap = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
};
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.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 = 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.insert_resource(Sim(board));
app.insert_resource(EditorState {
posts: 3,
name: "Stage".into(),
..EditorState::default()
});
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
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 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 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 = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(Sim(sand()));
app.insert_resource(EditorState {
solver: Some(Validation {
slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
posts: 3,
}),
..EditorState::default()
});
app.init_resource::<GameSettings>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_message::<bevy::input::keyboard::KeyboardInput>();
app.add_systems(Update, editor_input);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F5);
app.update();
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 a_pasted_name_is_tidied_like_a_typed_one() {
let tr = &crate::app::i18n::EN;
let named = |name: &str| {
let mut state = EditorState::default();
let mut level = Level::from_board("x", 3, sand());
level.name = name.into();
take_level(&mut state, &level, tr);
state.name
};
assert_eq!(named(" Gull Alley "), "Gull Alley");
assert_eq!(named(""), tr.ed_default_name);
assert_eq!(named(" "), tr.ed_default_name);
let long = named(&"\u{e9}".repeat(NAME_MAX + 12));
assert_eq!(long.chars().count(), NAME_MAX, "cut on characters");
let spaced = format!("{} tail", "a".repeat(NAME_MAX - 1));
assert_eq!(named(&spaced), "a".repeat(NAME_MAX - 1));
}
#[test]
fn the_flock_dial_steps_from_the_board_it_is_on() {
assert_eq!(next_gull_period(0), GULL_PERIODS[1]);
assert_eq!(next_gull_period(GULL_PERIODS[1]), GULL_PERIODS[2]);
assert_eq!(next_gull_period(GULL_PERIODS[3]), 0, "and round to off");
assert_eq!(next_gull_period(333), GULL_PERIODS[1], "off the dial");
}
#[test]
fn only_a_level_from_elsewhere_counts_as_taken() {
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
let mine = Level::from_board("Mine", 3, board.clone());
board.set_tile(2, 2, TileKind::Rock);
let bench = Level::from_board("Bench", 3, board.clone());
let shelf = [mine.clone()];
assert!(!is_from_elsewhere(&bench, &bench, &shelf), "F3 then F4");
assert!(!is_from_elsewhere(&mine, &bench, &shelf), "my own, back");
board.set_tile(3, 3, TileKind::Rock);
let theirs = Level::from_board("Mine", 3, board);
assert!(
is_from_elsewhere(&theirs, &bench, &shelf),
"their edit of mine"
);
}
#[test]
fn a_pasted_level_brings_its_name() {
let mut state = EditorState {
posts: 3,
name: "My Beach".into(),
..EditorState::default()
};
state.mine.insert("My Beach".into());
state.mine.insert("Gull Alley".into());
let pasted = Level::from_board("Gull Alley", 5, sand()).with_kind(LevelKind::Arena);
take_level(&mut state, &pasted, &crate::app::i18n::EN);
assert_eq!(state.name, "Gull Alley");
assert_eq!((state.posts, state.kind), (5, LevelKind::Arena));
assert!(!state.mine.contains("Gull Alley"), "a save over it is said");
assert!(state.mine.contains("My Beach"), "the rest are left alone");
}
#[test]
fn committing_a_name_does_not_start_a_playtest() {
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.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
app.world_mut().resource_mut::<EditorState>().mode = Mode::JustNamed;
let press = |app: &mut App| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::Enter);
app.update();
};
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 f6_flips_the_kind() {
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.insert_resource(Sim(sand()));
app.init_resource::<EditorState>();
app.init_resource::<GameSettings>();
app.init_resource::<Clipboard>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_systems(Update, editor_commands);
let press = |app: &mut App| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::F6);
app.update();
};
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
);
}
#[test]
fn a_level_travels_as_a_code_and_comes_back() {
let mut board = sand();
board.set_tile(2, 2, TileKind::Rock);
board.set_tile(4, 3, TileKind::Castle(0));
board.set_wall(1, 1, Direction::Right, true);
board.set_wrap(true);
let level = Level::from_board("Custom", 5, board.clone());
let code = crate::share::encode(crate::share::Kind::Level, level.to_text().as_bytes());
let back = level_from(crate::share::decode(&code), &crate::app::i18n::EN)
.expect("our own level reads back");
assert_eq!(back.posts, 5, "the signpost budget travels with it");
let rebuilt = back.board();
assert_eq!(rebuilt.tile_at(2, 2), TileKind::Rock);
assert_eq!(rebuilt.tile_at(4, 3), TileKind::Castle(0));
assert!(rebuilt.wall_at(1, 1, Direction::Right));
assert!(rebuilt.wrap(), "an open-ocean level stays open");
}
#[test]
fn what_is_not_a_level_says_which_way_it_is_not() {
let tr = &crate::app::i18n::EN;
assert_eq!(
level_from(None, tr).err(),
Some(tr.code_none_pasted.to_string())
);
let round = crate::share::encode(crate::share::Kind::Round, b"replay-v1\n");
let complaint = level_from(crate::share::decode(&round), tr).expect_err("not a level");
assert!(
complaint.contains("a round") && complaint.contains("a level"),
"{complaint}"
);
let junk = crate::share::encode(crate::share::Kind::Level, b"not a level at all");
assert!(level_from(crate::share::decode(&junk), tr).is_err());
}
}