Skip to main content

egui_hexweb/
widget.rs

1//! The egui widget that renders and drives a [`HexwebGame`]. This is the
2//! only module allowed to touch `egui::Ui`/`Painter`.
3//!
4//! # Interaction model
5//!
6//! Pieces move by drag & drop: press a piece and release it over any node
7//! that can take it. An empty node takes the piece as a move; an occupied
8//! one swaps the two pieces. While a piece is dragged it
9//! goes translucent at its origin and every node the drop could land on
10//! grows a landing ring, the nearest one emphasized; releasing anywhere
11//! that is not a legal target (the piece's own node included, where the
12//! swap would change nothing) snaps the piece back. A piece never leaves
13//! its node until the gesture commits, so a drag that ends badly is not a
14//! move at all.
15
16use std::f32::consts::TAU;
17
18use egui::{
19    Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Response, Sense, Shape, Stroke,
20    StrokeKind, Ui, Vec2, Widget,
21};
22
23use crate::game::{Dir, GameStatus, HexwebGame, Node, NodeId, PieceId};
24
25/// `sqrt(3)`: the vertical distance between two hexagon rows, as a
26/// fraction of the circumradius.
27const SQRT_3: f32 = 1.732_050_8;
28
29/// Piece hexagon radius, as a fraction of the cell size. Slightly under 1.0
30/// so a rim of the socket shows around every piece.
31const PIECE_RADIUS: f32 = 0.88;
32/// Chevron geometry, as fractions of the piece radius: how far from the
33/// piece's centre the arrow tip sits, how far back its wings start, and how
34/// far apart they are.
35const ARROW_TIP: f32 = 0.62;
36const ARROW_BACK: f32 = 0.30;
37const ARROW_SPREAD: f32 = 0.17;
38/// Arrow stroke width, as a fraction of the cell size.
39const ARROW_STROKE: f32 = 0.085;
40/// Radius of the landing rings, as a fraction of the cell
41/// size. Under the socket apothem (`sqrt(3)/2`) so rings stay inside the
42/// node they mark.
43const RING_RADIUS: f32 = 0.80;
44
45/// The total footprint [`HexwebWidget`] will occupy for `game` at a given
46/// `cell_size`. Derived from the actual node centers, so irregular board
47/// shapes (a pendant node, a notched hexagon) get a tight box instead of a
48/// bounding square. Lets a caller pre-size a container before laying the
49/// widget out.
50pub fn content_size(game: &HexwebGame, cell_size: f32) -> Vec2 {
51    let (min, max) = center_bounds(game, cell_size);
52    (max - min) + Vec2::new(2.0, SQRT_3) * cell_size
53}
54
55/// The cell size (hex circumradius, in logical pixels) that fits `game`'s
56/// board into `available` space — the same auto-sizing formula
57/// [`HexwebWidget`] uses internally when no explicit `cell_size` is set.
58/// Lets a caller pre-compute that fit (e.g. to size a `Scene` before laying
59/// the widget out inside it) instead of duplicating the formula.
60pub fn fit_cell_size(game: &HexwebGame, available: Vec2) -> f32 {
61    let unit = content_size(game, 1.0);
62    (available.x / unit.x).min(available.y / unit.y).max(4.0)
63}
64
65/// Centre of node `(q, r)` relative to the `(0, 0)` centre, for a flat-top
66/// hexagon of circumradius `size`: columns step `1.5·size` sideways, rows
67/// `sqrt(3)·size` vertically, with every odd column shifted half a row.
68fn node_center(q: i32, r: i32, size: f32) -> Vec2 {
69    Vec2::new(
70        1.5 * size * q as f32,
71        SQRT_3 * size * (r as f32 + 0.5 * q as f32),
72    )
73}
74
75/// Screen angle of a direction. `Dir::ALL` runs clockwise from the top, and
76/// egui angles run clockwise from +x with y pointing down, so `Dir::Up` is
77/// at -90°.
78fn dir_angle(dir: Dir) -> f32 {
79    -TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
80}
81
82/// The (min, max) of the node centers laid out at circumradius `size`.
83fn center_bounds(game: &HexwebGame, size: f32) -> (Vec2, Vec2) {
84    let mut min = Vec2::splat(f32::INFINITY);
85    let mut max = Vec2::splat(f32::NEG_INFINITY);
86    for node in game.nodes() {
87        let c = node_center(node.q, node.r, size);
88        min = min.min(c);
89        max = max.max(c);
90    }
91    (min, max)
92}
93
94/// The corners of a flat-top hexagon, clockwise.
95fn hex_corners(center: Pos2, radius: f32) -> Vec<Pos2> {
96    (0..6)
97        .map(|i| center + Vec2::angled(TAU / 6.0 * i as f32) * radius)
98        .collect()
99}
100
101/// One arrow, as a chevron pointing from the piece's centre toward `dir`.
102fn draw_arrow(painter: &egui::Painter, center: Pos2, dir: Dir, radius: f32, stroke: Stroke) {
103    let v = Vec2::angled(dir_angle(dir));
104    let tip = center + v * (radius * ARROW_TIP);
105    let back = center + v * (radius * ARROW_BACK);
106    let spread = v.rot90() * (radius * ARROW_SPREAD);
107    painter.add(Shape::line(vec![back + spread, tip, back - spread], stroke));
108}
109
110/// Whether dropping `piece` on `node` would land: an empty node takes a
111/// move, an occupied one takes a swap with its piece. The piece's own node
112/// never qualifies, so the highlighted target always marks a drop that
113/// actually lands.
114fn can_drop_at(game: &HexwebGame, piece: PieceId, node: NodeId) -> bool {
115    match game.piece_at(node) {
116        Some(other) => other != piece && game.can_swap(piece, other),
117        None => game.can_move(piece, node),
118    }
119}
120
121/// The mapping between board nodes and screen positions for one widget
122/// pass. Shared by painting and hit-testing so the two can never drift
123/// apart.
124#[derive(Clone, Copy)]
125struct Geometry {
126    /// Top-left of the widget's allocated rect.
127    origin: Pos2,
128    /// Hex circumradius.
129    cell: f32,
130    /// Node-center bounding-box min at this `cell`, used to shift the board
131    /// into the allocated rect with a margin of one hexagon.
132    min: Vec2,
133}
134
135impl Geometry {
136    fn new(origin: Pos2, cell: f32, game: &HexwebGame) -> Self {
137        Self {
138            origin,
139            cell,
140            min: center_bounds(game, cell).0,
141        }
142    }
143
144    /// Screen position of `node`'s center.
145    fn center(&self, node: &Node) -> Pos2 {
146        self.origin + node_center(node.q, node.r, self.cell) - self.min
147            + Vec2::new(self.cell, SQRT_3 * 0.5 * self.cell)
148    }
149
150    /// The node nearest `pos`, if it is close enough to claim the pointer.
151    /// Neighboring centers sit `sqrt(3)·cell` apart, so gating the nearest
152    /// at one circumradius covers every node's own hexagonal cell without
153    /// any two cells overlapping.
154    fn node_at(&self, game: &HexwebGame, pos: Pos2) -> Option<NodeId> {
155        let mut best = None;
156        let mut best_d = f32::INFINITY;
157        for (id, node) in game.nodes().iter().enumerate() {
158            let d = pos.distance(self.center(node));
159            if d < best_d {
160                best = Some(id);
161                best_d = d;
162            }
163        }
164        match best {
165            Some(id) if best_d <= self.cell => Some(id),
166            _ => None,
167        }
168    }
169}
170
171/// An egui widget that renders an interactive hexweb board.
172///
173/// Drag a piece onto an empty node to move it there, or onto another piece
174/// to swap the two.
175/// Arrows pointing at another piece are drawn in
176/// `satisfied_color`; the ones still pointing at an empty node or off the
177/// board are drawn in `unsatisfied_color`, which is the board's live
178/// progress feedback. The puzzle is solved when no arrow is unsatisfied.
179///
180/// ```ignore
181/// ui.add(egui_hexweb::HexwebWidget::new(&mut game));
182/// ```
183pub struct HexwebWidget<'a> {
184    game: &'a mut HexwebGame,
185    cell_size: Option<f32>,
186    win_message: Option<String>,
187    interactive: bool,
188    satisfied_color: Option<Color32>,
189    unsatisfied_color: Option<Color32>,
190}
191
192impl<'a> HexwebWidget<'a> {
193    pub fn new(game: &'a mut HexwebGame) -> Self {
194        Self {
195            game,
196            cell_size: None,
197            win_message: None,
198            interactive: true,
199            satisfied_color: None,
200            unsatisfied_color: None,
201        }
202    }
203
204    /// Override the size (in logical pixels) of each hexagon's
205    /// circumradius. When not set, the cell size is computed automatically
206    /// to fill the available space of the parent container.
207    pub fn cell_size(mut self, size: f32) -> Self {
208        self.cell_size = Some(size);
209        self
210    }
211
212    /// Message shown in the win banner drawn over the board once
213    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"`. The crate
214    /// ships no translations; embedding apps pass their own.
215    pub fn win_message(mut self, message: impl Into<String>) -> Self {
216        self.win_message = Some(message.into());
217        self
218    }
219
220    /// Color for arrows that point at an occupied node. Defaults to the
221    /// theme's strong text color.
222    pub fn satisfied_color(mut self, color: Color32) -> Self {
223        self.satisfied_color = Some(color);
224        self
225    }
226
227    /// Color for arrows that point at an empty node or off the board.
228    /// Defaults to the theme's error color — the accent the player reads
229    /// as "this piece still needs to move".
230    pub fn unsatisfied_color(mut self, color: Color32) -> Self {
231        self.unsatisfied_color = Some(color);
232        self
233    }
234
235    /// Whether the widget responds to input at all. Set to `false` to
236    /// render the board read-only. Defaults to `true`.
237    pub fn interactive(mut self, interactive: bool) -> Self {
238        self.interactive = interactive;
239        self
240    }
241}
242
243impl Widget for HexwebWidget<'_> {
244    fn ui(self, ui: &mut Ui) -> Response {
245        let Self {
246            game,
247            cell_size,
248            win_message,
249            interactive,
250            satisfied_color,
251            unsatisfied_color,
252        } = self;
253
254        let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
255        let total_size = content_size(game, cell);
256
257        let sense = if interactive {
258            Sense::drag()
259        } else {
260            Sense::hover()
261        };
262        let (response, painter) = ui.allocate_painter(total_size, sense);
263        let geometry = Geometry::new(response.rect.min, cell, game);
264
265        // ── Input ────────────────────────────────────────────────────────
266        // The dragged piece is stashed in egui's per-widget temp memory so
267        // a gesture spans frames without the game holding
268        // any presentation state.
269        let drag_id = response.id.with("hexweb_drag_piece");
270        let can_play = interactive && game.status() == GameStatus::InProgress;
271        let pointer = response.interact_pointer_pos();
272        let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
273
274        if can_play && response.drag_started() {
275            // Hit-test where the press started, not where the pointer
276            // is now: the first move event can already have crossed
277            // into a neighboring node's cell, and grabbing whatever
278            // piece happens to be under the *current* pointer would
279            // steal the wrong piece on a fast flick.
280            let grabbed = ui
281                .input(|i| i.pointer.press_origin())
282                .and_then(|pos| geometry.node_at(game, pos))
283                .and_then(|node| game.piece_at(node));
284            if let Some(piece) = grabbed {
285                ui.ctx().data_mut(|d| d.insert_temp(drag_id, piece));
286                dragging = Some(piece);
287            }
288        }
289
290        // Where the dragged piece would land right now, if anywhere.
291        let drag_target = if can_play {
292            dragging.and_then(|piece| {
293                pointer
294                    .and_then(|pos| geometry.node_at(game, pos))
295                    .filter(|&node| can_drop_at(game, piece, node))
296            })
297        } else {
298            None
299        };
300
301        if response.drag_stopped() {
302            if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
303                if let Some(node) = pointer
304                    .and_then(|pos| geometry.node_at(game, pos))
305                    .filter(|&node| can_drop_at(game, piece, node))
306                {
307                    match game.piece_at(node) {
308                        Some(other) => {
309                            game.swap_pieces(piece, other);
310                        }
311                        None => {
312                            game.move_piece(piece, node);
313                        }
314                    }
315                }
316            }
317            dragging = None;
318        }
319
320        if can_play {
321            if dragging.is_some() {
322                ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
323            } else if let Some(node) = response
324                .hover_pos()
325                .and_then(|pos| geometry.node_at(game, pos))
326            {
327                if game.piece_at(node).is_some() {
328                    ui.ctx().set_cursor_icon(CursorIcon::Grab);
329                }
330            }
331        }
332
333        // ── Painting ─────────────────────────────────────────────────────
334        let visuals = ui.visuals();
335        let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
336        let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
337
338        let (piece_fill, piece_edge) = if visuals.dark_mode {
339            (Color32::from_gray(0x5C), Color32::from_gray(0x82))
340        } else {
341            (Color32::from_gray(0xC9), Color32::from_gray(0x8E))
342        };
343        let socket_fill = visuals.extreme_bg_color;
344        let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
345
346        // Sockets first: every node shows one, pieces draw over them.
347        for node in game.nodes() {
348            painter.add(Shape::convex_polygon(
349                hex_corners(geometry.center(node), cell),
350                socket_fill,
351                Stroke::new(1.0, socket_edge),
352            ));
353        }
354
355        // Pieces and their arrows, on top of everything.
356        for piece in 0..game.piece_count() {
357            let Some(node) = game.node_of(piece) else {
358                continue;
359            };
360            let center = geometry.center(&game.nodes()[node]);
361            let in_hand = dragging == Some(piece);
362            let alpha = if in_hand { 0.35 } else { 1.0 };
363            let arrows = game.pieces()[piece].arrows;
364            let unsatisfied = game.unsatisfied_arrows(piece);
365
366            painter.add(Shape::convex_polygon(
367                hex_corners(center, cell * PIECE_RADIUS),
368                piece_fill.gamma_multiply(alpha),
369                Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
370            ));
371            for dir in arrows.iter() {
372                let color = if unsatisfied.contains(dir) {
373                    unsatisfied_color
374                } else {
375                    satisfied_color
376                };
377                draw_arrow(
378                    &painter,
379                    center,
380                    dir,
381                    cell * PIECE_RADIUS,
382                    Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
383                );
384            }
385        }
386
387        // Landing rings while a piece is in the air: a faint ring on every
388        // node the drop could land on (empty ones, and pieces that would be
389        // swapped), a strong one on the node it would land on right now.
390        // Painted over the pieces, so a ring on an occupied node stays
391        // visible; the radius clears the arrow tips.
392        if can_play {
393            if let Some(dragged) = dragging {
394                for (id, node) in game.nodes().iter().enumerate() {
395                    if !can_drop_at(game, dragged, id) {
396                        continue;
397                    }
398                    let faint = if game.piece_at(id).is_some() {
399                        piece_edge
400                    } else {
401                        piece_fill
402                    };
403                    let stroke = if drag_target == Some(id) {
404                        Stroke::new(cell * 0.10, satisfied_color)
405                    } else {
406                        Stroke::new(cell * 0.05, faint)
407                    };
408                    painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
409                }
410            }
411        }
412
413        // Win banner, drawn last so it sits on top of everything else.
414        if game.status() == GameStatus::Won {
415            let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
416            let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
417            let galley = painter.layout_no_wrap(message, font, satisfied_color);
418            // Hugging the top edge rather than centred: the fully satisfied
419            // ring of arrows is the reward for solving the board, and a
420            // banner in the middle of it covers exactly the part worth
421            // looking at.
422            let size = galley.size() + Vec2::new(cell, cell * 0.6);
423            let banner = Rect::from_center_size(
424                Pos2::new(
425                    response.rect.center().x,
426                    response.rect.min.y + size.y * 0.5 + cell * 0.15,
427                ),
428                size,
429            );
430            painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
431            painter.rect_stroke(
432                banner,
433                CornerRadius::same(6),
434                Stroke::new(1.0, satisfied_color),
435                StrokeKind::Inside,
436            );
437            painter.galley(
438                banner.center() - galley.size() * 0.5,
439                galley,
440                Color32::PLACEHOLDER,
441            );
442        }
443
444        response
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::game::{build_nodes, random_symmetric_board, Arrows, Params, Piece};
452
453    /// Beginner-shaped board: 8 nodes, 6 pieces, deterministic per seed.
454    fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
455        HexwebGame::random(
456            Params {
457                nodes: random_symmetric_board(nodes, seed),
458                pieces,
459                min_arrows: 2,
460                max_arrows: 4,
461            },
462            seed,
463        )
464    }
465
466    #[test]
467    fn dir_angles_match_the_pixel_deltas() {
468        for dir in Dir::ALL {
469            let (dq, dr) = dir.delta();
470            let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
471            let angled = Vec2::angled(dir_angle(dir));
472            assert!(
473                (pixel.normalized() - angled).length() < 1e-4,
474                "{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
475            );
476        }
477    }
478
479    #[test]
480    fn content_size_and_fit_round_trip() {
481        let game = preset_game(12, 9, 7);
482        let size = content_size(&game, 10.0);
483        assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
484        // The board is not a square: the fit is bounded by the tighter side.
485        assert!(size.x != size.y);
486        assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
487    }
488
489    #[test]
490    fn node_at_round_trips_through_every_center() {
491        let game = preset_game(16, 12, 3);
492        let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
493        for (id, node) in game.nodes().iter().enumerate() {
494            assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
495            // A small offset stays within the node's cell.
496            let near = geometry.center(node) + Vec2::new(6.0, -4.0);
497            assert_eq!(geometry.node_at(&game, near), Some(id));
498        }
499        assert_eq!(
500            geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
501            None,
502            "far away from the board there is nothing to hit"
503        );
504    }
505
506    const TEST_CELL: f32 = 24.0;
507
508    /// Runs the widget in a real `egui` pass so drags are exercised end to
509    /// end: the temp-memory gesture state and the drop logic only behave
510    /// correctly through egui's own pointer machinery, which a plain method
511    /// call can't check.
512    struct Harness {
513        ctx: egui::Context,
514        game: HexwebGame,
515        rect: Rect,
516        time: f64,
517    }
518
519    impl Harness {
520        fn new(game: HexwebGame) -> Self {
521            let mut harness = Self {
522                ctx: egui::Context::default(),
523                game,
524                rect: Rect::ZERO,
525                time: 0.0,
526            };
527            // The first pass places the board.
528            harness.pass(Vec::new());
529            harness
530        }
531
532        fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
533            self.time += 1.0 / 60.0;
534            let input = egui::RawInput {
535                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
536                events,
537                time: Some(self.time),
538                ..Default::default()
539            };
540            let game = &mut self.game;
541            let rect = std::cell::Cell::new(Rect::ZERO);
542            let _ = self.ctx.run_ui(input, |ui| {
543                let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
544                rect.set(response.rect);
545            });
546            self.rect = rect.get();
547            self.rect
548        }
549
550        fn geometry(&self) -> Geometry {
551            Geometry::new(self.rect.min, TEST_CELL, &self.game)
552        }
553
554        fn center(&self, node: NodeId) -> Pos2 {
555            self.geometry().center(&self.game.nodes()[node])
556        }
557
558        fn press(&mut self, pos: Pos2) {
559            self.pass(vec![
560                egui::Event::PointerMoved(pos),
561                egui::Event::PointerButton {
562                    pos,
563                    button: egui::PointerButton::Primary,
564                    pressed: true,
565                    modifiers: Default::default(),
566                },
567            ]);
568        }
569
570        fn drag(&mut self, pos: Pos2) {
571            self.pass(vec![egui::Event::PointerMoved(pos)]);
572        }
573
574        fn release(&mut self, pos: Pos2) {
575            self.pass(vec![
576                egui::Event::PointerMoved(pos),
577                egui::Event::PointerButton {
578                    pos,
579                    button: egui::PointerButton::Primary,
580                    pressed: false,
581                    modifiers: Default::default(),
582                },
583            ]);
584        }
585    }
586
587    fn empty_node(game: &HexwebGame) -> NodeId {
588        (0..game.node_count())
589            .find(|&node| game.piece_at(node).is_none())
590            .expect("presets always leave empty nodes")
591    }
592
593    fn occupied_node(game: &HexwebGame) -> NodeId {
594        (0..game.node_count())
595            .find(|&node| game.piece_at(node).is_some())
596            .expect("presets always have pieces")
597    }
598
599    #[test]
600    fn dragging_a_piece_to_an_empty_node_moves_it() {
601        let mut harness = Harness::new(preset_game(12, 9, 5));
602        let from = occupied_node(&harness.game);
603        let piece = harness.game.piece_at(from).unwrap();
604        let target = empty_node(&harness.game);
605
606        harness.press(harness.center(from));
607        // Cross egui's drag radius so this becomes a drag, not a click.
608        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
609        harness.drag(harness.center(target));
610        harness.release(harness.center(target));
611
612        assert_eq!(harness.game.node_of(piece), Some(target));
613        assert_eq!(harness.game.moves(), 1);
614    }
615
616    #[test]
617    fn dropping_off_the_board_snaps_back() {
618        let mut harness = Harness::new(preset_game(12, 9, 5));
619        let from = occupied_node(&harness.game);
620        let piece = harness.game.piece_at(from).unwrap();
621
622        harness.press(harness.center(from));
623        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
624        harness.release(Pos2::new(-100.0, -100.0));
625
626        assert_eq!(harness.game.node_of(piece), Some(from));
627        assert_eq!(harness.game.moves(), 0);
628    }
629
630    /// A node (other than `except`) holding a piece the given one may swap
631    /// with, if any.
632    fn swappable_node(game: &HexwebGame, piece: PieceId, except: NodeId) -> Option<NodeId> {
633        (0..game.node_count()).find(|&node| match game.piece_at(node) {
634            Some(other) => node != except && game.can_swap(piece, other),
635            None => false,
636        })
637    }
638
639    #[test]
640    fn dropping_on_an_occupied_node_swaps_the_pieces() {
641        let mut harness = Harness::new(preset_game(12, 9, 5));
642        let from = occupied_node(&harness.game);
643        let piece = harness.game.piece_at(from).unwrap();
644        let taken = swappable_node(&harness.game, piece, from)
645            .expect("some other piece carries different arrows");
646        let taken_piece = harness.game.piece_at(taken).unwrap();
647
648        harness.press(harness.center(from));
649        // Cross egui's drag radius so this becomes a drag, not a click.
650        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
651        harness.release(harness.center(taken));
652
653        assert_eq!(harness.game.node_of(piece), Some(taken));
654        assert_eq!(harness.game.node_of(taken_piece), Some(from));
655        assert_eq!(harness.game.moves(), 1);
656    }
657
658    #[test]
659    fn releasing_on_the_dragged_pieces_own_node_snaps_back() {
660        let mut harness = Harness::new(preset_game(12, 9, 5));
661        let from = occupied_node(&harness.game);
662        let piece = harness.game.piece_at(from).unwrap();
663
664        harness.press(harness.center(from));
665        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
666        harness.release(harness.center(from));
667
668        assert_eq!(harness.game.node_of(piece), Some(from));
669        assert_eq!(harness.game.moves(), 0);
670    }
671
672    /// Two identical pieces on a 4-node line, far enough apart that both
673    /// arrows point at empty nodes. Any seed does; this keeps the
674    /// identical-pair test independent of the presets.
675    fn identical_pair_game() -> HexwebGame {
676        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
677        let pieces = vec![
678            Piece {
679                arrows: Arrows::from_dir(Dir::Up),
680            },
681            Piece {
682                arrows: Arrows::from_dir(Dir::Up),
683            },
684        ];
685        let cell_piece = vec![Some(0), None, None, Some(1)];
686        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
687    }
688
689    #[test]
690    fn dropping_on_an_identical_piece_is_free() {
691        let mut harness = Harness::new(identical_pair_game());
692        let from = occupied_node(&harness.game);
693        let piece = harness.game.piece_at(from).unwrap();
694        let taken = (0..harness.game.node_count())
695            .find(|&node| node != from && harness.game.piece_at(node).is_some())
696            .expect("the second piece exists");
697
698        harness.press(harness.center(from));
699        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
700        harness.release(harness.center(taken));
701
702        // The pieces trade places, but they are interchangeable, so the
703        // swap costs no move.
704        assert_eq!(harness.game.node_of(piece), Some(taken));
705        assert_eq!(harness.game.moves(), 0);
706    }
707
708    /// Plays the board's unique solution through legal moves. Pieces can
709    /// sit on each other's targets, so cycles are broken by parking a
710    /// piece on any node no solution needs; there is always at least one
711    /// empty node to park on.
712    fn play_solution(game: &mut HexwebGame) {
713        let Some(solution) = game.solution() else {
714            panic!("test boards always have a solution");
715        };
716        let mut placed: Vec<bool> = (0..game.piece_count())
717            .map(|piece| game.node_of(piece) == Some(solution[piece]))
718            .collect();
719        while placed.iter().any(|&done| !done) {
720            // An unplaced piece whose target is empty can move right away.
721            let step = (0..game.piece_count())
722                .filter(|&piece| !placed[piece])
723                .find_map(|piece| {
724                    game.piece_at(solution[piece])
725                        .is_none()
726                        .then_some((piece, solution[piece]))
727                })
728                .or_else(|| {
729                    // Otherwise every unplaced target is occupied: park any
730                    // unplaced piece on a node no piece targets.
731                    let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
732                    let node =
733                        (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
734                    Some((piece, node))
735                })
736                .expect("a solvable board is always finishable");
737            let (piece, node) = step;
738            assert!(game.move_piece(piece, node));
739            placed[piece] = game.node_of(piece) == Some(solution[piece]);
740        }
741    }
742
743    #[test]
744    fn a_won_board_ignores_input() {
745        let mut harness = Harness::new(preset_game(8, 6, 2));
746        play_solution(&mut harness.game);
747        assert_eq!(harness.game.status(), GameStatus::Won);
748        let moves = harness.game.moves();
749
750        let from = occupied_node(&harness.game);
751        let target = empty_node(&harness.game);
752        let taken = (0..harness.game.node_count())
753            .find(|&node| node != from && harness.game.piece_at(node).is_some())
754            .expect("a second occupied node exists");
755        // Drags no longer pick anything up: neither onto an empty node nor
756        // onto another piece to swap.
757        harness.press(harness.center(from));
758        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
759        harness.release(harness.center(target));
760        harness.press(harness.center(from));
761        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
762        harness.release(harness.center(taken));
763
764        assert_eq!(harness.game.moves(), moves);
765    }
766}