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. The other way a piece can land is onto an
465    /// occupied node, swapping the two: see [`HexwebGame::can_swap`].
466    pub fn can_move(&self, piece: PieceId, to: NodeId) -> bool {
467        if self.status != GameStatus::InProgress || to >= self.nodes.len() {
468            return false;
469        }
470        match self.node_of(piece) {
471            Some(from) => from != to && self.cell_piece[to].is_none(),
472            None => false,
473        }
474    }
475
476    /// Moves `piece` to the empty node `to`. Returns whether the move
477    /// happened; illegal moves (see [`HexwebGame::can_move`]) leave the board
478    /// unchanged. Counts the move and latches the win when the board becomes
479    /// solved.
480    pub fn move_piece(&mut self, piece: PieceId, to: NodeId) -> bool {
481        let Some(from) = self.node_of(piece) else {
482            return false;
483        };
484        if !self.can_move(piece, to) {
485            return false;
486        }
487        self.cell_piece[from] = None;
488        self.cell_piece[to] = Some(piece);
489        self.moves += 1;
490        if self.is_solved() {
491            self.status = GameStatus::Won;
492        }
493        true
494    }
495
496    /// Whether pieces `a` and `b` may be swapped right now: the game must be
497    /// unfinished and both pieces must be placed and distinct. Two pieces
498    /// with identical arrows may be swapped, but the swap is a free no-op
499    /// (see [`HexwebGame::swap_pieces`]).
500    pub fn can_swap(&self, a: PieceId, b: PieceId) -> bool {
501        self.status == GameStatus::InProgress
502            && a != b
503            && self.node_of(a).is_some()
504            && self.node_of(b).is_some()
505    }
506
507    /// Exchanges the nodes of the two placed pieces `a` and `b`. Returns
508    /// whether the swap happened; illegal swaps (see
509    /// [`HexwebGame::can_swap`]) leave the board unchanged. Counts the move
510    /// and latches the win when the board becomes solved. Pieces with
511    /// identical arrows are interchangeable, so swapping two of them leaves
512    /// the move counter alone.
513    pub fn swap_pieces(&mut self, a: PieceId, b: PieceId) -> bool {
514        if !self.can_swap(a, b) {
515            return false;
516        }
517        let from = self.node_of(a).unwrap();
518        let to = self.node_of(b).unwrap();
519        self.cell_piece[from] = Some(b);
520        self.cell_piece[to] = Some(a);
521        if self.pieces[a] != self.pieces[b] {
522            self.moves += 1;
523        }
524        if self.is_solved() {
525            self.status = GameStatus::Won;
526        }
527        true
528    }
529
530    /// Whether every arrow of `piece` currently points at an occupied node.
531    pub fn satisfied(&self, piece: PieceId) -> bool {
532        self.unsatisfied_arrows(piece).is_empty()
533    }
534
535    /// The arrows of `piece` whose target node is empty or off the board.
536    /// This is the live feedback the widget paints in the accent color.
537    pub fn unsatisfied_arrows(&self, piece: PieceId) -> Arrows {
538        let Some(node) = self.node_of(piece) else {
539            return self.pieces[piece].arrows;
540        };
541        let mut result = Arrows::default();
542        for dir in self.pieces[piece].arrows.iter() {
543            match self.nodes[node].neighbors[dir as usize] {
544                Some(neighbor) if self.cell_piece[neighbor].is_some() => {}
545                _ => result = result.with(dir),
546            }
547        }
548        result
549    }
550
551    /// Whether every arrow of every piece points at an occupied node.
552    pub fn is_solved(&self) -> bool {
553        (0..self.pieces.len()).all(|piece| self.satisfied(piece))
554    }
555
556    /// How many moves have been played since the last reset/new game.
557    pub fn moves(&self) -> usize {
558        self.moves
559    }
560
561    /// Whether the game is finished. Latched: once [`GameStatus::Won`], it
562    /// stays won until [`HexwebGame::reset`] or a new game.
563    pub fn status(&self) -> GameStatus {
564        self.status
565    }
566
567    /// Restores the initial scramble, zeroing the move counter.
568    pub fn reset(&mut self) {
569        self.cell_piece = self.initial.clone();
570        self.moves = 0;
571        self.status = GameStatus::InProgress;
572    }
573
574    /// How many distinct placements of the pieces solve the board, capped at
575    /// `cap`. Counts the multiset: identical pieces are interchangeable, so
576    /// swapping two of them is not a second solution. Runs in microseconds
577    /// on the shipped board sizes; see `crate::solver`.
578    pub fn solution_count(&self, cap: usize) -> usize {
579        crate::solver::count(&self.pieces, &self.nodes, cap)
580    }
581
582    /// One solving placement, as the node each piece occupies (indexed by
583    /// piece id), or `None` if the board has no solution.
584    pub fn solution(&self) -> Option<Vec<NodeId>> {
585        crate::solver::solution(&self.pieces, &self.nodes)
586    }
587}
588
589fn validate_params(params: &Params) {
590    assert!(!params.nodes.is_empty(), "board needs at least one node");
591    let mut sorted = params.nodes.clone();
592    sorted.sort_unstable();
593    sorted.dedup();
594    assert_eq!(
595        sorted.len(),
596        params.nodes.len(),
597        "board nodes must be distinct coordinates"
598    );
599    let (built, _) = build_nodes(&params.nodes);
600    assert!(
601        built
602            .iter()
603            .all(|node| node.neighbors.iter().any(Option::is_some)),
604        "board has isolated nodes with no in-board neighbor"
605    );
606    assert!(
607        params.pieces >= 1,
608        "board needs at least one piece, got {}",
609        params.pieces
610    );
611    assert!(
612        params.pieces < params.nodes.len(),
613        "board needs at least one empty node: {} pieces on {} nodes",
614        params.pieces,
615        params.nodes.len()
616    );
617    assert!(
618        (1..=6).contains(&params.min_arrows)
619            && (params.min_arrows..=6).contains(&params.max_arrows),
620        "arrow range must be within 1..=6 with min <= max, got {}..={}",
621        params.min_arrows,
622        params.max_arrows
623    );
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    /// A 3-node vertical line: node 0 at (0,0), node 1 at (0,-1) above it,
631    /// node 2 at (0,1) below it. Piece 0 (arrow Up) starts on node 1, piece 1
632    /// (arrow Down) on node 0, leaving node 2 empty. Both arrows are
633    /// unsatisfied; moving piece 0 to node 2 solves it.
634    fn line_game() -> HexwebGame {
635        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1)]);
636        let pieces = vec![
637            Piece {
638                arrows: Arrows::from_dir(Dir::Up),
639            },
640            Piece {
641                arrows: Arrows::from_dir(Dir::Down),
642            },
643        ];
644        let cell_piece = vec![Some(1), Some(0), None];
645        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
646    }
647
648    #[test]
649    fn dir_delta_and_opposite_tables() {
650        let expected = [
651            (Dir::Up, (0, -1), Dir::Down),
652            (Dir::RightUp, (1, -1), Dir::LeftDown),
653            (Dir::RightDown, (1, 0), Dir::LeftUp),
654            (Dir::Down, (0, 1), Dir::Up),
655            (Dir::LeftDown, (-1, 1), Dir::RightUp),
656            (Dir::LeftUp, (-1, 0), Dir::RightDown),
657        ];
658        for &(dir, delta, opposite) in &expected {
659            assert_eq!(dir.delta(), delta);
660            assert_eq!(dir.opposite(), opposite);
661            assert_eq!(opposite.opposite(), dir);
662        }
663        assert_eq!(Dir::ALL.len(), 6);
664    }
665
666    #[test]
667    fn arrows_bitmask_round_trip() {
668        let mut arrows = Arrows::default();
669        assert!(arrows.is_empty());
670        assert_eq!(arrows.len(), 0);
671        arrows = arrows.with(Dir::Up).with(Dir::LeftDown);
672        assert_eq!(arrows.len(), 2);
673        assert!(arrows.contains(Dir::Up));
674        assert!(arrows.contains(Dir::LeftDown));
675        assert!(!arrows.contains(Dir::Down));
676        assert_eq!(
677            arrows.iter().collect::<Vec<_>>(),
678            vec![Dir::Up, Dir::LeftDown]
679        );
680        arrows = arrows.without(Dir::Up);
681        assert_eq!(arrows, Arrows::from_dir(Dir::LeftDown));
682        assert_eq!(Arrows::all().len(), 6);
683    }
684
685    #[test]
686    fn hexagon_sizes() {
687        assert_eq!(hexagon(0).len(), 1);
688        assert_eq!(hexagon(1).len(), 7);
689        assert_eq!(hexagon(2).len(), 19);
690        assert!(hexagon(1).contains(&(0, 0)));
691        assert!(hexagon(1).contains(&(-1, 1)));
692        assert!(!hexagon(1).contains(&(0, 2)));
693    }
694
695    /// The mirror map about the axis obtained by rotating `mirror`'s axis by
696    /// `k` steps of 60°.
697    fn conjugate(mirror: fn(Cell) -> Cell, k: u32) -> impl Fn(Cell) -> Cell {
698        move |cell: Cell| {
699            let mut c = cell;
700            for _ in 0..k {
701                c = rot_inv(c);
702            }
703            let mut out = mirror(c);
704            for _ in 0..k {
705                out = rot(out);
706            }
707            out
708        }
709    }
710
711    #[test]
712    fn random_boards_are_symmetric_connected_and_sized() {
713        let pool = hexagon(2);
714        for nodes in 8..=16 {
715            for seed in 0..32u64 {
716                let coords = random_symmetric_board(nodes, seed);
717                assert_eq!(coords.len(), nodes, "shape of {nodes} nodes, seed {seed}");
718                assert!(
719                    coords.windows(2).all(|w| w[0] < w[1]),
720                    "shape of {nodes} nodes, seed {seed} is not sorted"
721                );
722
723                // Mirror invariance under at least one of the six axes.
724                let set: BTreeSet<Cell> = coords.iter().copied().collect();
725                let mut symmetric = false;
726                for mirror in [mirror_v, mirror_h] {
727                    for k in 0..3u32 {
728                        let map = conjugate(mirror, k);
729                        symmetric |= set.iter().all(|&c| set.contains(&map(c)));
730                    }
731                }
732                assert!(
733                    symmetric,
734                    "shape of {nodes} nodes, seed {seed} is not mirror-symmetric"
735                );
736
737                assert!(
738                    coords.iter().all(|c| pool.contains(c)),
739                    "shape of {nodes} nodes, seed {seed} leaves hexagon(2)"
740                );
741                assert!(
742                    is_connected(&coords),
743                    "shape of {nodes} nodes, seed {seed} is not connected"
744                );
745
746                // Same seed, same shape.
747                assert_eq!(coords, random_symmetric_board(nodes, seed));
748            }
749        }
750    }
751
752    #[test]
753    fn every_size_offers_a_variety_of_shapes() {
754        for nodes in 8..=16 {
755            let candidates = symmetric_candidates(nodes);
756            println!("{nodes} nodes: {} shapes", candidates.len());
757            assert!(
758                candidates.len() >= 10,
759                "only {} shapes for {nodes} nodes",
760                candidates.len()
761            );
762            let unique: BTreeSet<&Vec<Cell>> = candidates.iter().collect();
763            assert_eq!(
764                unique.len(),
765                candidates.len(),
766                "duplicate shapes at {nodes} nodes"
767            );
768        }
769
770        // Known shapes stay reachable: the old 8-node board (7-node hexagon
771        // plus one pendant) and the old 16-node board (hexagon(2) with a
772        // three-cell bite out of one edge).
773        let mut old8 = hexagon(1);
774        old8.push((0, -2));
775        old8.sort_unstable();
776        assert!(symmetric_candidates(8).contains(&old8));
777        let old16: Vec<Cell> = hexagon(2)
778            .into_iter()
779            .filter(|&c| !matches!(c, (0, 2) | (2, 0) | (-2, 2)))
780            .collect();
781        assert!(symmetric_candidates(16).contains(&old16));
782    }
783
784    #[test]
785    fn neighbor_tables_are_antisymmetric() {
786        for nodes in 8..=16 {
787            let coords = random_symmetric_board(nodes, nodes as u64);
788            let (built, _) = build_nodes(&coords);
789            for (id, node) in built.iter().enumerate() {
790                for (i, &neighbor) in node.neighbors.iter().enumerate() {
791                    if let Some(neighbor) = neighbor {
792                        let back = built[neighbor].neighbors[Dir::ALL[i].opposite() as usize];
793                        assert_eq!(back, Some(id));
794                    }
795                }
796            }
797        }
798    }
799
800    #[test]
801    fn accessors_report_the_board() {
802        let game = line_game();
803        assert_eq!(game.node_count(), 3);
804        assert_eq!(game.piece_count(), 2);
805        assert_eq!(game.empty_count(), 1);
806        assert_eq!(game.node_at(0, -1), Some(1));
807        assert_eq!(game.node_at(5, 5), None);
808        assert_eq!(game.piece_at(1), Some(0));
809        assert_eq!(game.piece_at(2), None);
810        assert_eq!(game.node_of(0), Some(1));
811        assert_eq!(game.pieces()[1].arrows, Arrows::from_dir(Dir::Down));
812        assert_eq!(game.nodes()[1].q, 0);
813        assert_eq!(game.nodes()[1].r, -1);
814    }
815
816    #[test]
817    fn starts_unsatisfied_and_reports_which_arrows() {
818        let game = line_game();
819        assert!(!game.is_solved());
820        assert_eq!(game.status(), GameStatus::InProgress);
821        // Piece 0 sits on node 1 at (0,-1); its Up arrow points off-board.
822        assert_eq!(game.unsatisfied_arrows(0), Arrows::from_dir(Dir::Up));
823        // Piece 1 sits on node 0; its Down arrow points at the empty node 2.
824        assert_eq!(game.unsatisfied_arrows(1), Arrows::from_dir(Dir::Down));
825        assert!(!game.satisfied(0));
826        assert!(!game.satisfied(1));
827    }
828
829    #[test]
830    fn illegal_moves_are_rejected() {
831        let mut game = line_game();
832        assert!(!game.can_move(0, 1)); // own node
833        assert!(!game.can_move(0, 0)); // occupied by piece 1
834        assert!(!game.can_move(0, 99)); // off-board
835        assert_eq!(game.moves(), 0);
836        assert!(!game.move_piece(0, 0));
837        assert!(!game.move_piece(0, 1));
838        assert_eq!(game.moves(), 0);
839    }
840
841    #[test]
842    fn winning_move_latches_and_reset_restores() {
843        let mut game = line_game();
844        assert!(game.move_piece(0, 2));
845        assert_eq!(game.moves(), 1);
846        assert!(game.is_solved());
847        assert_eq!(game.status(), GameStatus::Won);
848        assert_eq!(game.unsatisfied_arrows(0), Arrows::default());
849        assert!(game.satisfied(1));
850
851        // The win is latched: no further moves are accepted.
852        assert!(!game.can_move(1, 1));
853        assert!(!game.move_piece(1, 1));
854        assert_eq!(game.moves(), 1);
855
856        game.reset();
857        assert_eq!(game.status(), GameStatus::InProgress);
858        assert_eq!(game.moves(), 0);
859        assert_eq!(game.piece_at(1), Some(0));
860        assert!(!game.is_solved());
861    }
862
863    /// A 4-node vertical line: node 0 at (0,0), node 1 at (0,-1), node 2 at
864    /// (0,1), node 3 at (0,2). Piece 0 (arrow Up) sits on node 0, piece 1
865    /// (arrow Down) on node 3, nodes 1 and 2 stay empty: piece 0 points at
866    /// the empty node 1 and piece 1 points off-board, so the game is
867    /// unsolved, and swapping the two is a legal move that doesn't solve it.
868    fn long_line_game() -> HexwebGame {
869        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
870        let pieces = vec![
871            Piece {
872                arrows: Arrows::from_dir(Dir::Up),
873            },
874            Piece {
875                arrows: Arrows::from_dir(Dir::Down),
876            },
877        ];
878        let cell_piece = vec![Some(0), None, None, Some(1)];
879        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
880    }
881
882    /// Like [`long_line_game`], but both pieces carry the same single Up
883    /// arrow, so the two are interchangeable.
884    fn identical_pair_game() -> HexwebGame {
885        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
886        let pieces = vec![
887            Piece {
888                arrows: Arrows::from_dir(Dir::Up),
889            },
890            Piece {
891                arrows: Arrows::from_dir(Dir::Up),
892            },
893        ];
894        let cell_piece = vec![Some(0), None, None, Some(1)];
895        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
896    }
897
898    #[test]
899    fn swapping_pieces_exchanges_nodes_and_counts_a_move() {
900        let mut game = long_line_game();
901        assert!(game.can_swap(0, 1));
902        let (node_a, node_b) = (game.node_of(0), game.node_of(1));
903        assert!(game.swap_pieces(0, 1));
904        assert_eq!(game.node_of(0), node_b);
905        assert_eq!(game.node_of(1), node_a);
906        assert_eq!(game.moves(), 1);
907        assert_eq!(game.status(), GameStatus::InProgress);
908    }
909
910    #[test]
911    fn winning_swap_latches_and_reset_restores() {
912        let mut game = line_game();
913        // Swapping the two pieces solves it: each lands pointing at the
914        // other.
915        assert!(game.swap_pieces(0, 1));
916        assert_eq!(game.moves(), 1);
917        assert!(game.is_solved());
918        assert_eq!(game.status(), GameStatus::Won);
919
920        // The win is latched: no further swaps are accepted.
921        assert!(!game.can_swap(0, 1));
922        assert!(!game.swap_pieces(0, 1));
923        assert_eq!(game.moves(), 1);
924
925        game.reset();
926        assert_eq!(game.status(), GameStatus::InProgress);
927        assert_eq!(game.moves(), 0);
928        assert_eq!(game.node_of(0), Some(1));
929        assert_eq!(game.node_of(1), Some(0));
930        assert!(!game.is_solved());
931    }
932
933    #[test]
934    fn illegal_swaps_are_rejected() {
935        let mut game = long_line_game();
936        assert!(!game.can_swap(0, 0)); // same piece
937        assert!(!game.can_swap(0, 7)); // no such piece
938        assert!(!game.swap_pieces(0, 0));
939        assert!(!game.swap_pieces(0, 7));
940        assert_eq!(game.moves(), 0);
941        assert_eq!(game.node_of(0), Some(0));
942        assert_eq!(game.node_of(1), Some(3));
943    }
944
945    #[test]
946    fn swapping_identical_pieces_does_not_count_a_move() {
947        let mut game = identical_pair_game();
948        assert!(game.can_swap(0, 1));
949        let (node_a, node_b) = (game.node_of(0), game.node_of(1));
950        assert!(game.swap_pieces(0, 1));
951        assert_eq!(game.node_of(0), node_b);
952        assert_eq!(game.node_of(1), node_a);
953        // Interchangeable pieces: the position is unchanged, so the swap is
954        // free.
955        assert_eq!(game.moves(), 0);
956        assert_eq!(game.status(), GameStatus::InProgress);
957    }
958
959    #[test]
960    fn params_are_validated() {
961        let valid = Params {
962            nodes: random_symmetric_board(8, 0),
963            pieces: 6,
964            min_arrows: 2,
965            max_arrows: 3,
966        };
967        validate_params(&valid);
968
969        let mut dup = valid.clone();
970        dup.nodes[1] = dup.nodes[0];
971        assert!(std::panic::catch_unwind(|| validate_params(&dup)).is_err());
972
973        let mut full = valid.clone();
974        full.pieces = full.nodes.len();
975        assert!(std::panic::catch_unwind(|| validate_params(&full)).is_err());
976
977        let mut no_pieces = valid.clone();
978        no_pieces.pieces = 0;
979        assert!(std::panic::catch_unwind(|| validate_params(&no_pieces)).is_err());
980
981        let mut no_arrows = valid.clone();
982        no_arrows.min_arrows = 0;
983        assert!(std::panic::catch_unwind(|| validate_params(&no_arrows)).is_err());
984
985        let mut too_many = valid.clone();
986        too_many.max_arrows = 7;
987        assert!(std::panic::catch_unwind(|| validate_params(&too_many)).is_err());
988
989        let mut inverted = valid.clone();
990        inverted.min_arrows = 4;
991        inverted.max_arrows = 3;
992        assert!(std::panic::catch_unwind(|| validate_params(&inverted)).is_err());
993
994        let mut isolated = valid.clone();
995        isolated.nodes.push((100, 100));
996        assert!(std::panic::catch_unwind(|| validate_params(&isolated)).is_err());
997    }
998}