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}