Skip to main content

egui_numberlink/
game.rs

1//! Renderer-agnostic Numberlink game logic.
2
3use crate::generator;
4
5/// The current status of the game.
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7pub enum GameStatus {
8    /// The puzzle is not yet solved.
9    Playing,
10    /// Every number's two endpoints are connected by a drawn path. Cells no
11    /// path ends up using don't matter — filling the whole board was never
12    /// required, only connecting each pair (blocked cells remain real
13    /// obstacles no path may enter, they just aren't also required to be
14    /// used by someone). Puzzles built via [`NumberlinkGame::random`] are
15    /// generated with a best-effort (not provable) uniqueness check, so this
16    /// is *usually* the solution, not guaranteed to be; puzzles built via
17    /// [`NumberlinkGame::from_endpoints`] have no such guarantee at all.
18    Won,
19}
20
21/// The Numberlink game state: board dimensions, each number's fixed
22/// endpoints, the player's drawn paths, and undo/redo history.
23///
24/// Numbers are `0..pair_count`, displayed 1-based by convention (number `0`
25/// is "1" on the board) — see [`NumberlinkWidget`](crate::NumberlinkWidget).
26pub struct NumberlinkGame {
27    /// Board width in cells.
28    pub width: usize,
29    /// Board height in cells.
30    pub height: usize,
31    endpoints: Vec<((usize, usize), (usize, usize))>,
32    /// Flat `width * height` grid: `true` for a cell no path may ever enter.
33    blocked: Vec<bool>,
34    /// `paths[n]` is the ordered sequence of cells the player has drawn for
35    /// number `n` so far, always starting at one of `endpoints[n]`. Empty
36    /// until the player starts drawing that number.
37    paths: Vec<Vec<(usize, usize)>>,
38    /// Flat `width * height` grid: which number (if any) currently occupies
39    /// each cell via a drawn path. Endpoints of a number that hasn't been
40    /// drawn yet are *not* reflected here — query [`Self::endpoints`] for
41    /// those regardless of path state.
42    owner: Vec<Option<usize>>,
43    /// Set at the start of a drag, consumed (and possibly pushed to
44    /// `undo_stack`) when the drag ends; `None` while no drag is active.
45    pending_undo: Option<Vec<Vec<(usize, usize)>>>,
46    undo_stack: Vec<Vec<Vec<(usize, usize)>>>,
47    redo_stack: Vec<Vec<Vec<(usize, usize)>>>,
48    /// Current game status.
49    pub status: GameStatus,
50}
51
52impl NumberlinkGame {
53    /// Build a puzzle from explicit endpoint pairs (one per number, in
54    /// display order) with no blocked cells. Use this for curated puzzles.
55    /// See [`Self::from_endpoints_with_blocked`] to also specify walls.
56    ///
57    /// Unlike [`Self::random`], there is no guarantee any non-crossing
58    /// solution exists for arbitrary endpoints — that's on the caller to
59    /// ensure if a definite win is wanted.
60    ///
61    /// # Panics
62    ///
63    /// Panics if `width`/`height` is zero, any endpoint is out of bounds, an
64    /// endpoint pair repeats the same cell for both ends, or any cell is
65    /// used as an endpoint by more than one number.
66    pub fn from_endpoints(
67        width: usize,
68        height: usize,
69        endpoints: Vec<((usize, usize), (usize, usize))>,
70    ) -> Self {
71        Self::from_endpoints_with_blocked(width, height, endpoints, Vec::new())
72    }
73
74    /// Build a puzzle from explicit endpoint pairs (one per number, in
75    /// display order) plus a set of blocked cells no path may ever enter.
76    ///
77    /// Unlike [`Self::random`], there is no guarantee any non-crossing
78    /// solution exists for arbitrary endpoints — that's on the caller to
79    /// ensure if a definite win is wanted.
80    ///
81    /// # Panics
82    ///
83    /// Panics if `width`/`height` is zero, any endpoint or blocked cell is
84    /// out of bounds, an endpoint pair repeats the same cell for both ends,
85    /// or any cell is used as an endpoint by more than one number or is both
86    /// an endpoint and blocked.
87    pub fn from_endpoints_with_blocked(
88        width: usize,
89        height: usize,
90        endpoints: Vec<((usize, usize), (usize, usize))>,
91        blocked_cells: Vec<(usize, usize)>,
92    ) -> Self {
93        assert!(width > 0 && height > 0, "board must have at least one cell");
94        let mut seen = std::collections::HashSet::new();
95        for &(a, b) in &endpoints {
96            assert!(a.0 < width && a.1 < height, "endpoint {a:?} out of bounds");
97            assert!(b.0 < width && b.1 < height, "endpoint {b:?} out of bounds");
98            assert_ne!(a, b, "a pair's two endpoints must be distinct cells");
99            assert!(seen.insert(a), "cell {a:?} used as more than one endpoint");
100            assert!(seen.insert(b), "cell {b:?} used as more than one endpoint");
101        }
102        let mut blocked = vec![false; width * height];
103        for &c in &blocked_cells {
104            assert!(
105                c.0 < width && c.1 < height,
106                "blocked cell {c:?} out of bounds"
107            );
108            assert!(
109                !seen.contains(&c),
110                "cell {c:?} cannot be both an endpoint and blocked"
111            );
112            blocked[c.1 * width + c.0] = true;
113        }
114        let pair_count = endpoints.len();
115        Self {
116            width,
117            height,
118            endpoints,
119            blocked,
120            paths: vec![Vec::new(); pair_count],
121            owner: vec![None; width * height],
122            pending_undo: None,
123            undo_stack: Vec::new(),
124            redo_stack: Vec::new(),
125            status: GameStatus::Playing,
126        }
127    }
128
129    /// Build a procedurally-generated puzzle with `pair_count` numbers on a
130    /// `width x height` board, reproducible via `seed`. No blocked cells
131    /// (winning only requires connecting each pair, not filling the whole
132    /// board — see [`GameStatus::Won`]). Internally, one Hamiltonian path
133    /// over the whole board is cut into `pair_count` contiguous segments,
134    /// chosen by actively searching many candidate cut points and keeping
135    /// whichever spreads every segment's own two endpoints apart the most
136    /// (relative to that segment's own length), so a number's two endpoints
137    /// don't just sit next to each other. The generator prefers a
138    /// verified-unique layout within a bounded search, falling back to the
139    /// best-scoring layout found (at least one solution, not necessarily
140    /// unique) if none is found in time — see [`crate::generator`].
141    ///
142    /// # Panics
143    ///
144    /// Panics if `width * height < pair_count * 2`, or in the practically
145    /// unreachable case that generation can't produce even a merely-valid
146    /// layout within its attempt budget (see [`crate::generator`]).
147    pub fn random(width: usize, height: usize, pair_count: usize, seed: u64) -> Self {
148        let endpoints = generator::generate(width, height, pair_count, seed);
149        Self::from_endpoints(width, height, endpoints)
150    }
151
152    /// Whether `cell` is a permanent wall no path may ever enter.
153    pub fn is_blocked(&self, cell: (usize, usize)) -> bool {
154        self.blocked[self.idx(cell)]
155    }
156
157    /// Number of numbered pairs on the board.
158    pub fn pair_count(&self) -> usize {
159        self.endpoints.len()
160    }
161
162    /// The two fixed endpoint cells for `number`.
163    pub fn endpoints(&self, number: usize) -> ((usize, usize), (usize, usize)) {
164        self.endpoints[number]
165    }
166
167    /// The cells of `number`'s path drawn so far, in order from one endpoint
168    /// towards the other. Empty if the player hasn't started drawing it.
169    pub fn path_cells(&self, number: usize) -> &[(usize, usize)] {
170        &self.paths[number]
171    }
172
173    /// Which number (if any) currently occupies `cell` via a drawn path.
174    /// `None` for an empty cell *or* an endpoint that hasn't been drawn to
175    /// yet (use [`Self::number_at`] to also match untouched endpoints).
176    pub fn owner_at(&self, cell: (usize, usize)) -> Option<usize> {
177        self.owner[self.idx(cell)]
178    }
179
180    /// Which number `cell` belongs to for the purpose of starting a drag:
181    /// either one of that number's two endpoints (drawn or not), or a cell
182    /// currently on its drawn path.
183    pub fn number_at(&self, cell: (usize, usize)) -> Option<usize> {
184        if let Some(n) = self.owner_at(cell) {
185            return Some(n);
186        }
187        self.endpoints
188            .iter()
189            .position(|&(a, b)| cell == a || cell == b)
190    }
191
192    #[inline]
193    fn idx(&self, cell: (usize, usize)) -> usize {
194        cell.1 * self.width + cell.0
195    }
196
197    fn set_owner(&mut self, cell: (usize, usize), number: Option<usize>) {
198        let idx = self.idx(cell);
199        self.owner[idx] = number;
200    }
201
202    /// Begin (or restart) drawing `number`'s path from `cell`. `cell` must
203    /// be one of `number`'s two endpoints or a cell already on its path
204    /// (typically decided by [`Self::number_at`]); returns `false` and does
205    /// nothing otherwise.
206    ///
207    /// Both endpoints are always "live": pressing either one clears the
208    /// path and starts fresh anchored at *that* cell, even if the path was
209    /// originally drawn starting from the other end (a number's two ends
210    /// are symmetric, not a fixed direction). Pressing an interior cell of
211    /// the current path instead truncates it to the prefix ending there,
212    /// keeping whichever end it was drawn from — the standard "grab a
213    /// point along the pipe to redraw the tail" gesture.
214    pub fn start_drag(&mut self, number: usize, cell: (usize, usize)) -> bool {
215        let (a, b) = self.endpoints[number];
216        if cell == a || cell == b {
217            self.pending_undo = Some(self.paths.clone());
218            let old_path = std::mem::take(&mut self.paths[number]);
219            for c in old_path {
220                self.set_owner(c, None);
221            }
222            self.paths[number].push(cell);
223            self.set_owner(cell, Some(number));
224            return true;
225        }
226        let Some(index) = self.paths[number].iter().position(|&c| c == cell) else {
227            return false;
228        };
229        self.pending_undo = Some(self.paths.clone());
230        let removed: Vec<_> = self.paths[number].split_off(index + 1);
231        for c in removed {
232            self.set_owner(c, None);
233        }
234        true
235    }
236
237    /// Extend, retract, or reject a move of the in-progress drag (started
238    /// via [`Self::start_drag`]) towards `cell`:
239    /// - Moving onto an empty, orthogonally-adjacent cell extends the path.
240    /// - Moving onto the path's own second-to-last cell retracts it by one.
241    /// - Moving onto a blocked cell is rejected — no path may ever enter one.
242    /// - Moving onto a node (drawn or not) belonging to a *different* number
243    ///   is rejected — a path may never pass through another number's
244    ///   endpoint, drawn or otherwise.
245    /// - Once the path has reached its own far endpoint, it's complete and
246    ///   rejects any further extension (retracting off that endpoint,
247    ///   per the point above, is still allowed).
248    ///
249    /// No-op if `number` has no path started (call [`Self::start_drag`]
250    /// first) or `cell` is neither adjacent nor a retraction target.
251    pub fn drag_to(&mut self, number: usize, cell: (usize, usize)) {
252        let Some(&last) = self.paths[number].last() else {
253            return;
254        };
255        if cell == last {
256            return;
257        }
258        if self.paths[number].len() >= 2 && self.paths[number][self.paths[number].len() - 2] == cell
259        {
260            let removed = self.paths[number].pop().expect("checked len >= 2 above");
261            self.set_owner(removed, None);
262            return;
263        }
264        let (a, b) = self.endpoints[number];
265        if self.paths[number].len() > 1 && (last == a || last == b) {
266            // The path already reached its own far endpoint (the anchor,
267            // at index 0, doesn't count) — it's complete and cannot be
268            // extended past it.
269            return;
270        }
271        if !generator::is_adjacent(last, cell) {
272            return;
273        }
274        if self.is_blocked(cell) {
275            return;
276        }
277        if let Some(owner) = self.owner_at(cell) {
278            if owner != number {
279                return;
280            }
281            // Occupied by this same number but not the retraction target
282            // above (i.e. the path looping back on itself) — reject.
283            return;
284        }
285        if let Some(owner) = self
286            .endpoints
287            .iter()
288            .position(|&(a, b)| cell == a || cell == b)
289        {
290            if owner != number {
291                // A different number's node, not yet drawn onto — do not
292                // let the path pass through it.
293                return;
294            }
295            // Else: this number's own undrawn far endpoint — fall through
296            // and let it complete the path below.
297        }
298        self.paths[number].push(cell);
299        self.set_owner(cell, Some(number));
300        self.check_win();
301    }
302
303    /// End the in-progress drag for `number` (pointer released or
304    /// cancelled). If the drag actually changed the board relative to when
305    /// [`Self::start_drag`] was called, pushes that prior state onto the
306    /// undo stack and clears the redo stack. No-op if no drag is active.
307    pub fn end_drag(&mut self, number: usize) {
308        let Some(before) = self.pending_undo.take() else {
309            return;
310        };
311        // A path of length <= 1 (just the anchor `start_drag` always seeds,
312        // with nothing successfully extended onto it) renders and plays
313        // identically to an empty one — no pipe is drawn either way, and
314        // the player can pick it back up from the same starting point next
315        // time. Treat the two as equal so a drag that starts and
316        // immediately fails to extend (e.g. every target rejected) doesn't
317        // push a no-op-looking entry onto the undo stack.
318        fn normalized(p: &[(usize, usize)]) -> Option<&[(usize, usize)]> {
319            (p.len() > 1).then_some(p)
320        }
321        if normalized(&before[number]) != normalized(&self.paths[number]) {
322            self.undo_stack.push(before);
323            self.redo_stack.clear();
324        }
325    }
326
327    /// Clear every number's path (keeping the same puzzle) and undo/redo
328    /// history. To get a *different* puzzle, construct a new
329    /// [`NumberlinkGame`].
330    pub fn reset(&mut self) {
331        self.paths = vec![Vec::new(); self.pair_count()];
332        self.owner = vec![None; self.width * self.height];
333        self.pending_undo = None;
334        self.undo_stack.clear();
335        self.redo_stack.clear();
336        self.status = GameStatus::Playing;
337    }
338
339    pub fn can_undo(&self) -> bool {
340        self.pending_undo.is_none() && !self.undo_stack.is_empty()
341    }
342
343    pub fn can_redo(&self) -> bool {
344        self.pending_undo.is_none() && !self.redo_stack.is_empty()
345    }
346
347    pub fn undo(&mut self) {
348        if self.pending_undo.is_some() {
349            return;
350        }
351        if let Some(prev) = self.undo_stack.pop() {
352            let current = self.paths.clone();
353            self.redo_stack.push(current);
354            self.apply_paths(prev);
355        }
356    }
357
358    pub fn redo(&mut self) {
359        if self.pending_undo.is_some() {
360            return;
361        }
362        if let Some(next) = self.redo_stack.pop() {
363            let current = self.paths.clone();
364            self.undo_stack.push(current);
365            self.apply_paths(next);
366        }
367    }
368
369    fn apply_paths(&mut self, paths: Vec<Vec<(usize, usize)>>) {
370        self.owner = vec![None; self.width * self.height];
371        for (n, path) in paths.iter().enumerate() {
372            for &c in path {
373                self.set_owner(c, Some(n));
374            }
375        }
376        self.paths = paths;
377        self.check_win();
378    }
379
380    fn check_win(&mut self) {
381        let all_connected = (0..self.pair_count()).all(|n| {
382            let (a, b) = self.endpoints[n];
383            let path = &self.paths[n];
384            matches!(path.first(), Some(&f) if f == a || f == b)
385                && matches!(path.last(), Some(&l) if l == a || l == b)
386                && path.first() != path.last()
387        });
388        self.status = if all_connected {
389            GameStatus::Won
390        } else {
391            GameStatus::Playing
392        };
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn small_game() -> NumberlinkGame {
401        // 2x2 board, two horizontal pairs: (0,0)-(1,0) and (0,1)-(1,1).
402        NumberlinkGame::from_endpoints(2, 2, vec![((0, 0), (1, 0)), ((0, 1), (1, 1))])
403    }
404
405    #[test]
406    fn drawing_both_pairs_wins() {
407        let mut game = small_game();
408        game.start_drag(0, (0, 0));
409        game.drag_to(0, (1, 0));
410        game.end_drag(0);
411        assert_eq!(game.status, GameStatus::Playing);
412
413        game.start_drag(1, (0, 1));
414        game.drag_to(1, (1, 1));
415        game.end_drag(1);
416        assert_eq!(game.status, GameStatus::Won);
417    }
418
419    #[test]
420    fn winning_only_requires_connecting_every_pair() {
421        // 4x1 board, one pair spanning the first two cells; (2, 0) is
422        // blocked and (3, 0) is a perfectly ordinary, non-blocked cell —
423        // neither needs to be filled to win. Filling the whole board was
424        // never required, only connecting each pair.
425        let mut game =
426            NumberlinkGame::from_endpoints_with_blocked(4, 1, vec![((0, 0), (1, 0))], vec![(2, 0)]);
427        game.start_drag(0, (0, 0));
428        game.drag_to(0, (1, 0));
429        game.end_drag(0);
430        assert_eq!(game.status, GameStatus::Won);
431        assert_eq!(game.owner_at((2, 0)), None);
432        assert_eq!(game.owner_at((3, 0)), None);
433    }
434
435    #[test]
436    fn dragging_onto_a_blocked_cell_is_rejected() {
437        let mut game =
438            NumberlinkGame::from_endpoints_with_blocked(3, 1, vec![((0, 0), (2, 0))], vec![(1, 0)]);
439        game.start_drag(0, (0, 0));
440        game.drag_to(0, (1, 0));
441        assert_eq!(game.path_cells(0), &[(0, 0)]);
442        assert_eq!(game.owner_at((1, 0)), None);
443    }
444
445    #[test]
446    fn retracting_removes_the_last_cell() {
447        let mut game = NumberlinkGame::from_endpoints(3, 1, vec![((0, 0), (2, 0))]);
448        game.start_drag(0, (0, 0));
449        game.drag_to(0, (1, 0));
450        game.drag_to(0, (2, 0));
451        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0), (2, 0)]);
452
453        game.drag_to(0, (1, 0)); // retract
454        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0)]);
455        assert_eq!(game.owner_at((2, 0)), None);
456    }
457
458    #[test]
459    fn drag_cannot_extend_past_its_own_completed_endpoint() {
460        let mut game = NumberlinkGame::from_endpoints(4, 1, vec![((0, 0), (2, 0))]);
461        game.start_drag(0, (0, 0));
462        game.drag_to(0, (1, 0));
463        game.drag_to(0, (2, 0)); // reaches the far endpoint - path complete
464        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0), (2, 0)]);
465
466        game.drag_to(0, (3, 0)); // must be rejected: can't pass through own endpoint
467        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0), (2, 0)]);
468        assert_eq!(game.owner_at((3, 0)), None);
469    }
470
471    #[test]
472    fn cannot_step_onto_another_numbers_path() {
473        let mut game = small_game();
474        game.start_drag(0, (0, 0));
475        game.drag_to(0, (1, 0));
476        game.end_drag(0);
477
478        game.start_drag(1, (0, 1));
479        game.drag_to(1, (1, 0)); // belongs to number 0, must be rejected
480        assert_eq!(game.path_cells(1), &[(0, 1)]);
481    }
482
483    #[test]
484    fn cannot_drag_through_an_undrawn_endpoint_of_another_number() {
485        // 3x2 board: number 0 spans the full top row (0,0)-(2,0); number 1's
486        // endpoints are (1,0) — sitting directly in the middle of number 0's
487        // straight line — and (1,1).
488        let mut game =
489            NumberlinkGame::from_endpoints(3, 2, vec![((0, 0), (2, 0)), ((1, 0), (1, 1))]);
490        game.start_drag(0, (0, 0));
491        game.drag_to(0, (1, 0)); // must be rejected: (1,0) is number 1's node
492        assert_eq!(game.path_cells(0), &[(0, 0)]);
493        assert_eq!(game.owner_at((1, 0)), None);
494    }
495
496    #[test]
497    fn a_drag_that_never_extends_does_not_push_a_no_op_undo_entry() {
498        // Regression test: `start_drag` always seeds the path with the
499        // anchor cell, so a drag that starts and then has every extension
500        // rejected still left a 1-cell "path" (renders/plays identically to
501        // untouched) that used to still count as "changed" and push a
502        // spurious entry onto the undo stack.
503        let mut game = small_game();
504        game.start_drag(0, (0, 0));
505        game.drag_to(0, (1, 0));
506        game.end_drag(0);
507        assert!(game.can_undo());
508
509        game.start_drag(1, (0, 1));
510        game.drag_to(1, (1, 0)); // belongs to number 0, rejected
511        game.end_drag(1);
512        // `start_drag` always seeds the anchor cell; a 1-cell path is the
513        // expected result of a drag that never successfully extended.
514        assert_eq!(game.path_cells(1), &[(0, 1)]);
515
516        game.undo();
517        // The one real move (number 0's connection) must be what gets
518        // undone, not an invisible no-op from the rejected number-1 drag.
519        assert!(game.path_cells(0).is_empty());
520        assert!(!game.can_undo());
521    }
522
523    #[test]
524    fn grabbing_an_interior_cell_truncates_the_path_there() {
525        let mut game = NumberlinkGame::from_endpoints(4, 1, vec![((0, 0), (3, 0))]);
526        game.start_drag(0, (0, 0));
527        game.drag_to(0, (1, 0));
528        game.drag_to(0, (2, 0));
529        game.end_drag(0);
530
531        // Grabbing an interior cell keeps the prefix up to it and frees the
532        // rest, rather than wiping the whole path.
533        assert!(game.start_drag(0, (1, 0)));
534        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0)]);
535        assert_eq!(game.owner_at((2, 0)), None);
536    }
537
538    #[test]
539    fn grabbing_the_far_endpoint_starts_fresh_from_there() {
540        let mut game = NumberlinkGame::from_endpoints(4, 1, vec![((0, 0), (3, 0))]);
541        game.start_drag(0, (0, 0));
542        game.drag_to(0, (1, 0));
543        game.drag_to(0, (2, 0));
544        game.end_drag(0);
545
546        // Both endpoints are symmetric: grabbing the *other* one starts a
547        // fresh path anchored there, not at the original (0, 0) start.
548        assert!(game.start_drag(0, (3, 0)));
549        assert_eq!(game.path_cells(0), &[(3, 0)]);
550        assert_eq!(game.owner_at((0, 0)), None);
551        assert_eq!(game.owner_at((1, 0)), None);
552        assert_eq!(game.owner_at((2, 0)), None);
553    }
554
555    #[test]
556    fn undo_and_redo_restore_prior_and_next_path_state() {
557        let mut game = small_game();
558        game.start_drag(0, (0, 0));
559        game.drag_to(0, (1, 0));
560        game.end_drag(0);
561        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0)]);
562
563        assert!(game.can_undo());
564        game.undo();
565        assert!(game.path_cells(0).is_empty());
566
567        assert!(game.can_redo());
568        game.redo();
569        assert_eq!(game.path_cells(0), &[(0, 0), (1, 0)]);
570    }
571
572    #[test]
573    #[should_panic(expected = "used as more than one endpoint")]
574    fn from_endpoints_rejects_a_cell_reused_across_numbers() {
575        NumberlinkGame::from_endpoints(2, 2, vec![((0, 0), (1, 0)), ((0, 0), (1, 1))]);
576    }
577}