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 be
3//! met by the opposite arrow on the piece they point at.
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). A soft cap:
179    /// the generator's reciprocal closure may push a piece above it.
180    pub max_arrows: usize,
181}
182
183/// The hexweb puzzle: pieces on a hexagonal lattice, moved until every arrow
184/// is met by the opposite arrow of the piece it points at.
185#[derive(Clone, Debug)]
186pub struct HexwebGame {
187    nodes: Vec<Node>,
188    /// Coordinate -> node id. A BTreeMap so `Debug` output (and thus the
189    /// reproducibility tests) is deterministic.
190    lookup: BTreeMap<(i32, i32), NodeId>,
191    pieces: Vec<Piece>,
192    /// Occupancy: which piece (if any) sits on each node.
193    cell_piece: Vec<Option<PieceId>>,
194    /// The placement the board starts (and resets) with.
195    initial: Vec<Option<PieceId>>,
196    moves: usize,
197    status: GameStatus,
198}
199
200/// All nodes `(q, r)` with hex distance at most `radius` from the center:
201/// `3·R·(R+1)+1` nodes (1, 7, 19, 37, ...).
202pub fn hexagon(radius: u32) -> Vec<(i32, i32)> {
203    let r = radius as i32;
204    let mut coords = Vec::new();
205    for q in -r..=r {
206        for rr in (-r).max(-q - r)..=r.min(-q + r) {
207            coords.push((q, rr));
208        }
209    }
210    coords
211}
212
213/// An axial cell coordinate.
214type Cell = (i32, i32);
215
216/// One 60° rotation of the axial lattice.
217fn rot((q, r): Cell) -> Cell {
218    (-r, q + r)
219}
220
221/// The inverse of [`rot`]. Only the tests conjugate mirror maps with it.
222#[cfg(test)]
223fn rot_inv((q, r): Cell) -> Cell {
224    (r + q, -q)
225}
226
227/// Reflection across the vertical pixel axis: the `q = 0` column stays put.
228fn mirror_v((q, r): Cell) -> Cell {
229    (-q, r + q)
230}
231
232/// Reflection across the horizontal pixel axis.
233fn mirror_h((q, r): Cell) -> Cell {
234    (q, -q - r)
235}
236
237/// An orbit of a mirror map over the board cells: either a single on-axis
238/// cell or a mirror pair.
239type Orbit = (Cell, Option<Cell>);
240
241/// Splits `cells` (which must be closed under `mirror`) into its mirror
242/// orbits, in the order the cells appear in the input.
243fn mirror_orbits(cells: &[Cell], mirror: fn(Cell) -> Cell) -> Vec<Orbit> {
244    let mut seen: BTreeSet<Cell> = BTreeSet::new();
245    let mut orbits = Vec::new();
246    for &cell in cells {
247        if seen.insert(cell) {
248            let image = mirror(cell);
249            let twin = (image != cell).then_some(image);
250            seen.extend(twin);
251            orbits.push((cell, twin));
252        }
253    }
254    orbits
255}
256
257/// Whether `coords` forms one connected piece under the lattice's six
258/// neighbor directions.
259fn is_connected(coords: &[Cell]) -> bool {
260    let set: BTreeSet<Cell> = coords.iter().copied().collect();
261    let start = coords[0];
262    let mut seen: BTreeSet<Cell> = BTreeSet::from([start]);
263    let mut stack = vec![start];
264    while let Some(cell) = stack.pop() {
265        for dir in Dir::ALL {
266            let (dq, dr) = dir.delta();
267            let next = (cell.0 + dq, cell.1 + dr);
268            if set.contains(&next) && seen.insert(next) {
269                stack.push(next);
270            }
271        }
272    }
273    seen.len() == coords.len()
274}
275
276/// Every mirror-symmetric connected board with exactly `nodes` nodes, in a
277/// deterministic order.
278///
279/// The candidate shapes are the subsets of the 19-cell `hexagon(2)` board
280/// that are closed under one of the lattice's six mirror axes: enumerating
281/// the mirror orbits under the two axis classes (vertical and horizontal)
282/// and rotating the results by 0/60/120 degrees covers all six. Subsets of
283/// the wrong size or split into several pieces are dropped; pieces can only
284/// move within a connected group, so a board of islands would freeze its
285/// far pieces.
286fn symmetric_candidates(nodes: usize) -> Vec<Vec<Cell>> {
287    let pool = hexagon(2);
288    let mut found: BTreeSet<Vec<Cell>> = BTreeSet::new();
289    for mirror in [mirror_v, mirror_h] {
290        let orbits = mirror_orbits(&pool, mirror);
291        for mask in 0..1u64 << orbits.len() {
292            let mut coords: Vec<Cell> = Vec::new();
293            for (bit, &(cell, twin)) in orbits.iter().enumerate() {
294                if mask >> bit & 1 == 1 {
295                    coords.push(cell);
296                    coords.extend(twin);
297                }
298            }
299            if coords.len() != nodes || !is_connected(&coords) {
300                continue;
301            }
302            // Connectivity is rotation-invariant, so checking the base
303            // orientation is enough. Rotations by 180° (and, for shapes with
304            // their own rotational symmetry, other overlapping rotations)
305            // collapse into duplicates via the set below.
306            for times in 0..3u32 {
307                let mut candidate = coords.clone();
308                for _ in 0..times {
309                    candidate = candidate.iter().copied().map(rot).collect();
310                }
311                candidate.sort_unstable();
312                found.insert(candidate);
313            }
314        }
315    }
316    found.into_iter().collect()
317}
318
319/// A mirror-symmetric board with exactly `nodes` nodes, for any count from 8
320/// to 16, picked at random from every such shape.
321///
322/// Shapes may have holes and irregular outlines (there are dozens to
323/// hundreds per node count), but they are always mirror-symmetric about one
324/// of the six lattice axes and always one connected piece. `seed` picks one
325/// of them uniformly; the same seed always yields the same shape.
326///
327/// Panics for node counts outside `8..=16`.
328pub fn random_symmetric_board(nodes: usize, seed: u64) -> Vec<(i32, i32)> {
329    assert!(
330        (8..=16).contains(&nodes),
331        "random_symmetric_board supports 8..=16 nodes, got {nodes}"
332    );
333    let candidates = symmetric_candidates(nodes);
334    let mut rng = fastrand::Rng::with_seed(seed);
335    candidates[rng.usize(..candidates.len())].clone()
336}
337
338/// Builds the node list with precomputed neighbor tables from raw
339/// coordinates.
340pub(crate) fn build_nodes(coords: &[(i32, i32)]) -> (Vec<Node>, BTreeMap<(i32, i32), NodeId>) {
341    let lookup: BTreeMap<(i32, i32), NodeId> = coords
342        .iter()
343        .enumerate()
344        .map(|(id, &coord)| (coord, id))
345        .collect();
346    let nodes = coords
347        .iter()
348        .map(|&(q, r)| Node {
349            q,
350            r,
351            neighbors: std::array::from_fn(|i| {
352                let (dq, dr) = Dir::ALL[i].delta();
353                lookup.get(&(q + dq, r + dr)).copied()
354            }),
355        })
356        .collect();
357    (nodes, lookup)
358}
359
360impl HexwebGame {
361    /// Generates a new puzzle from `params` with a deterministic seed.
362    ///
363    /// The generator lays a solved configuration down first, then verifies
364    /// with an exact counting solver that exactly one placement of the pieces
365    /// solves it (repairing by adding arrows if not), and finally scrambles
366    /// the board with legal moves. See `crate::generator`.
367    ///
368    /// Panics (see [`Params`] and [`validate_params`]) on impossible
369    /// parameters: duplicate or isolated nodes, a piece count outside
370    /// `1..nodes.len()`, or an arrow range outside `1..=6` with
371    /// `min <= max`.
372    pub fn random(params: Params, seed: u64) -> Self {
373        validate_params(&params);
374        let (nodes, _) = build_nodes(&params.nodes);
375        let mut rng = fastrand::Rng::with_seed(seed);
376        let generated = crate::generator::generate(
377            &nodes,
378            params.pieces,
379            params.min_arrows,
380            params.max_arrows,
381            &mut rng,
382        );
383        let pieces: Vec<Piece> = generated
384            .arrows
385            .into_iter()
386            .map(|arrows| Piece { arrows })
387            .collect();
388        Self::from_parts(
389            nodes,
390            pieces,
391            generated.scramble.clone(),
392            generated.scramble,
393        )
394    }
395
396    pub(crate) fn from_parts(
397        nodes: Vec<Node>,
398        pieces: Vec<Piece>,
399        cell_piece: Vec<Option<PieceId>>,
400        initial: Vec<Option<PieceId>>,
401    ) -> Self {
402        debug_assert_eq!(nodes.len(), cell_piece.len());
403        debug_assert_eq!(cell_piece.len(), initial.len());
404        let lookup: BTreeMap<(i32, i32), NodeId> = nodes
405            .iter()
406            .enumerate()
407            .map(|(id, node)| ((node.q, node.r), id))
408            .collect();
409        Self {
410            nodes,
411            lookup,
412            pieces,
413            cell_piece,
414            initial,
415            moves: 0,
416            status: GameStatus::InProgress,
417        }
418    }
419
420    /// The board's nodes, in the order of the coordinates passed to
421    /// [`Params`].
422    pub fn nodes(&self) -> &[Node] {
423        &self.nodes
424    }
425
426    /// Total node count (occupied + empty).
427    pub fn node_count(&self) -> usize {
428        self.nodes.len()
429    }
430
431    /// How many pieces the board holds.
432    pub fn piece_count(&self) -> usize {
433        self.pieces.len()
434    }
435
436    /// How many nodes are currently empty.
437    pub fn empty_count(&self) -> usize {
438        self.nodes.len() - self.pieces.len()
439    }
440
441    /// The pieces, in piece-id order.
442    pub fn pieces(&self) -> &[Piece] {
443        &self.pieces
444    }
445
446    /// The piece sitting on `node`, if any.
447    pub fn piece_at(&self, node: NodeId) -> Option<PieceId> {
448        self.cell_piece[node]
449    }
450
451    /// The node `piece` currently occupies.
452    pub fn node_of(&self, piece: PieceId) -> Option<NodeId> {
453        self.cell_piece
454            .iter()
455            .position(|&occupant| occupant == Some(piece))
456    }
457
458    /// The node with axial coordinates `(q, r)`, if it exists on the board.
459    pub fn node_at(&self, q: i32, r: i32) -> Option<NodeId> {
460        self.lookup.get(&(q, r)).copied()
461    }
462
463    /// Whether `piece` may be moved to `to` right now: the game must be
464    /// unfinished, `to` must be an in-board node, and it must be empty and
465    /// not `piece`'s own node. The other way a piece can land is onto an
466    /// occupied node, swapping the two: see [`HexwebGame::can_swap`].
467    pub fn can_move(&self, piece: PieceId, to: NodeId) -> bool {
468        if self.status != GameStatus::InProgress || to >= self.nodes.len() {
469            return false;
470        }
471        match self.node_of(piece) {
472            Some(from) => from != to && self.cell_piece[to].is_none(),
473            None => false,
474        }
475    }
476
477    /// Moves `piece` to the empty node `to`. Returns whether the move
478    /// happened; illegal moves (see [`HexwebGame::can_move`]) leave the board
479    /// unchanged. Counts the move and latches the win when the board becomes
480    /// solved.
481    pub fn move_piece(&mut self, piece: PieceId, to: NodeId) -> bool {
482        let Some(from) = self.node_of(piece) else {
483            return false;
484        };
485        if !self.can_move(piece, to) {
486            return false;
487        }
488        self.cell_piece[from] = None;
489        self.cell_piece[to] = Some(piece);
490        self.moves += 1;
491        if self.is_solved() {
492            self.status = GameStatus::Won;
493        }
494        true
495    }
496
497    /// Whether pieces `a` and `b` may be swapped right now: the game must be
498    /// unfinished and both pieces must be placed and distinct. Two pieces
499    /// with identical arrows may be swapped, but the swap is a free no-op
500    /// (see [`HexwebGame::swap_pieces`]).
501    pub fn can_swap(&self, a: PieceId, b: PieceId) -> bool {
502        self.status == GameStatus::InProgress
503            && a != b
504            && self.node_of(a).is_some()
505            && self.node_of(b).is_some()
506    }
507
508    /// Exchanges the nodes of the two placed pieces `a` and `b`. Returns
509    /// whether the swap happened; illegal swaps (see
510    /// [`HexwebGame::can_swap`]) leave the board unchanged. Counts the move
511    /// and latches the win when the board becomes solved. Pieces with
512    /// identical arrows are interchangeable, so swapping two of them leaves
513    /// the move counter alone.
514    pub fn swap_pieces(&mut self, a: PieceId, b: PieceId) -> bool {
515        if !self.can_swap(a, b) {
516            return false;
517        }
518        let from = self.node_of(a).unwrap();
519        let to = self.node_of(b).unwrap();
520        self.cell_piece[from] = Some(b);
521        self.cell_piece[to] = Some(a);
522        if self.pieces[a] != self.pieces[b] {
523            self.moves += 1;
524        }
525        if self.is_solved() {
526            self.status = GameStatus::Won;
527        }
528        true
529    }
530
531    /// Whether every arrow of `piece` currently forms a mutual pair.
532    pub fn satisfied(&self, piece: PieceId) -> bool {
533        self.unsatisfied_arrows(piece).is_empty()
534    }
535
536    /// The arrows of `piece` that do not yet form a mutual pair: their
537    /// target node is empty, off the board, or holds a piece that does not
538    /// point back with the opposite arrow. This is the live feedback the
539    /// widget paints in the accent color.
540    pub fn unsatisfied_arrows(&self, piece: PieceId) -> Arrows {
541        let Some(node) = self.node_of(piece) else {
542            return self.pieces[piece].arrows;
543        };
544        let mut result = Arrows::default();
545        for dir in self.pieces[piece].arrows.iter() {
546            let satisfied = match self.nodes[node].neighbors[dir as usize] {
547                Some(neighbor) => match self.cell_piece[neighbor] {
548                    Some(other) => self.pieces[other].arrows.contains(dir.opposite()),
549                    None => false,
550                },
551                None => false,
552            };
553            if !satisfied {
554                result = result.with(dir);
555            }
556        }
557        result
558    }
559
560    /// Whether every arrow of every piece is met by the opposite arrow of
561    /// the piece it points at: a completed web of mutual pairs.
562    pub fn is_solved(&self) -> bool {
563        (0..self.pieces.len()).all(|piece| self.satisfied(piece))
564    }
565
566    /// How many moves have been played since the last reset/new game.
567    pub fn moves(&self) -> usize {
568        self.moves
569    }
570
571    /// Whether the game is finished. Latched: once [`GameStatus::Won`], it
572    /// stays won until [`HexwebGame::reset`] or a new game.
573    pub fn status(&self) -> GameStatus {
574        self.status
575    }
576
577    /// Restores the initial scramble, zeroing the move counter.
578    pub fn reset(&mut self) {
579        self.cell_piece = self.initial.clone();
580        self.moves = 0;
581        self.status = GameStatus::InProgress;
582    }
583
584    /// How many distinct placements of the pieces solve the board, capped at
585    /// `cap`. Counts the multiset: identical pieces are interchangeable, so
586    /// swapping two of them is not a second solution. Runs in microseconds
587    /// on the shipped board sizes; see `crate::solver`.
588    pub fn solution_count(&self, cap: usize) -> usize {
589        crate::solver::count(&self.pieces, &self.nodes, cap)
590    }
591
592    /// One solving placement, as the node each piece occupies (indexed by
593    /// piece id), or `None` if the board has no solution.
594    pub fn solution(&self) -> Option<Vec<NodeId>> {
595        crate::solver::solution(&self.pieces, &self.nodes)
596    }
597}
598
599fn validate_params(params: &Params) {
600    assert!(!params.nodes.is_empty(), "board needs at least one node");
601    let mut sorted = params.nodes.clone();
602    sorted.sort_unstable();
603    sorted.dedup();
604    assert_eq!(
605        sorted.len(),
606        params.nodes.len(),
607        "board nodes must be distinct coordinates"
608    );
609    let (built, _) = build_nodes(&params.nodes);
610    assert!(
611        built
612            .iter()
613            .all(|node| node.neighbors.iter().any(Option::is_some)),
614        "board has isolated nodes with no in-board neighbor"
615    );
616    assert!(
617        params.pieces >= 1,
618        "board needs at least one piece, got {}",
619        params.pieces
620    );
621    assert!(
622        params.pieces < params.nodes.len(),
623        "board needs at least one empty node: {} pieces on {} nodes",
624        params.pieces,
625        params.nodes.len()
626    );
627    assert!(
628        (1..=6).contains(&params.min_arrows)
629            && (params.min_arrows..=6).contains(&params.max_arrows),
630        "arrow range must be within 1..=6 with min <= max, got {}..={}",
631        params.min_arrows,
632        params.max_arrows
633    );
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    /// A 3-node vertical line: node 0 at (0,0), node 1 at (0,-1) above it,
641    /// node 2 at (0,1) below it. Piece 0 (arrow Up) starts on node 1, piece 1
642    /// (arrow Down) on node 0, leaving node 2 empty. Both arrows are
643    /// unsatisfied; moving piece 0 to node 2 solves it.
644    fn line_game() -> HexwebGame {
645        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1)]);
646        let pieces = vec![
647            Piece {
648                arrows: Arrows::from_dir(Dir::Up),
649            },
650            Piece {
651                arrows: Arrows::from_dir(Dir::Down),
652            },
653        ];
654        let cell_piece = vec![Some(1), Some(0), None];
655        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
656    }
657
658    #[test]
659    fn dir_delta_and_opposite_tables() {
660        let expected = [
661            (Dir::Up, (0, -1), Dir::Down),
662            (Dir::RightUp, (1, -1), Dir::LeftDown),
663            (Dir::RightDown, (1, 0), Dir::LeftUp),
664            (Dir::Down, (0, 1), Dir::Up),
665            (Dir::LeftDown, (-1, 1), Dir::RightUp),
666            (Dir::LeftUp, (-1, 0), Dir::RightDown),
667        ];
668        for &(dir, delta, opposite) in &expected {
669            assert_eq!(dir.delta(), delta);
670            assert_eq!(dir.opposite(), opposite);
671            assert_eq!(opposite.opposite(), dir);
672        }
673        assert_eq!(Dir::ALL.len(), 6);
674    }
675
676    #[test]
677    fn arrows_bitmask_round_trip() {
678        let mut arrows = Arrows::default();
679        assert!(arrows.is_empty());
680        assert_eq!(arrows.len(), 0);
681        arrows = arrows.with(Dir::Up).with(Dir::LeftDown);
682        assert_eq!(arrows.len(), 2);
683        assert!(arrows.contains(Dir::Up));
684        assert!(arrows.contains(Dir::LeftDown));
685        assert!(!arrows.contains(Dir::Down));
686        assert_eq!(
687            arrows.iter().collect::<Vec<_>>(),
688            vec![Dir::Up, Dir::LeftDown]
689        );
690        arrows = arrows.without(Dir::Up);
691        assert_eq!(arrows, Arrows::from_dir(Dir::LeftDown));
692        assert_eq!(Arrows::all().len(), 6);
693    }
694
695    #[test]
696    fn hexagon_sizes() {
697        assert_eq!(hexagon(0).len(), 1);
698        assert_eq!(hexagon(1).len(), 7);
699        assert_eq!(hexagon(2).len(), 19);
700        assert!(hexagon(1).contains(&(0, 0)));
701        assert!(hexagon(1).contains(&(-1, 1)));
702        assert!(!hexagon(1).contains(&(0, 2)));
703    }
704
705    /// The mirror map about the axis obtained by rotating `mirror`'s axis by
706    /// `k` steps of 60°.
707    fn conjugate(mirror: fn(Cell) -> Cell, k: u32) -> impl Fn(Cell) -> Cell {
708        move |cell: Cell| {
709            let mut c = cell;
710            for _ in 0..k {
711                c = rot_inv(c);
712            }
713            let mut out = mirror(c);
714            for _ in 0..k {
715                out = rot(out);
716            }
717            out
718        }
719    }
720
721    #[test]
722    fn random_boards_are_symmetric_connected_and_sized() {
723        let pool = hexagon(2);
724        for nodes in 8..=16 {
725            for seed in 0..32u64 {
726                let coords = random_symmetric_board(nodes, seed);
727                assert_eq!(coords.len(), nodes, "shape of {nodes} nodes, seed {seed}");
728                assert!(
729                    coords.windows(2).all(|w| w[0] < w[1]),
730                    "shape of {nodes} nodes, seed {seed} is not sorted"
731                );
732
733                // Mirror invariance under at least one of the six axes.
734                let set: BTreeSet<Cell> = coords.iter().copied().collect();
735                let mut symmetric = false;
736                for mirror in [mirror_v, mirror_h] {
737                    for k in 0..3u32 {
738                        let map = conjugate(mirror, k);
739                        symmetric |= set.iter().all(|&c| set.contains(&map(c)));
740                    }
741                }
742                assert!(
743                    symmetric,
744                    "shape of {nodes} nodes, seed {seed} is not mirror-symmetric"
745                );
746
747                assert!(
748                    coords.iter().all(|c| pool.contains(c)),
749                    "shape of {nodes} nodes, seed {seed} leaves hexagon(2)"
750                );
751                assert!(
752                    is_connected(&coords),
753                    "shape of {nodes} nodes, seed {seed} is not connected"
754                );
755
756                // Same seed, same shape.
757                assert_eq!(coords, random_symmetric_board(nodes, seed));
758            }
759        }
760    }
761
762    #[test]
763    fn every_size_offers_a_variety_of_shapes() {
764        for nodes in 8..=16 {
765            let candidates = symmetric_candidates(nodes);
766            println!("{nodes} nodes: {} shapes", candidates.len());
767            assert!(
768                candidates.len() >= 10,
769                "only {} shapes for {nodes} nodes",
770                candidates.len()
771            );
772            let unique: BTreeSet<&Vec<Cell>> = candidates.iter().collect();
773            assert_eq!(
774                unique.len(),
775                candidates.len(),
776                "duplicate shapes at {nodes} nodes"
777            );
778        }
779
780        // Known shapes stay reachable: the old 8-node board (7-node hexagon
781        // plus one pendant) and the old 16-node board (hexagon(2) with a
782        // three-cell bite out of one edge).
783        let mut old8 = hexagon(1);
784        old8.push((0, -2));
785        old8.sort_unstable();
786        assert!(symmetric_candidates(8).contains(&old8));
787        let old16: Vec<Cell> = hexagon(2)
788            .into_iter()
789            .filter(|&c| !matches!(c, (0, 2) | (2, 0) | (-2, 2)))
790            .collect();
791        assert!(symmetric_candidates(16).contains(&old16));
792    }
793
794    #[test]
795    fn neighbor_tables_are_antisymmetric() {
796        for nodes in 8..=16 {
797            let coords = random_symmetric_board(nodes, nodes as u64);
798            let (built, _) = build_nodes(&coords);
799            for (id, node) in built.iter().enumerate() {
800                for (i, &neighbor) in node.neighbors.iter().enumerate() {
801                    if let Some(neighbor) = neighbor {
802                        let back = built[neighbor].neighbors[Dir::ALL[i].opposite() as usize];
803                        assert_eq!(back, Some(id));
804                    }
805                }
806            }
807        }
808    }
809
810    #[test]
811    fn accessors_report_the_board() {
812        let game = line_game();
813        assert_eq!(game.node_count(), 3);
814        assert_eq!(game.piece_count(), 2);
815        assert_eq!(game.empty_count(), 1);
816        assert_eq!(game.node_at(0, -1), Some(1));
817        assert_eq!(game.node_at(5, 5), None);
818        assert_eq!(game.piece_at(1), Some(0));
819        assert_eq!(game.piece_at(2), None);
820        assert_eq!(game.node_of(0), Some(1));
821        assert_eq!(game.pieces()[1].arrows, Arrows::from_dir(Dir::Down));
822        assert_eq!(game.nodes()[1].q, 0);
823        assert_eq!(game.nodes()[1].r, -1);
824    }
825
826    #[test]
827    fn starts_unsatisfied_and_reports_which_arrows() {
828        let game = line_game();
829        assert!(!game.is_solved());
830        assert_eq!(game.status(), GameStatus::InProgress);
831        // Piece 0 sits on node 1 at (0,-1); its Up arrow points off-board.
832        assert_eq!(game.unsatisfied_arrows(0), Arrows::from_dir(Dir::Up));
833        // Piece 1 sits on node 0; its Down arrow points at the empty node 2.
834        assert_eq!(game.unsatisfied_arrows(1), Arrows::from_dir(Dir::Down));
835        assert!(!game.satisfied(0));
836        assert!(!game.satisfied(1));
837    }
838
839    #[test]
840    fn arrows_without_reciprocal_stay_unsatisfied() {
841        // Two {Up} pieces stacked: the lower one points at an occupied
842        // node, but the piece there points up, not back down.
843        let (nodes, _) = build_nodes(&[(0, 0), (0, -1)]);
844        let up = Piece {
845            arrows: Arrows::from_dir(Dir::Up),
846        };
847        let pieces = vec![up, up];
848        let cell_piece = vec![Some(0), Some(1)];
849        let game = HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece);
850        assert_eq!(game.unsatisfied_arrows(0), Arrows::from_dir(Dir::Up));
851        assert!(!game.is_solved());
852
853        // Making the pair mutual satisfies both arrows.
854        let (nodes, _) = build_nodes(&[(0, 0), (0, -1)]);
855        let pieces = vec![
856            Piece {
857                arrows: Arrows::from_dir(Dir::Up),
858            },
859            Piece {
860                arrows: Arrows::from_dir(Dir::Down),
861            },
862        ];
863        let cell_piece = vec![Some(0), Some(1)];
864        let game = HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece);
865        assert_eq!(game.unsatisfied_arrows(0), Arrows::default());
866        assert_eq!(game.unsatisfied_arrows(1), Arrows::default());
867        assert!(game.is_solved());
868    }
869
870    #[test]
871    fn illegal_moves_are_rejected() {
872        let mut game = line_game();
873        assert!(!game.can_move(0, 1)); // own node
874        assert!(!game.can_move(0, 0)); // occupied by piece 1
875        assert!(!game.can_move(0, 99)); // off-board
876        assert_eq!(game.moves(), 0);
877        assert!(!game.move_piece(0, 0));
878        assert!(!game.move_piece(0, 1));
879        assert_eq!(game.moves(), 0);
880    }
881
882    #[test]
883    fn winning_move_latches_and_reset_restores() {
884        let mut game = line_game();
885        assert!(game.move_piece(0, 2));
886        assert_eq!(game.moves(), 1);
887        assert!(game.is_solved());
888        assert_eq!(game.status(), GameStatus::Won);
889        assert_eq!(game.unsatisfied_arrows(0), Arrows::default());
890        assert!(game.satisfied(1));
891
892        // The win is latched: no further moves are accepted.
893        assert!(!game.can_move(1, 1));
894        assert!(!game.move_piece(1, 1));
895        assert_eq!(game.moves(), 1);
896
897        game.reset();
898        assert_eq!(game.status(), GameStatus::InProgress);
899        assert_eq!(game.moves(), 0);
900        assert_eq!(game.piece_at(1), Some(0));
901        assert!(!game.is_solved());
902    }
903
904    /// A 4-node vertical line: node 0 at (0,0), node 1 at (0,-1), node 2 at
905    /// (0,1), node 3 at (0,2). Piece 0 (arrow Up) sits on node 0, piece 1
906    /// (arrow Down) on node 3, nodes 1 and 2 stay empty: piece 0 points at
907    /// the empty node 1 and piece 1 points off-board, so the game is
908    /// unsolved, and swapping the two is a legal move that doesn't solve it.
909    fn long_line_game() -> HexwebGame {
910        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
911        let pieces = vec![
912            Piece {
913                arrows: Arrows::from_dir(Dir::Up),
914            },
915            Piece {
916                arrows: Arrows::from_dir(Dir::Down),
917            },
918        ];
919        let cell_piece = vec![Some(0), None, None, Some(1)];
920        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
921    }
922
923    /// Like [`long_line_game`], but both pieces carry the same single Up
924    /// arrow, so the two are interchangeable.
925    fn identical_pair_game() -> HexwebGame {
926        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
927        let pieces = vec![
928            Piece {
929                arrows: Arrows::from_dir(Dir::Up),
930            },
931            Piece {
932                arrows: Arrows::from_dir(Dir::Up),
933            },
934        ];
935        let cell_piece = vec![Some(0), None, None, Some(1)];
936        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
937    }
938
939    #[test]
940    fn swapping_pieces_exchanges_nodes_and_counts_a_move() {
941        let mut game = long_line_game();
942        assert!(game.can_swap(0, 1));
943        let (node_a, node_b) = (game.node_of(0), game.node_of(1));
944        assert!(game.swap_pieces(0, 1));
945        assert_eq!(game.node_of(0), node_b);
946        assert_eq!(game.node_of(1), node_a);
947        assert_eq!(game.moves(), 1);
948        assert_eq!(game.status(), GameStatus::InProgress);
949    }
950
951    #[test]
952    fn winning_swap_latches_and_reset_restores() {
953        let mut game = line_game();
954        // Swapping the two pieces solves it: each lands pointing at the
955        // other.
956        assert!(game.swap_pieces(0, 1));
957        assert_eq!(game.moves(), 1);
958        assert!(game.is_solved());
959        assert_eq!(game.status(), GameStatus::Won);
960
961        // The win is latched: no further swaps are accepted.
962        assert!(!game.can_swap(0, 1));
963        assert!(!game.swap_pieces(0, 1));
964        assert_eq!(game.moves(), 1);
965
966        game.reset();
967        assert_eq!(game.status(), GameStatus::InProgress);
968        assert_eq!(game.moves(), 0);
969        assert_eq!(game.node_of(0), Some(1));
970        assert_eq!(game.node_of(1), Some(0));
971        assert!(!game.is_solved());
972    }
973
974    #[test]
975    fn illegal_swaps_are_rejected() {
976        let mut game = long_line_game();
977        assert!(!game.can_swap(0, 0)); // same piece
978        assert!(!game.can_swap(0, 7)); // no such piece
979        assert!(!game.swap_pieces(0, 0));
980        assert!(!game.swap_pieces(0, 7));
981        assert_eq!(game.moves(), 0);
982        assert_eq!(game.node_of(0), Some(0));
983        assert_eq!(game.node_of(1), Some(3));
984    }
985
986    #[test]
987    fn swapping_identical_pieces_does_not_count_a_move() {
988        let mut game = identical_pair_game();
989        assert!(game.can_swap(0, 1));
990        let (node_a, node_b) = (game.node_of(0), game.node_of(1));
991        assert!(game.swap_pieces(0, 1));
992        assert_eq!(game.node_of(0), node_b);
993        assert_eq!(game.node_of(1), node_a);
994        // Interchangeable pieces: the position is unchanged, so the swap is
995        // free.
996        assert_eq!(game.moves(), 0);
997        assert_eq!(game.status(), GameStatus::InProgress);
998    }
999
1000    #[test]
1001    fn params_are_validated() {
1002        let valid = Params {
1003            nodes: random_symmetric_board(8, 0),
1004            pieces: 6,
1005            min_arrows: 2,
1006            max_arrows: 3,
1007        };
1008        validate_params(&valid);
1009
1010        let mut dup = valid.clone();
1011        dup.nodes[1] = dup.nodes[0];
1012        assert!(std::panic::catch_unwind(|| validate_params(&dup)).is_err());
1013
1014        let mut full = valid.clone();
1015        full.pieces = full.nodes.len();
1016        assert!(std::panic::catch_unwind(|| validate_params(&full)).is_err());
1017
1018        let mut no_pieces = valid.clone();
1019        no_pieces.pieces = 0;
1020        assert!(std::panic::catch_unwind(|| validate_params(&no_pieces)).is_err());
1021
1022        let mut no_arrows = valid.clone();
1023        no_arrows.min_arrows = 0;
1024        assert!(std::panic::catch_unwind(|| validate_params(&no_arrows)).is_err());
1025
1026        let mut too_many = valid.clone();
1027        too_many.max_arrows = 7;
1028        assert!(std::panic::catch_unwind(|| validate_params(&too_many)).is_err());
1029
1030        let mut inverted = valid.clone();
1031        inverted.min_arrows = 4;
1032        inverted.max_arrows = 3;
1033        assert!(std::panic::catch_unwind(|| validate_params(&inverted)).is_err());
1034
1035        let mut isolated = valid.clone();
1036        isolated.nodes.push((100, 100));
1037        assert!(std::panic::catch_unwind(|| validate_params(&isolated)).is_err());
1038    }
1039}