1use std::collections::{BTreeMap, BTreeSet};
10
11pub type NodeId = usize;
13
14pub type PieceId = usize;
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
31#[repr(u8)]
32pub enum Dir {
33 Up = 0,
34 RightUp,
35 RightDown,
36 Down,
37 LeftDown,
38 LeftUp,
39}
40
41impl Dir {
42 pub const ALL: [Dir; 6] = [
45 Dir::Up,
46 Dir::RightUp,
47 Dir::RightDown,
48 Dir::Down,
49 Dir::LeftDown,
50 Dir::LeftUp,
51 ];
52
53 pub fn delta(self) -> (i32, i32) {
55 match self {
56 Dir::Up => (0, -1),
57 Dir::RightUp => (1, -1),
58 Dir::RightDown => (1, 0),
59 Dir::Down => (0, 1),
60 Dir::LeftDown => (-1, 1),
61 Dir::LeftUp => (-1, 0),
62 }
63 }
64
65 pub fn opposite(self) -> Dir {
67 match self {
68 Dir::Up => Dir::Down,
69 Dir::RightUp => Dir::LeftDown,
70 Dir::RightDown => Dir::LeftUp,
71 Dir::Down => Dir::Up,
72 Dir::LeftDown => Dir::RightUp,
73 Dir::LeftUp => Dir::RightDown,
74 }
75 }
76}
77
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Default)]
84pub struct Arrows(u8);
85
86impl Arrows {
87 pub fn from_dir(dir: Dir) -> Self {
89 Self(1 << dir as u8)
90 }
91
92 pub fn all() -> Self {
94 Self(0b0011_1111)
95 }
96
97 pub fn contains(self, dir: Dir) -> bool {
99 self.0 & (1 << dir as u8) != 0
100 }
101
102 pub fn with(self, dir: Dir) -> Self {
104 Self(self.0 | (1 << dir as u8))
105 }
106
107 pub fn without(self, dir: Dir) -> Self {
109 Self(self.0 & !(1 << dir as u8))
110 }
111
112 pub fn len(self) -> usize {
114 self.0.count_ones() as usize
115 }
116
117 pub fn is_empty(self) -> bool {
119 self.0 == 0
120 }
121
122 pub(crate) fn bits(self) -> u8 {
125 self.0
126 }
127
128 pub fn iter(self) -> impl Iterator<Item = Dir> {
130 Dir::ALL.into_iter().filter(move |&dir| self.contains(dir))
131 }
132}
133
134impl IntoIterator for Arrows {
135 type Item = Dir;
136 type IntoIter = std::vec::IntoIter<Dir>;
137
138 fn into_iter(self) -> Self::IntoIter {
139 self.iter().collect::<Vec<_>>().into_iter()
140 }
141}
142
143#[derive(Clone, Copy, PartialEq, Eq, Debug)]
145pub struct Piece {
146 pub arrows: Arrows,
147}
148
149#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
151pub enum GameStatus {
152 #[default]
153 InProgress,
154 Won,
155}
156
157#[derive(Clone, Copy, Debug)]
159pub struct Node {
160 pub q: i32,
161 pub r: i32,
162 pub neighbors: [Option<NodeId>; 6],
164}
165
166#[derive(Clone, Debug)]
168pub struct Params {
169 pub nodes: Vec<(i32, i32)>,
172 pub pieces: usize,
176 pub min_arrows: usize,
178 pub max_arrows: usize,
180}
181
182#[derive(Clone, Debug)]
185pub struct HexwebGame {
186 nodes: Vec<Node>,
187 lookup: BTreeMap<(i32, i32), NodeId>,
190 pieces: Vec<Piece>,
191 cell_piece: Vec<Option<PieceId>>,
193 initial: Vec<Option<PieceId>>,
195 moves: usize,
196 status: GameStatus,
197}
198
199pub fn hexagon(radius: u32) -> Vec<(i32, i32)> {
202 let r = radius as i32;
203 let mut coords = Vec::new();
204 for q in -r..=r {
205 for rr in (-r).max(-q - r)..=r.min(-q + r) {
206 coords.push((q, rr));
207 }
208 }
209 coords
210}
211
212type Cell = (i32, i32);
214
215fn rot((q, r): Cell) -> Cell {
217 (-r, q + r)
218}
219
220#[cfg(test)]
222fn rot_inv((q, r): Cell) -> Cell {
223 (r + q, -q)
224}
225
226fn mirror_v((q, r): Cell) -> Cell {
228 (-q, r + q)
229}
230
231fn mirror_h((q, r): Cell) -> Cell {
233 (q, -q - r)
234}
235
236type Orbit = (Cell, Option<Cell>);
239
240fn mirror_orbits(cells: &[Cell], mirror: fn(Cell) -> Cell) -> Vec<Orbit> {
243 let mut seen: BTreeSet<Cell> = BTreeSet::new();
244 let mut orbits = Vec::new();
245 for &cell in cells {
246 if seen.insert(cell) {
247 let image = mirror(cell);
248 let twin = (image != cell).then_some(image);
249 seen.extend(twin);
250 orbits.push((cell, twin));
251 }
252 }
253 orbits
254}
255
256fn is_connected(coords: &[Cell]) -> bool {
259 let set: BTreeSet<Cell> = coords.iter().copied().collect();
260 let start = coords[0];
261 let mut seen: BTreeSet<Cell> = BTreeSet::from([start]);
262 let mut stack = vec![start];
263 while let Some(cell) = stack.pop() {
264 for dir in Dir::ALL {
265 let (dq, dr) = dir.delta();
266 let next = (cell.0 + dq, cell.1 + dr);
267 if set.contains(&next) && seen.insert(next) {
268 stack.push(next);
269 }
270 }
271 }
272 seen.len() == coords.len()
273}
274
275fn symmetric_candidates(nodes: usize) -> Vec<Vec<Cell>> {
286 let pool = hexagon(2);
287 let mut found: BTreeSet<Vec<Cell>> = BTreeSet::new();
288 for mirror in [mirror_v, mirror_h] {
289 let orbits = mirror_orbits(&pool, mirror);
290 for mask in 0..1u64 << orbits.len() {
291 let mut coords: Vec<Cell> = Vec::new();
292 for (bit, &(cell, twin)) in orbits.iter().enumerate() {
293 if mask >> bit & 1 == 1 {
294 coords.push(cell);
295 coords.extend(twin);
296 }
297 }
298 if coords.len() != nodes || !is_connected(&coords) {
299 continue;
300 }
301 for times in 0..3u32 {
306 let mut candidate = coords.clone();
307 for _ in 0..times {
308 candidate = candidate.iter().copied().map(rot).collect();
309 }
310 candidate.sort_unstable();
311 found.insert(candidate);
312 }
313 }
314 }
315 found.into_iter().collect()
316}
317
318pub fn random_symmetric_board(nodes: usize, seed: u64) -> Vec<(i32, i32)> {
328 assert!(
329 (8..=16).contains(&nodes),
330 "random_symmetric_board supports 8..=16 nodes, got {nodes}"
331 );
332 let candidates = symmetric_candidates(nodes);
333 let mut rng = fastrand::Rng::with_seed(seed);
334 candidates[rng.usize(..candidates.len())].clone()
335}
336
337pub(crate) fn build_nodes(coords: &[(i32, i32)]) -> (Vec<Node>, BTreeMap<(i32, i32), NodeId>) {
340 let lookup: BTreeMap<(i32, i32), NodeId> = coords
341 .iter()
342 .enumerate()
343 .map(|(id, &coord)| (coord, id))
344 .collect();
345 let nodes = coords
346 .iter()
347 .map(|&(q, r)| Node {
348 q,
349 r,
350 neighbors: std::array::from_fn(|i| {
351 let (dq, dr) = Dir::ALL[i].delta();
352 lookup.get(&(q + dq, r + dr)).copied()
353 }),
354 })
355 .collect();
356 (nodes, lookup)
357}
358
359impl HexwebGame {
360 pub fn random(params: Params, seed: u64) -> Self {
372 validate_params(¶ms);
373 let (nodes, _) = build_nodes(¶ms.nodes);
374 let mut rng = fastrand::Rng::with_seed(seed);
375 let generated = crate::generator::generate(
376 &nodes,
377 params.pieces,
378 params.min_arrows,
379 params.max_arrows,
380 &mut rng,
381 );
382 let pieces: Vec<Piece> = generated
383 .arrows
384 .into_iter()
385 .map(|arrows| Piece { arrows })
386 .collect();
387 Self::from_parts(
388 nodes,
389 pieces,
390 generated.scramble.clone(),
391 generated.scramble,
392 )
393 }
394
395 pub(crate) fn from_parts(
396 nodes: Vec<Node>,
397 pieces: Vec<Piece>,
398 cell_piece: Vec<Option<PieceId>>,
399 initial: Vec<Option<PieceId>>,
400 ) -> Self {
401 debug_assert_eq!(nodes.len(), cell_piece.len());
402 debug_assert_eq!(cell_piece.len(), initial.len());
403 let lookup: BTreeMap<(i32, i32), NodeId> = nodes
404 .iter()
405 .enumerate()
406 .map(|(id, node)| ((node.q, node.r), id))
407 .collect();
408 Self {
409 nodes,
410 lookup,
411 pieces,
412 cell_piece,
413 initial,
414 moves: 0,
415 status: GameStatus::InProgress,
416 }
417 }
418
419 pub fn nodes(&self) -> &[Node] {
422 &self.nodes
423 }
424
425 pub fn node_count(&self) -> usize {
427 self.nodes.len()
428 }
429
430 pub fn piece_count(&self) -> usize {
432 self.pieces.len()
433 }
434
435 pub fn empty_count(&self) -> usize {
437 self.nodes.len() - self.pieces.len()
438 }
439
440 pub fn pieces(&self) -> &[Piece] {
442 &self.pieces
443 }
444
445 pub fn piece_at(&self, node: NodeId) -> Option<PieceId> {
447 self.cell_piece[node]
448 }
449
450 pub fn node_of(&self, piece: PieceId) -> Option<NodeId> {
452 self.cell_piece
453 .iter()
454 .position(|&occupant| occupant == Some(piece))
455 }
456
457 pub fn node_at(&self, q: i32, r: i32) -> Option<NodeId> {
459 self.lookup.get(&(q, r)).copied()
460 }
461
462 pub fn can_move(&self, piece: PieceId, to: NodeId) -> bool {
466 if self.status != GameStatus::InProgress || to >= self.nodes.len() {
467 return false;
468 }
469 match self.node_of(piece) {
470 Some(from) => from != to && self.cell_piece[to].is_none(),
471 None => false,
472 }
473 }
474
475 pub fn move_piece(&mut self, piece: PieceId, to: NodeId) -> bool {
480 let Some(from) = self.node_of(piece) else {
481 return false;
482 };
483 if !self.can_move(piece, to) {
484 return false;
485 }
486 self.cell_piece[from] = None;
487 self.cell_piece[to] = Some(piece);
488 self.moves += 1;
489 if self.is_solved() {
490 self.status = GameStatus::Won;
491 }
492 true
493 }
494
495 pub fn satisfied(&self, piece: PieceId) -> bool {
497 self.unsatisfied_arrows(piece).is_empty()
498 }
499
500 pub fn unsatisfied_arrows(&self, piece: PieceId) -> Arrows {
503 let Some(node) = self.node_of(piece) else {
504 return self.pieces[piece].arrows;
505 };
506 let mut result = Arrows::default();
507 for dir in self.pieces[piece].arrows.iter() {
508 match self.nodes[node].neighbors[dir as usize] {
509 Some(neighbor) if self.cell_piece[neighbor].is_some() => {}
510 _ => result = result.with(dir),
511 }
512 }
513 result
514 }
515
516 pub fn is_solved(&self) -> bool {
518 (0..self.pieces.len()).all(|piece| self.satisfied(piece))
519 }
520
521 pub fn moves(&self) -> usize {
523 self.moves
524 }
525
526 pub fn status(&self) -> GameStatus {
529 self.status
530 }
531
532 pub fn reset(&mut self) {
534 self.cell_piece = self.initial.clone();
535 self.moves = 0;
536 self.status = GameStatus::InProgress;
537 }
538
539 pub fn solution_count(&self, cap: usize) -> usize {
544 crate::solver::count(&self.pieces, &self.nodes, cap)
545 }
546
547 pub fn solution(&self) -> Option<Vec<NodeId>> {
550 crate::solver::solution(&self.pieces, &self.nodes)
551 }
552}
553
554fn validate_params(params: &Params) {
555 assert!(!params.nodes.is_empty(), "board needs at least one node");
556 let mut sorted = params.nodes.clone();
557 sorted.sort_unstable();
558 sorted.dedup();
559 assert_eq!(
560 sorted.len(),
561 params.nodes.len(),
562 "board nodes must be distinct coordinates"
563 );
564 let (built, _) = build_nodes(¶ms.nodes);
565 assert!(
566 built
567 .iter()
568 .all(|node| node.neighbors.iter().any(Option::is_some)),
569 "board has isolated nodes with no in-board neighbor"
570 );
571 assert!(
572 params.pieces >= 1,
573 "board needs at least one piece, got {}",
574 params.pieces
575 );
576 assert!(
577 params.pieces < params.nodes.len(),
578 "board needs at least one empty node: {} pieces on {} nodes",
579 params.pieces,
580 params.nodes.len()
581 );
582 assert!(
583 (1..=6).contains(¶ms.min_arrows)
584 && (params.min_arrows..=6).contains(¶ms.max_arrows),
585 "arrow range must be within 1..=6 with min <= max, got {}..={}",
586 params.min_arrows,
587 params.max_arrows
588 );
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 fn line_game() -> HexwebGame {
600 let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1)]);
601 let pieces = vec![
602 Piece {
603 arrows: Arrows::from_dir(Dir::Up),
604 },
605 Piece {
606 arrows: Arrows::from_dir(Dir::Down),
607 },
608 ];
609 let cell_piece = vec![Some(1), Some(0), None];
610 HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
611 }
612
613 #[test]
614 fn dir_delta_and_opposite_tables() {
615 let expected = [
616 (Dir::Up, (0, -1), Dir::Down),
617 (Dir::RightUp, (1, -1), Dir::LeftDown),
618 (Dir::RightDown, (1, 0), Dir::LeftUp),
619 (Dir::Down, (0, 1), Dir::Up),
620 (Dir::LeftDown, (-1, 1), Dir::RightUp),
621 (Dir::LeftUp, (-1, 0), Dir::RightDown),
622 ];
623 for &(dir, delta, opposite) in &expected {
624 assert_eq!(dir.delta(), delta);
625 assert_eq!(dir.opposite(), opposite);
626 assert_eq!(opposite.opposite(), dir);
627 }
628 assert_eq!(Dir::ALL.len(), 6);
629 }
630
631 #[test]
632 fn arrows_bitmask_round_trip() {
633 let mut arrows = Arrows::default();
634 assert!(arrows.is_empty());
635 assert_eq!(arrows.len(), 0);
636 arrows = arrows.with(Dir::Up).with(Dir::LeftDown);
637 assert_eq!(arrows.len(), 2);
638 assert!(arrows.contains(Dir::Up));
639 assert!(arrows.contains(Dir::LeftDown));
640 assert!(!arrows.contains(Dir::Down));
641 assert_eq!(
642 arrows.iter().collect::<Vec<_>>(),
643 vec![Dir::Up, Dir::LeftDown]
644 );
645 arrows = arrows.without(Dir::Up);
646 assert_eq!(arrows, Arrows::from_dir(Dir::LeftDown));
647 assert_eq!(Arrows::all().len(), 6);
648 }
649
650 #[test]
651 fn hexagon_sizes() {
652 assert_eq!(hexagon(0).len(), 1);
653 assert_eq!(hexagon(1).len(), 7);
654 assert_eq!(hexagon(2).len(), 19);
655 assert!(hexagon(1).contains(&(0, 0)));
656 assert!(hexagon(1).contains(&(-1, 1)));
657 assert!(!hexagon(1).contains(&(0, 2)));
658 }
659
660 fn conjugate(mirror: fn(Cell) -> Cell, k: u32) -> impl Fn(Cell) -> Cell {
663 move |cell: Cell| {
664 let mut c = cell;
665 for _ in 0..k {
666 c = rot_inv(c);
667 }
668 let mut out = mirror(c);
669 for _ in 0..k {
670 out = rot(out);
671 }
672 out
673 }
674 }
675
676 #[test]
677 fn random_boards_are_symmetric_connected_and_sized() {
678 let pool = hexagon(2);
679 for nodes in 8..=16 {
680 for seed in 0..32u64 {
681 let coords = random_symmetric_board(nodes, seed);
682 assert_eq!(coords.len(), nodes, "shape of {nodes} nodes, seed {seed}");
683 assert!(
684 coords.windows(2).all(|w| w[0] < w[1]),
685 "shape of {nodes} nodes, seed {seed} is not sorted"
686 );
687
688 let set: BTreeSet<Cell> = coords.iter().copied().collect();
690 let mut symmetric = false;
691 for mirror in [mirror_v, mirror_h] {
692 for k in 0..3u32 {
693 let map = conjugate(mirror, k);
694 symmetric |= set.iter().all(|&c| set.contains(&map(c)));
695 }
696 }
697 assert!(
698 symmetric,
699 "shape of {nodes} nodes, seed {seed} is not mirror-symmetric"
700 );
701
702 assert!(
703 coords.iter().all(|c| pool.contains(c)),
704 "shape of {nodes} nodes, seed {seed} leaves hexagon(2)"
705 );
706 assert!(
707 is_connected(&coords),
708 "shape of {nodes} nodes, seed {seed} is not connected"
709 );
710
711 assert_eq!(coords, random_symmetric_board(nodes, seed));
713 }
714 }
715 }
716
717 #[test]
718 fn every_size_offers_a_variety_of_shapes() {
719 for nodes in 8..=16 {
720 let candidates = symmetric_candidates(nodes);
721 println!("{nodes} nodes: {} shapes", candidates.len());
722 assert!(
723 candidates.len() >= 10,
724 "only {} shapes for {nodes} nodes",
725 candidates.len()
726 );
727 let unique: BTreeSet<&Vec<Cell>> = candidates.iter().collect();
728 assert_eq!(
729 unique.len(),
730 candidates.len(),
731 "duplicate shapes at {nodes} nodes"
732 );
733 }
734
735 let mut old8 = hexagon(1);
739 old8.push((0, -2));
740 old8.sort_unstable();
741 assert!(symmetric_candidates(8).contains(&old8));
742 let old16: Vec<Cell> = hexagon(2)
743 .into_iter()
744 .filter(|&c| !matches!(c, (0, 2) | (2, 0) | (-2, 2)))
745 .collect();
746 assert!(symmetric_candidates(16).contains(&old16));
747 }
748
749 #[test]
750 fn neighbor_tables_are_antisymmetric() {
751 for nodes in 8..=16 {
752 let coords = random_symmetric_board(nodes, nodes as u64);
753 let (built, _) = build_nodes(&coords);
754 for (id, node) in built.iter().enumerate() {
755 for (i, &neighbor) in node.neighbors.iter().enumerate() {
756 if let Some(neighbor) = neighbor {
757 let back = built[neighbor].neighbors[Dir::ALL[i].opposite() as usize];
758 assert_eq!(back, Some(id));
759 }
760 }
761 }
762 }
763 }
764
765 #[test]
766 fn accessors_report_the_board() {
767 let game = line_game();
768 assert_eq!(game.node_count(), 3);
769 assert_eq!(game.piece_count(), 2);
770 assert_eq!(game.empty_count(), 1);
771 assert_eq!(game.node_at(0, -1), Some(1));
772 assert_eq!(game.node_at(5, 5), None);
773 assert_eq!(game.piece_at(1), Some(0));
774 assert_eq!(game.piece_at(2), None);
775 assert_eq!(game.node_of(0), Some(1));
776 assert_eq!(game.pieces()[1].arrows, Arrows::from_dir(Dir::Down));
777 assert_eq!(game.nodes()[1].q, 0);
778 assert_eq!(game.nodes()[1].r, -1);
779 }
780
781 #[test]
782 fn starts_unsatisfied_and_reports_which_arrows() {
783 let game = line_game();
784 assert!(!game.is_solved());
785 assert_eq!(game.status(), GameStatus::InProgress);
786 assert_eq!(game.unsatisfied_arrows(0), Arrows::from_dir(Dir::Up));
788 assert_eq!(game.unsatisfied_arrows(1), Arrows::from_dir(Dir::Down));
790 assert!(!game.satisfied(0));
791 assert!(!game.satisfied(1));
792 }
793
794 #[test]
795 fn illegal_moves_are_rejected() {
796 let mut game = line_game();
797 assert!(!game.can_move(0, 1)); assert!(!game.can_move(0, 0)); assert!(!game.can_move(0, 99)); assert_eq!(game.moves(), 0);
801 assert!(!game.move_piece(0, 0));
802 assert!(!game.move_piece(0, 1));
803 assert_eq!(game.moves(), 0);
804 }
805
806 #[test]
807 fn winning_move_latches_and_reset_restores() {
808 let mut game = line_game();
809 assert!(game.move_piece(0, 2));
810 assert_eq!(game.moves(), 1);
811 assert!(game.is_solved());
812 assert_eq!(game.status(), GameStatus::Won);
813 assert_eq!(game.unsatisfied_arrows(0), Arrows::default());
814 assert!(game.satisfied(1));
815
816 assert!(!game.can_move(1, 1));
818 assert!(!game.move_piece(1, 1));
819 assert_eq!(game.moves(), 1);
820
821 game.reset();
822 assert_eq!(game.status(), GameStatus::InProgress);
823 assert_eq!(game.moves(), 0);
824 assert_eq!(game.piece_at(1), Some(0));
825 assert!(!game.is_solved());
826 }
827
828 #[test]
829 fn params_are_validated() {
830 let valid = Params {
831 nodes: random_symmetric_board(8, 0),
832 pieces: 6,
833 min_arrows: 2,
834 max_arrows: 3,
835 };
836 validate_params(&valid);
837
838 let mut dup = valid.clone();
839 dup.nodes[1] = dup.nodes[0];
840 assert!(std::panic::catch_unwind(|| validate_params(&dup)).is_err());
841
842 let mut full = valid.clone();
843 full.pieces = full.nodes.len();
844 assert!(std::panic::catch_unwind(|| validate_params(&full)).is_err());
845
846 let mut no_pieces = valid.clone();
847 no_pieces.pieces = 0;
848 assert!(std::panic::catch_unwind(|| validate_params(&no_pieces)).is_err());
849
850 let mut no_arrows = valid.clone();
851 no_arrows.min_arrows = 0;
852 assert!(std::panic::catch_unwind(|| validate_params(&no_arrows)).is_err());
853
854 let mut too_many = valid.clone();
855 too_many.max_arrows = 7;
856 assert!(std::panic::catch_unwind(|| validate_params(&too_many)).is_err());
857
858 let mut inverted = valid.clone();
859 inverted.min_arrows = 4;
860 inverted.max_arrows = 3;
861 assert!(std::panic::catch_unwind(|| validate_params(&inverted)).is_err());
862
863 let mut isolated = valid.clone();
864 isolated.nodes.push((100, 100));
865 assert!(std::panic::catch_unwind(|| validate_params(&isolated)).is_err());
866 }
867}