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::{SolverSlot, 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];
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 gull_period_idx: usize,
pub feedback: String,
pub(super) solver: Option<SolverSlot>,
mine: std::collections::HashSet<String>,
resize_armed: bool,
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) {
let tidy = state.name.trim().to_string();
state.name = if tidy.is_empty() {
tr.ed_default_name.to_string()
} else {
tidy
};
state.mode = Mode::JustNamed;
state.feedback.clear();
}
}
const NAME_MAX: usize = 28;
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()
}
fn replace_board(
board: Board,
sim: &mut Sim,
state: &mut EditorState,
commands: &mut Commands,
sprites: BoardSprites,
cursors: &mut Query<(&mut Cursor, &mut Transform)>,
) {
sim.0 = board;
state.dirty = true;
crate::app::session::despawn_board_sprites(commands.reborrow(), sprites);
crate::app::cursor::center_on(&sim.0, cursors);
}
#[allow(clippy::too_many_arguments)]
pub fn editor_input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
settings: Res<GameSettings>,
mut sim: ResMut<Sim>,
mut state: ResMut<EditorState>,
sprites: BoardSprites,
mut cursors: Query<(&mut Cursor, &mut Transform)>,
) {
if state.is_naming() {
type_a_name(&mut typed, &keys, &mut state, settings.tr());
return;
}
typed.clear();
if state.resize_armed && keys.get_just_pressed().any(|&k| k != KeyCode::F5) {
state.resize_armed = false;
}
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.resize_armed && has_work(&state, board) {
state.resize_armed = true;
state.feedback = fill(
tr.ed_resize_confirm,
&[("w", &w.to_string()), ("h", &h.to_string())],
);
return;
}
state.resize_armed = false;
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) {
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)]))
}
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)
}
#[allow(clippy::too_many_arguments)]
pub fn editor_commands(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut sim: ResMut<Sim>,
settings: Res<GameSettings>,
mut clipboard: ResMut<Clipboard>,
mut state: ResMut<EditorState>,
mut saved: MessageWriter<crate::app::LevelSaved>,
mut shared: MessageWriter<crate::app::CodeShared>,
mut taken: MessageWriter<crate::app::CodeTaken>,
mut next_screen: ResMut<NextState<Screen>>,
sprites: BoardSprites,
mut cursors: Query<(&mut Cursor, &mut Transform)>,
) {
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(level) => {
taken.write(crate::app::CodeTaken);
state.posts = level.posts;
state.kind = level.kind;
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.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) {
state.gull_period_idx = (state.gull_period_idx + 1) % GULL_PERIODS.len();
let period = GULL_PERIODS[state.gull_period_idx];
board.set_gull_period(period);
state.feedback = if period == 0 {
tr.ed_gulls_off.into()
} else {
fill(tr.ed_gulls_every, &[("period", &period.to_string())])
};
}
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 snapshot = board.clone();
if state.kind == LevelKind::Puzzle {
board.set_signpost_rule(state.posts, crate::sim::CapPolicy::Reject);
}
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>,
cursors: Query<&Cursor>,
) {
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)
{
sim.0 = *snapshot;
state.dirty = true;
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!(app.world().resource::<EditorState>().resize_armed);
tap(&mut app, KeyCode::F5);
assert_ne!(size(&mut app), built, "and the second press takes it");
assert!(!app.world().resource::<EditorState>().resize_armed);
app.world_mut().resource_mut::<Sim>().0.set_wrap(true);
let built = size(&mut app);
tap(&mut app, KeyCode::F5);
assert!(app.world().resource::<EditorState>().resize_armed);
tap(&mut app, KeyCode::KeyO);
assert!(
!app.world().resource::<EditorState>().resize_armed,
"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 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());
}
}