1mod format;
35
36use crate::sim::board::{Board, CapPolicy};
37use crate::sim::solve::Placement;
38
39pub const PUZZLE_TICK_LIMIT: u64 = 60 * crate::sim::TICKS_PER_SECOND as u64;
42
43#[derive(Clone, Debug)]
44pub struct Level {
45 pub name: String,
46 pub posts: u8,
48 pub solution: Vec<Placement>,
50 pub goal: Goal,
51 pub kind: LevelKind,
55 board: Board,
56 crab_count: u32,
57 explicit_rule: bool,
60}
61
62#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
70pub enum LevelKind {
71 #[default]
74 Puzzle,
75 Arena,
78}
79
80impl LevelKind {
81 pub fn token(self) -> &'static str {
82 match self {
83 LevelKind::Puzzle => "puzzle",
84 LevelKind::Arena => "arena",
85 }
86 }
87
88 pub fn from_token(token: &str) -> Option<LevelKind> {
89 match token {
90 "puzzle" => Some(LevelKind::Puzzle),
91 "arena" => Some(LevelKind::Arena),
92 _ => None,
93 }
94 }
95}
96
97#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub enum Goal {
102 AllCrabs,
104 Bank(u32),
106 Survive,
108 Golden,
110}
111
112#[derive(Clone, Copy, PartialEq, Eq, Debug)]
113pub enum PuzzleOutcome {
114 Running,
115 Won,
116 Lost,
118}
119
120impl Level {
121 pub fn board(&self) -> Board {
130 let mut board = self.board.clone();
131 if !self.explicit_rule && self.kind == LevelKind::Puzzle {
132 board.set_signpost_rule(self.posts, CapPolicy::Reject);
133 }
134 board
135 }
136
137 pub fn crab_count(&self) -> u32 {
138 self.crab_count
139 }
140
141 pub fn with_kind(mut self, kind: LevelKind) -> Level {
144 self.kind = kind;
145 self
146 }
147
148 pub fn seats(&self) -> u8 {
152 self.board.castle_seats()
153 }
154
155 pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
160 let alive = board.crabs().len() as u32;
161 let eaten = board.crabs_banked() + alive < board.crabs_spawned();
162 let timed_out = board.round_over() || board.ticks() >= PUZZLE_TICK_LIMIT;
163 let (lost, won) = match self.goal {
167 Goal::AllCrabs => (
168 eaten,
169 alive == 0 && board.crabs_banked() == board.crabs_spawned(),
170 ),
171 Goal::Bank(n) => (false, board.crabs_banked() >= n),
172 Goal::Survive => (eaten, timed_out),
176 Goal::Golden => (false, board.golden_banked() >= 1),
177 };
178 if lost {
179 PuzzleOutcome::Lost
180 } else if won {
181 PuzzleOutcome::Won
182 } else if timed_out {
183 PuzzleOutcome::Lost
185 } else {
186 PuzzleOutcome::Running
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use crate::sim::direction::Direction;
194
195 #[test]
196 fn parse_rejects_bad_tile_chars() {
197 let text = "name: Bad\nposts: 1\nmap:\n+-+\n|X|\n+-+\n";
198 let err = super::Level::parse(text).unwrap_err();
199 assert!(err.contains("bad tile char"), "{err}");
200 }
201
202 #[test]
203 fn parse_rejects_unknown_keys() {
204 let text = "name: Bad\nwibble: 3\nmap:\n+-+\n|.|\n+-+\n";
205 let err = super::Level::parse(text).unwrap_err();
206 assert!(err.contains("unknown key"), "{err}");
207 }
208
209 #[test]
210 fn parse_rejects_malformed_solutions_and_directions() {
211 let text = "name: Bad\nposts: 1\nsolution: 0,0 Q\nmap:\n+-+\n|.|\n+-+\n";
212 assert!(super::Level::parse(text).is_err());
213 let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
214 assert!(super::Level::parse(text).is_err());
215 }
216
217 #[test]
223 fn parse_rejects_a_map_with_no_tiles() {
224 for text in [
225 "name: Bad\nposts: 1\nmap:\n+-+-+\n", "name: Bad\nposts: 1\nmap:\n+\n|\n+\n", "name: Bad\nposts: 1\nmap:\n+\n",
228 ] {
229 let err = super::Level::parse(text).unwrap_err();
230 assert!(err.contains("at least one tile"), "{text:?} gave {err}");
231 }
232 }
233
234 #[test]
238 fn parse_rejects_a_map_too_wide_to_name() {
239 let border: String = "+-".repeat(300) + "+";
240 let row: String = "|.".repeat(300) + "|";
241 let text = format!("name: Vast\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
242 let err = super::Level::parse(&text).unwrap_err();
243 assert!(err.contains("300"), "{err}");
244 let border: String = "+-".repeat(255) + "+";
246 let row: String = "|.".repeat(255) + "|";
247 let text = format!("name: Wide\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
248 let level = super::Level::parse(&text).expect("255 tiles is nameable");
249 assert_eq!(level.board.width(), 255);
250 }
251
252 #[test]
256 fn parse_pads_short_map_rows() {
257 let text = "name: Trimmed\nposts: 1\nmap:\n+-+-+\n|.\n+-+-+\n";
258 let level = super::Level::parse(text).expect("pads, never panics");
259 assert_eq!(level.board.width(), 2);
260 }
261
262 use super::*;
263 use crate::sim::board::TileKind;
264
265 #[test]
269 fn a_turnstiles_pivot_survives_the_format() {
270 for next_right in [true, false] {
271 let mut board = Board::new(4, 3, 1);
272 board.set_tile(1, 1, TileKind::Turnstile { next_right });
273 let text = Level::from_board("Pivot", 1, board).to_text();
274 let parsed = Level::parse(&text).expect("own output").board();
275 assert_eq!(
276 parsed.tile_at(1, 1),
277 TileKind::Turnstile { next_right },
278 "next_right {next_right} was not preserved:\n{text}"
279 );
280 }
281 }
282
283 const TINY: &str = "\
284name: Tiny
285posts: 1
286crab: 0,0 R L common
287solution: 2,0 D
288map:
289+-+-+-+
290|. . .|
291+ +-+ +
292|. . 0|
293+-+-+-+
294";
295
296 #[test]
297 fn terrain_tiles_round_trip() {
298 let mut board = Board::new(4, 3, 7);
299 board.set_tile(1, 1, TileKind::Turnstile { next_right: true });
300 board.set_tile(2, 1, TileKind::Kelp);
301 board.set_tile(3, 1, TileKind::Pool);
302 let text = Level::from_board("Terrain", 2, board).to_text();
303 let level = Level::parse(&text).expect("parses");
304 assert_eq!(
305 level.board.tile_at(1, 1),
306 TileKind::Turnstile { next_right: true }
307 );
308 assert_eq!(level.board.tile_at(2, 1), TileKind::Kelp);
309 assert_eq!(level.board.tile_at(3, 1), TileKind::Pool);
310 assert_eq!(level.to_text(), text, "text form is stable");
311 }
312
313 #[test]
314 fn parses_dimensions_walls_and_tiles() {
315 let level = Level::parse(TINY).expect("parses");
316 assert_eq!(level.name, "Tiny");
317 assert_eq!(level.posts, 1);
318 assert_eq!(level.crab_count(), 1);
319 let board = level.board();
320 assert_eq!((board.width(), board.height()), (3, 2));
321 assert_eq!(board.tile_at(2, 1), TileKind::Castle(0));
322 assert!(board.wall_at(1, 0, Direction::Down));
323 assert!(!board.wall_at(0, 0, Direction::Down));
324 assert_eq!(board.crabs().len(), 1);
325 }
326
327 #[test]
328 fn solution_wins_the_puzzle() {
329 let level = Level::parse(TINY).expect("parses");
330 let mut board = level.board();
331 for &(x, y, dir) in &level.solution {
332 assert!(board.place_signpost(0, x, y, dir), "placement ({x},{y})");
333 }
334 let outcome = loop {
335 board.tick_idle();
336 match level.outcome(&board) {
337 PuzzleOutcome::Running => {}
338 done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => break done,
339 }
340 };
341 assert_eq!(outcome, PuzzleOutcome::Won);
342 }
343
344 #[test]
345 fn serialization_round_trips() {
346 let level = Level::parse(TINY).expect("parses");
347 let text = level.to_text();
348 let again = Level::parse(&text).unwrap_or_else(|e| panic!("reparse: {e}\n{text}"));
349 assert_eq!(level.name, again.name);
350 assert_eq!(level.posts, again.posts);
351 assert_eq!(level.solution, again.solution);
352 assert_eq!(
353 level.board().state_hash(),
354 again.board().state_hash(),
355 "round-tripped board must be bit-identical"
356 );
357 }
358
359 #[test]
360 fn timed_level_loses_at_the_wave() {
361 let text = TINY.replace("posts: 1", "posts: 1\nround: 10");
364 let level = Level::parse(&text).expect("parses");
365 let mut board = level.board();
366 for _ in 0..12 {
367 board.tick_idle();
368 }
369 assert!(board.round_over());
370 assert_eq!(level.outcome(&board), PuzzleOutcome::Lost);
371 }
372
373 #[test]
374 fn goal_wrap_and_new_kinds_round_trip() {
375 let text = "\
376name: Fancy
377posts: 2
378rule: evict 3
379round: 900
380wrap: on
381goal: bank 30
382crab: 0,0 R L golden
383crab: 2,1 L R sparkling
384map:
385+-+-+-+-+
386|. . . .|
387+ + + + +
388|. 0 . .|
389+-+-+-+-+
390";
391 let level = Level::parse(text).expect("parses");
392 assert_eq!(level.goal, Goal::Bank(30));
393 assert!(level.board().wrap());
394 let again = Level::parse(&level.to_text()).expect("reparses");
395 assert_eq!(again.goal, level.goal);
396 assert_eq!(
397 again.board().state_hash(),
398 level.board().state_hash(),
399 "wrap/goal/kind round-trip must be bit-identical"
400 );
401 }
402
403 #[test]
404 fn goal_outcomes_judge_correctly() {
405 let bank = Level::parse(
407 "name: B\nposts: 0\nrule: evict 3\nround: 900\ngoal: bank 1\n\
408 crab: 0,1 R L common\nmap:\n+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+-+-+-+\n",
409 )
410 .expect("parses");
411 let mut board = bank.board();
412 assert_eq!(bank.outcome(&board), PuzzleOutcome::Running);
413 for _ in 0..200 {
414 board.tick_idle();
415 }
416 assert_eq!(bank.outcome(&board), PuzzleOutcome::Won);
417
418 let survive = Level::parse(
421 "name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
422 crab: 1,0 R L common\ngull: 1,0 R\nmap:\n+-+-+-+-+-+-+-+-+-+\n\
423 |. . . . . . . . .|\n+-+-+-+-+-+-+-+-+-+\n",
424 )
425 .expect("parses");
426 let mut board = survive.board();
427 board.tick_idle();
428 assert_eq!(
429 survive.outcome(&board),
430 PuzzleOutcome::Lost,
431 "the gull got someone"
432 );
433 }
434
435 #[test]
439 fn the_kind_round_trips_and_outranks_the_castles() {
440 let level = Level::parse(TINY).expect("parses");
441 assert_eq!(level.kind, LevelKind::Puzzle, "one castle, one player");
442 for kind in [LevelKind::Puzzle, LevelKind::Arena] {
443 let text = level.clone().with_kind(kind).to_text();
444 assert_eq!(Level::parse(&text).expect("reparses").kind, kind, "{text}");
445 }
446 let four = "\
448name: Four
449posts: 1
450kind: puzzle
451crab: 0,0 R L common
452map:
453+-+-+-+-+
454|0 1 2 3|
455+-+-+-+-+
456";
457 let level = Level::parse(four).expect("parses");
458 assert_eq!(level.seats(), 4);
459 assert_eq!(level.kind, LevelKind::Puzzle, "the line beats the board");
460 assert!(Level::parse(&four.replace("kind: puzzle", "kind: mud")).is_err());
461 }
462
463 #[test]
467 fn a_file_with_no_kind_is_read_off_its_castles() {
468 assert_eq!(Level::parse(TINY).expect("parses").kind, LevelKind::Puzzle);
469 let two = "\
470name: Old Arena
471posts: 3
472crab: 0,0 R L common
473map:
474+-+-+-+
475|0 . 1|
476+-+-+-+
477";
478 let level = Level::parse(two).expect("parses");
479 assert_eq!(level.seats(), 2);
480 assert_eq!(level.kind, LevelKind::Arena);
481 assert!(
483 level.to_text().contains("kind: arena"),
484 "{}",
485 level.to_text()
486 );
487 }
488
489 #[test]
490 fn inventory_is_enforced() {
491 let level = Level::parse(TINY).expect("parses");
492 let mut board = level.board();
493 assert!(board.place_signpost(0, 0, 1, Direction::Up));
494 assert!(!board.place_signpost(0, 1, 1, Direction::Up));
496 assert_eq!(board.signpost_count(0), 1);
497 }
498}