Skip to main content

egui_hexweb/
game.rs

1//! Game logic for the hexweb puzzle: a hexagonal lattice of nodes where a
2//! subset of nodes holds pieces, each piece carrying arrows that must all
3//! point at occupied nodes.
4//!
5//! This module is pure logic: no `egui` types, no painting, no input. The
6//! [`HexwebGame`] struct owns the board and the piece placement; the widget
7//! in `crate::widget` renders and drives it.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Index into the board's node list.
12pub type NodeId = usize;
13
14/// Index into the board's piece list.
15pub type PieceId = usize;
16
17/// One of the six lattice directions of the flat-top hex grid, ordered
18/// clockwise starting at the top.
19///
20/// In axial coordinates the deltas are:
21///
22/// | direction   | delta     | pixel delta (size = circumradius)     |
23/// |-------------|-----------|---------------------------------------|
24/// | [`Dir::Up`]      | `(0, -1)` | `(0, -√3·size)`                  |
25/// | [`Dir::RightUp`] | `(1, -1)` | `(1.5·size, -√3/2·size)`         |
26/// | [`Dir::RightDown`]| `(1, 0)` | `(1.5·size, +√3/2·size)`         |
27/// | [`Dir::Down`]    | `(0, +1)` | `(0, +√3·size)`                  |
28/// | [`Dir::LeftDown`]| `(-1, +1)`| `(-1.5·size, +√3/2·size)`        |
29/// | [`Dir::LeftUp`]  | `(-1, 0)` | `(-1.5·size, -√3/2·size)`        |
30#[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    /// All directions, clockwise. The index in this array is the bit index
43    /// used by [`Arrows`] and the angle used when painting an arrow.
44    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    /// Axial-coordinate delta of this direction.
54    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    /// The direction pointing the opposite way.
66    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/// A non-empty subset of the six directions, as a bitmask.
79///
80/// Two pieces with equal `Arrows` are interchangeable: the uniqueness
81/// guarantee counts placements of the *multiset* of arrow sets, so swapping
82/// identical pieces is not a second solution.
83#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Default)]
84pub struct Arrows(u8);
85
86impl Arrows {
87    /// The set containing exactly one direction.
88    pub fn from_dir(dir: Dir) -> Self {
89        Self(1 << dir as u8)
90    }
91
92    /// The set containing all six directions.
93    pub fn all() -> Self {
94        Self(0b0011_1111)
95    }
96
97    /// Whether `dir` is in the set.
98    pub fn contains(self, dir: Dir) -> bool {
99        self.0 & (1 << dir as u8) != 0
100    }
101
102    /// The set plus `dir`.
103    pub fn with(self, dir: Dir) -> Self {
104        Self(self.0 | (1 << dir as u8))
105    }
106
107    /// The set minus `dir`.
108    pub fn without(self, dir: Dir) -> Self {
109        Self(self.0 & !(1 << dir as u8))
110    }
111
112    /// How many directions the set holds.
113    pub fn len(self) -> usize {
114        self.0.count_ones() as usize
115    }
116
117    /// Whether the set is empty.
118    pub fn is_empty(self) -> bool {
119        self.0 == 0
120    }
121
122    /// The raw bitmask. Grouping/sorting by this value gives every distinct
123    /// arrow set a fixed canonical order.
124    pub(crate) fn bits(self) -> u8 {
125        self.0
126    }
127
128    /// The directions, clockwise.
129    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/// A movable piece. A piece is fully described by its arrows.
144#[derive(Clone, Copy, PartialEq, Eq, Debug)]
145pub struct Piece {
146    pub arrows: Arrows,
147}
148
149/// Whether the puzzle is solved. The win is latched: it never reverts.
150#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
151pub enum GameStatus {
152    #[default]
153    InProgress,
154    Won,
155}
156
157/// A node of the board with its axial coordinates and precomputed neighbors.
158#[derive(Clone, Copy, Debug)]
159pub struct Node {
160    pub q: i32,
161    pub r: i32,
162    /// Neighbor in each direction, if that node exists on the board.
163    pub neighbors: [Option<NodeId>; 6],
164}
165
166/// Parameters for [`HexwebGame::random`].
167#[derive(Clone, Debug)]
168pub struct Params {
169    /// Axial coordinates of the board's nodes, e.g. from
170    /// [`random_symmetric_board`].
171    pub nodes: Vec<(i32, i32)>,
172    /// How many pieces the board holds; the rest of the nodes stay empty.
173    /// Must be at least 1 and strictly less than `nodes.len()` (there must be
174    /// somewhere to move to).
175    pub pieces: usize,
176    /// Inclusive lower bound of arrows per piece (at least 1).
177    pub min_arrows: usize,
178    /// Inclusive upper bound of arrows per piece (at most 6).
179    pub max_arrows: usize,
180}
181
182/// The hexweb puzzle: pieces on a hexagonal lattice, moved until every arrow
183/// points at another piece.
184#[derive(Clone, Debug)]
185pub struct HexwebGame {
186    nodes: Vec<Node>,
187    /// Coordinate -> node id. A BTreeMap so `Debug` output (and thus the
188    /// reproducibility tests) is deterministic.
189    lookup: BTreeMap<(i32, i32), NodeId>,
190    pieces: Vec<Piece>,
191    /// Occupancy: which piece (if any) sits on each node.
192    cell_piece: Vec<Option<PieceId>>,
193    /// The placement the board starts (and resets) with.
194    initial: Vec<Option<PieceId>>,
195    moves: usize,
196    status: GameStatus,
197}
198
199/// All nodes `(q, r)` with hex distance at most `radius` from the center:
200/// `3·R·(R+1)+1` nodes (1, 7, 19, 37, ...).
201pub 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
212/// An axial cell coordinate.
213type Cell = (i32, i32);
214
215/// One 60° rotation of the axial lattice.
216fn rot((q, r): Cell) -> Cell {
217    (-r, q + r)
218}
219
220/// The inverse of [`rot`]. Only the tests conjugate mirror maps with it.
221#[cfg(test)]
222fn rot_inv((q, r): Cell) -> Cell {
223    (r + q, -q)
224}
225
226/// Reflection across the vertical pixel axis: the `q = 0` column stays put.
227fn mirror_v((q, r): Cell) -> Cell {
228    (-q, r + q)
229}
230
231/// Reflection across the horizontal pixel axis.
232fn mirror_h((q, r): Cell) -> Cell {
233    (q, -q - r)
234}
235
236/// An orbit of a mirror map over the board cells: either a single on-axis
237/// cell or a mirror pair.
238type Orbit = (Cell, Option<Cell>);
239
240/// Splits `cells` (which must be closed under `mirror`) into its mirror
241/// orbits, in the order the cells appear in the input.
242fn 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
256/// Whether `coords` forms one connected piece under the lattice's six
257/// neighbor directions.
258fn 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
275/// Every mirror-symmetric connected board with exactly `nodes` nodes, in a
276/// deterministic order.
277///
278/// The candidate shapes are the subsets of the 19-cell `hexagon(2)` board
279/// that are closed under one of the lattice's six mirror axes: enumerating
280/// the mirror orbits under the two axis classes (vertical and horizontal)
281/// and rotating the results by 0/60/120 degrees covers all six. Subsets of
282/// the wrong size or split into several pieces are dropped; pieces can only
283/// move within a connected group, so a board of islands would freeze its
284/// far pieces.
285fn 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            // Connectivity is rotation-invariant, so checking the base
302            // orientation is enough. Rotations by 180° (and, for shapes with
303            // their own rotational symmetry, other overlapping rotations)
304            // collapse into duplicates via the set below.
305            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
318/// A mirror-symmetric board with exactly `nodes` nodes, for any count from 8
319/// to 16, picked at random from every such shape.
320///
321/// Shapes may have holes and irregular outlines (there are dozens to
322/// hundreds per node count), but they are always mirror-symmetric about one
323/// of the six lattice axes and always one connected piece. `seed` picks one
324/// of them uniformly; the same seed always yields the same shape.
325///
326/// Panics for node counts outside `8..=16`.
327pub 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
337/// Builds the node list with precomputed neighbor tables from raw
338/// coordinates.
339pub(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    /// Generates a new puzzle from `params` with a deterministic seed.
361    ///
362    /// The generator lays a solved configuration down first, then verifies
363    /// with an exact counting solver that exactly one placement of the pieces
364    /// solves it (repairing by adding arrows if not), and finally scrambles
365    /// the board with legal moves. See `crate::generator`.
366    ///
367    /// Panics (see [`Params`] and [`validate_params`]) on impossible
368    /// parameters: duplicate or isolated nodes, a piece count outside
369    /// `1..nodes.len()`, or an arrow range outside `1..=6` with
370    /// `min <= max`.
371    pub fn random(params: Params, seed: u64) -> Self {
372        validate_params(&params);
373        let (nodes, _) = build_nodes(&params.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    /// The board's nodes, in the order of the coordinates passed to
420    /// [`Params`].
421    pub fn nodes(&self) -> &[Node] {
422        &self.nodes
423    }
424
425    /// Total node count (occupied + empty).
426    pub fn node_count(&self) -> usize {
427        self.nodes.len()
428    }
429
430    /// How many pieces the board holds.
431    pub fn piece_count(&self) -> usize {
432        self.pieces.len()
433    }
434
435    /// How many nodes are currently empty.
436    pub fn empty_count(&self) -> usize {
437        self.nodes.len() - self.pieces.len()
438    }
439
440    /// The pieces, in piece-id order.
441    pub fn pieces(&self) -> &[Piece] {
442        &self.pieces
443    }
444
445    /// The piece sitting on `node`, if any.
446    pub fn piece_at(&self, node: NodeId) -> Option<PieceId> {
447        self.cell_piece[node]
448    }
449
450    /// The node `piece` currently occupies.
451    pub fn node_of(&self, piece: PieceId) -> Option<NodeId> {
452        self.cell_piece
453            .iter()
454            .position(|&occupant| occupant == Some(piece))
455    }
456
457    /// The node with axial coordinates `(q, r)`, if it exists on the board.
458    pub fn node_at(&self, q: i32, r: i32) -> Option<NodeId> {
459        self.lookup.get(&(q, r)).copied()
460    }
461
462    /// Whether `piece` may be moved to `to` right now: the game must be
463    /// unfinished, `to` must be an in-board node, and it must be empty and
464    /// not `piece`'s own node.
465    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    /// Moves `piece` to the empty node `to`. Returns whether the move
476    /// happened; illegal moves (see [`HexwebGame::can_move`]) leave the board
477    /// unchanged. Counts the move and latches the win when the board becomes
478    /// solved.
479    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    /// Whether every arrow of `piece` currently points at an occupied node.
496    pub fn satisfied(&self, piece: PieceId) -> bool {
497        self.unsatisfied_arrows(piece).is_empty()
498    }
499
500    /// The arrows of `piece` whose target node is empty or off the board.
501    /// This is the live feedback the widget paints in the accent color.
502    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    /// Whether every arrow of every piece points at an occupied node.
517    pub fn is_solved(&self) -> bool {
518        (0..self.pieces.len()).all(|piece| self.satisfied(piece))
519    }
520
521    /// How many moves have been played since the last reset/new game.
522    pub fn moves(&self) -> usize {
523        self.moves
524    }
525
526    /// Whether the game is finished. Latched: once [`GameStatus::Won`], it
527    /// stays won until [`HexwebGame::reset`] or a new game.
528    pub fn status(&self) -> GameStatus {
529        self.status
530    }
531
532    /// Restores the initial scramble, zeroing the move counter.
533    pub fn reset(&mut self) {
534        self.cell_piece = self.initial.clone();
535        self.moves = 0;
536        self.status = GameStatus::InProgress;
537    }
538
539    /// How many distinct placements of the pieces solve the board, capped at
540    /// `cap`. Counts the multiset: identical pieces are interchangeable, so
541    /// swapping two of them is not a second solution. Runs in microseconds
542    /// on the shipped board sizes; see `crate::solver`.
543    pub fn solution_count(&self, cap: usize) -> usize {
544        crate::solver::count(&self.pieces, &self.nodes, cap)
545    }
546
547    /// One solving placement, as the node each piece occupies (indexed by
548    /// piece id), or `None` if the board has no solution.
549    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(&params.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(&params.min_arrows)
584            && (params.min_arrows..=6).contains(&params.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    /// A 3-node vertical line: node 0 at (0,0), node 1 at (0,-1) above it,
596    /// node 2 at (0,1) below it. Piece 0 (arrow Up) starts on node 1, piece 1
597    /// (arrow Down) on node 0, leaving node 2 empty. Both arrows are
598    /// unsatisfied; moving piece 0 to node 2 solves it.
599    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    /// The mirror map about the axis obtained by rotating `mirror`'s axis by
661    /// `k` steps of 60°.
662    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                // Mirror invariance under at least one of the six axes.
689                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                // Same seed, same shape.
712                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        // Known shapes stay reachable: the old 8-node board (7-node hexagon
736        // plus one pendant) and the old 16-node board (hexagon(2) with a
737        // three-cell bite out of one edge).
738        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        // Piece 0 sits on node 1 at (0,-1); its Up arrow points off-board.
787        assert_eq!(game.unsatisfied_arrows(0), Arrows::from_dir(Dir::Up));
788        // Piece 1 sits on node 0; its Down arrow points at the empty node 2.
789        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)); // own node
798        assert!(!game.can_move(0, 0)); // occupied by piece 1
799        assert!(!game.can_move(0, 99)); // off-board
800        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        // The win is latched: no further moves are accepted.
817        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}