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 board.set_castle_raids(false);
138 }
139 board
140 }
141
142 pub fn crab_count(&self) -> u32 {
143 self.crab_count
144 }
145
146 pub fn with_kind(mut self, kind: LevelKind) -> Level {
149 self.kind = kind;
150 self
151 }
152
153 pub fn seats(&self) -> u8 {
157 self.board.castle_seats()
158 }
159
160 pub fn play_out(&self, board: &mut Board) -> (PuzzleOutcome, u64) {
168 loop {
169 board.tick_idle();
170 match self.outcome(board) {
171 PuzzleOutcome::Running => {}
172 done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => return (done, board.ticks()),
173 }
174 }
175 }
176
177 pub fn place_solution(&self, board: &mut Board) -> Result<(), Placement> {
181 for &(x, y, dir) in &self.solution {
182 if !board.place_signpost(0, x, y, dir) {
183 return Err((x, y, dir));
184 }
185 }
186 Ok(())
187 }
188
189 pub fn solved_board(&self) -> Result<Board, Placement> {
191 let mut board = self.board();
192 self.place_solution(&mut board)?;
193 Ok(board)
194 }
195
196 pub fn deadline(board: &Board) -> u64 {
202 board.round_length().map_or(PUZZLE_TICK_LIMIT, u64::from)
203 }
204
205 pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
210 let alive = board.crabs().len() as u32;
211 let eaten = board.crabs_banked() + alive < board.crabs_spawned();
212 let timed_out = board.ticks() >= Self::deadline(board);
213 let (lost, won) = match self.goal {
217 Goal::AllCrabs => (
218 eaten,
219 alive == 0 && board.crabs_banked() == board.crabs_spawned(),
220 ),
221 Goal::Bank(n) => (false, board.crabs_banked() >= n),
222 Goal::Survive => (eaten, timed_out),
225 Goal::Golden => (false, board.golden_banked() >= 1),
226 };
227 if lost {
228 PuzzleOutcome::Lost
229 } else if won {
230 PuzzleOutcome::Won
231 } else if timed_out {
232 PuzzleOutcome::Lost
234 } else {
235 PuzzleOutcome::Running
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use crate::sim::direction::Direction;
243
244 #[test]
245 fn parse_rejects_bad_tile_chars() {
246 let text = "name: Bad\nposts: 1\nmap:\n+-+\n|X|\n+-+\n";
247 let err = super::Level::parse(text).unwrap_err();
248 assert!(err.contains("bad tile char"), "{err}");
249 }
250
251 #[test]
252 fn parse_rejects_unknown_keys() {
253 let text = "name: Bad\nwibble: 3\nmap:\n+-+\n|.|\n+-+\n";
254 let err = super::Level::parse(text).unwrap_err();
255 assert!(err.contains("unknown key"), "{err}");
256 }
257
258 #[test]
259 fn parse_rejects_malformed_solutions_and_directions() {
260 let text = "name: Bad\nposts: 1\nsolution: 0,0 Q\nmap:\n+-+\n|.|\n+-+\n";
261 assert!(super::Level::parse(text).is_err());
262 let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
263 assert!(super::Level::parse(text).is_err());
264 }
265
266 #[test]
272 fn parse_rejects_a_map_with_no_tiles() {
273 for text in [
274 "name: Bad\nposts: 1\nmap:\n+-+-+\n", "name: Bad\nposts: 1\nmap:\n+\n|\n+\n", "name: Bad\nposts: 1\nmap:\n+\n",
277 ] {
278 let err = super::Level::parse(text).unwrap_err();
279 assert!(err.contains("at least one tile"), "{text:?} gave {err}");
280 }
281 }
282
283 #[test]
287 fn parse_rejects_a_map_too_wide_to_name() {
288 let border: String = "+-".repeat(300) + "+";
289 let row: String = "|.".repeat(300) + "|";
290 let text = format!("name: Vast\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
291 let err = super::Level::parse(&text).unwrap_err();
292 assert!(err.contains("300"), "{err}");
293 let border: String = "+-".repeat(255) + "+";
295 let row: String = "|.".repeat(255) + "|";
296 let text = format!("name: Wide\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
297 let level = super::Level::parse(&text).expect("255 tiles is nameable");
298 assert_eq!(level.board.width(), 255);
299 }
300
301 #[test]
305 fn parse_pads_short_map_rows() {
306 let text = "name: Trimmed\nposts: 1\nmap:\n+-+-+\n|.\n+-+-+\n";
307 let level = super::Level::parse(text).expect("pads, never panics");
308 assert_eq!(level.board.width(), 2);
309 }
310
311 use super::*;
312 use crate::sim::board::TileKind;
313
314 #[test]
321 fn parse_refuses_a_map_row_wider_than_its_border() {
322 let text = "name: T\nposts: 1\nmap:\n+-+-+\n|.|.|.|\n+-+-+\n";
323 let err = Level::parse(text).unwrap_err();
324 assert!(err.contains("row 1 is 7 wide"), "{err}");
325 }
326
327 #[test]
332 fn castle_raids_survive_the_format() {
333 let map = "map:\n+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
334 let head = "name: T\nposts: 1\nrule: reject 1\ncrab: 0,0 R R common\n";
335 let kept = Level::parse(&format!("{head}{map}")).expect("level");
336 assert!(kept.board().castle_raids(), "stated rule, raids kept");
337 assert!(
338 !kept.to_text().contains("raids:"),
339 "the default is not written"
340 );
341 let off = Level::parse(&format!("{head}raids: off\n{map}")).expect("level");
342 assert!(!off.board().castle_raids());
343 let text = off.to_text();
344 assert!(text.contains("raids: off"), "{text}");
345 let back = Level::parse(&text).expect("round trip");
346 assert!(!back.board().castle_raids());
347 }
348
349 #[test]
354 fn a_stated_round_outlasts_the_tick_limit() {
355 let text = "name: T\nposts: 0\ngoal: survive\nround: 2700\nmap:\n\
356 +-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
357 let level = Level::parse(text).expect("level");
358 let mut board = level.board();
359 assert_eq!(Level::deadline(&board), 2700);
360 for _ in 0..PUZZLE_TICK_LIMIT {
361 board.tick_idle();
362 }
363 assert_eq!(
364 level.outcome(&board),
365 PuzzleOutcome::Running,
366 "the limit is not the round"
367 );
368 let (outcome, at) = level.play_out(&mut board);
369 assert_eq!((outcome, at), (PuzzleOutcome::Won, 2700));
370 let untimed = Level::parse(&text.replace("round: 2700\n", "")).expect("level");
372 assert_eq!(Level::deadline(&untimed.board()), PUZZLE_TICK_LIMIT);
373 }
374
375 #[test]
380 fn the_deadline_loses_every_goal_but_survive() {
381 let map = "map:\n+-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
382 let timed = Level::parse(&format!(
383 "name: T\nposts: 0\nround: 300\ncrab: 0,0 R R common\n{map}"
384 ))
385 .expect("level");
386 let mut board = timed.board();
387 assert_eq!(timed.play_out(&mut board), (PuzzleOutcome::Lost, 300));
388 let untimed = Level::parse(&format!("name: T\nposts: 0\ncrab: 0,0 R R common\n{map}"))
389 .expect("level");
390 let mut board = untimed.board();
391 assert_eq!(
392 untimed.play_out(&mut board),
393 (PuzzleOutcome::Lost, PUZZLE_TICK_LIMIT)
394 );
395 }
396
397 #[test]
401 fn raids_on_is_the_default_said_out_loud() {
402 let text = "name: T\nposts: 1\nrule: reject 1\nraids: on\ncrab: 0,0 R R common\nmap:\n\
403 +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
404 let level = Level::parse(text).expect("level");
405 assert!(level.board().castle_raids());
406 assert!(!level.to_text().contains("raids:"), "not worth writing");
407 }
408
409 #[test]
410 fn a_turnstiles_pivot_survives_the_format() {
411 for next_right in [true, false] {
412 let mut board = Board::new(4, 3, 1);
413 board.set_tile(1, 1, TileKind::Turnstile { next_right });
414 let text = Level::from_board("Pivot", 1, board).to_text();
415 let parsed = Level::parse(&text).expect("own output").board();
416 assert_eq!(
417 parsed.tile_at(1, 1),
418 TileKind::Turnstile { next_right },
419 "next_right {next_right} was not preserved:\n{text}"
420 );
421 }
422 }
423
424 const TINY: &str = "\
425name: Tiny
426posts: 1
427crab: 0,0 R L common
428solution: 2,0 D
429map:
430+-+-+-+
431|. . .|
432+ +-+ +
433|. . 0|
434+-+-+-+
435";
436
437 #[test]
438 fn terrain_tiles_round_trip() {
439 let mut board = Board::new(4, 3, 7);
440 board.set_tile(1, 1, TileKind::Turnstile { next_right: true });
441 board.set_tile(2, 1, TileKind::Kelp);
442 board.set_tile(3, 1, TileKind::Pool);
443 let text = Level::from_board("Terrain", 2, board).to_text();
444 let level = Level::parse(&text).expect("parses");
445 assert_eq!(
446 level.board.tile_at(1, 1),
447 TileKind::Turnstile { next_right: true }
448 );
449 assert_eq!(level.board.tile_at(2, 1), TileKind::Kelp);
450 assert_eq!(level.board.tile_at(3, 1), TileKind::Pool);
451 assert_eq!(level.to_text(), text, "text form is stable");
452 }
453
454 #[test]
455 fn parses_dimensions_walls_and_tiles() {
456 let level = Level::parse(TINY).expect("parses");
457 assert_eq!(level.name, "Tiny");
458 assert_eq!(level.posts, 1);
459 assert_eq!(level.crab_count(), 1);
460 let board = level.board();
461 assert_eq!((board.width(), board.height()), (3, 2));
462 assert_eq!(board.tile_at(2, 1), TileKind::Castle(0));
463 assert!(board.wall_at(1, 0, Direction::Down));
464 assert!(!board.wall_at(0, 0, Direction::Down));
465 assert_eq!(board.crabs().len(), 1);
466 }
467
468 #[test]
469 fn solution_wins_the_puzzle() {
470 let level = Level::parse(TINY).expect("parses");
471 let mut board = level.solved_board().expect("every placement lands");
472 assert_eq!(level.play_out(&mut board).0, PuzzleOutcome::Won);
473 }
474
475 #[test]
476 fn serialization_round_trips() {
477 let level = Level::parse(TINY).expect("parses");
478 let text = level.to_text();
479 let again = Level::parse(&text).unwrap_or_else(|e| panic!("reparse: {e}\n{text}"));
480 assert_eq!(level.name, again.name);
481 assert_eq!(level.posts, again.posts);
482 assert_eq!(level.solution, again.solution);
483 assert_eq!(
484 level.board().state_hash(),
485 again.board().state_hash(),
486 "round-tripped board must be bit-identical"
487 );
488 }
489
490 #[test]
491 fn timed_level_loses_at_the_wave() {
492 let text = TINY.replace("posts: 1", "posts: 1\nround: 10");
495 let level = Level::parse(&text).expect("parses");
496 let mut board = level.board();
497 for _ in 0..12 {
498 board.tick_idle();
499 }
500 assert!(board.round_over());
501 assert_eq!(level.outcome(&board), PuzzleOutcome::Lost);
502 }
503
504 #[test]
505 fn goal_wrap_and_new_kinds_round_trip() {
506 let text = "\
507name: Fancy
508posts: 2
509rule: evict 3
510round: 900
511wrap: on
512goal: bank 30
513crab: 0,0 R L golden
514crab: 2,1 L R sparkling
515map:
516+-+-+-+-+
517|. . . .|
518+ + + + +
519|. 0 . .|
520+-+-+-+-+
521";
522 let level = Level::parse(text).expect("parses");
523 assert_eq!(level.goal, Goal::Bank(30));
524 assert!(level.board().wrap());
525 let again = Level::parse(&level.to_text()).expect("reparses");
526 assert_eq!(again.goal, level.goal);
527 assert_eq!(
528 again.board().state_hash(),
529 level.board().state_hash(),
530 "wrap/goal/kind round-trip must be bit-identical"
531 );
532 }
533
534 #[test]
535 fn goal_outcomes_judge_correctly() {
536 let bank = Level::parse(
538 "name: B\nposts: 0\nrule: evict 3\nround: 900\ngoal: bank 1\n\
539 crab: 0,1 R L common\nmap:\n+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+-+-+-+\n",
540 )
541 .expect("parses");
542 let mut board = bank.board();
543 assert_eq!(bank.outcome(&board), PuzzleOutcome::Running);
544 for _ in 0..200 {
545 board.tick_idle();
546 }
547 assert_eq!(bank.outcome(&board), PuzzleOutcome::Won);
548
549 let survive = Level::parse(
552 "name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
553 crab: 1,0 R L common\ngull: 1,0 R\nmap:\n+-+-+-+-+-+-+-+-+-+\n\
554 |. . . . . . . . .|\n+-+-+-+-+-+-+-+-+-+\n",
555 )
556 .expect("parses");
557 let mut board = survive.board();
558 board.tick_idle();
559 assert_eq!(
560 survive.outcome(&board),
561 PuzzleOutcome::Lost,
562 "the gull got someone"
563 );
564 }
565
566 #[test]
570 fn the_kind_round_trips_and_outranks_the_castles() {
571 let level = Level::parse(TINY).expect("parses");
572 assert_eq!(level.kind, LevelKind::Puzzle, "one castle, one player");
573 for kind in [LevelKind::Puzzle, LevelKind::Arena] {
574 let text = level.clone().with_kind(kind).to_text();
575 assert_eq!(Level::parse(&text).expect("reparses").kind, kind, "{text}");
576 }
577 let four = "\
579name: Four
580posts: 1
581kind: puzzle
582crab: 0,0 R L common
583map:
584+-+-+-+-+
585|0 1 2 3|
586+-+-+-+-+
587";
588 let level = Level::parse(four).expect("parses");
589 assert_eq!(level.seats(), 4);
590 assert_eq!(level.kind, LevelKind::Puzzle, "the line beats the board");
591 assert!(Level::parse(&four.replace("kind: puzzle", "kind: mud")).is_err());
592 }
593
594 #[test]
598 fn a_file_with_no_kind_is_read_off_its_castles() {
599 assert_eq!(Level::parse(TINY).expect("parses").kind, LevelKind::Puzzle);
600 let two = "\
601name: Old Arena
602posts: 3
603crab: 0,0 R L common
604map:
605+-+-+-+
606|0 . 1|
607+-+-+-+
608";
609 let level = Level::parse(two).expect("parses");
610 assert_eq!(level.seats(), 2);
611 assert_eq!(level.kind, LevelKind::Arena);
612 assert!(
614 level.to_text().contains("kind: arena"),
615 "{}",
616 level.to_text()
617 );
618 }
619
620 #[test]
621 fn inventory_is_enforced() {
622 let level = Level::parse(TINY).expect("parses");
623 let mut board = level.board();
624 assert!(board.place_signpost(0, 0, 1, Direction::Up));
625 assert!(!board.place_signpost(0, 1, 1, Direction::Up));
627 assert_eq!(board.signpost_count(0), 1);
628 }
629}