Skip to content

Commit 2f92881

Browse files
author
Ferdinand Schober
committed
visualize forced endings
1 parent a24208c commit 2f92881

5 files changed

Lines changed: 357 additions & 30 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ solitaire-solver = { path = "./solitaire-solver", version = "0.0.1" }
1616

1717
[dev-dependencies]
1818
rayon = "1.11.0"
19+
rand = "0.10"
1920

2021
# run build.rs in release mode, otherwise it takes 100 years
2122
[profile.dev.build-override]

solitaire-game/src/hints.rs

Lines changed: 84 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
use bevy::prelude::*;
22
use bevy_vector_shapes::prelude::*;
3-
use solitaire_solver::{Board, Dir};
43

5-
use crate::{BoardPosition, CurrentBoard, board::MARKER_POS, solver::FeasibleConstellations};
4+
use crate::{
5+
BoardPosition, CurrentBoard,
6+
board::MARKER_POS,
7+
solver::{FeasibleConstellations, ForcedJumps},
8+
};
69

710
pub struct HintsPlugin;
811

@@ -12,7 +15,7 @@ impl Plugin for HintsPlugin {
1215
app.add_observer(update_hints);
1316
app.add_systems(
1417
Update,
15-
draw_possible_moves.run_if(
18+
(draw_possible_moves, draw_forced_jumps).run_if(
1619
resource_exists::<ShowHints>.and_then(resource_exists::<FeasibleConstellations>),
1720
),
1821
);
@@ -33,36 +36,89 @@ fn update_hints(_: On<ToggleHints>, mut commands: Commands, show_hints: Option<R
3336
}
3437
}
3538

39+
/// Draws one marker per legal move: green if a win is still reachable after it, red if not.
40+
///
41+
/// Within the feasible set, a successor being feasible is exactly it being able to reach the
42+
/// solved board, so this two-state colouring is already complete information about the moves
43+
/// available *now* - including "only one of these is green", which is what makes a move
44+
/// forced. Colouring that case specially was tried and dropped: it told the player nothing
45+
/// they could not see by counting green lines. What they cannot see is
46+
/// [`draw_forced_jumps`].
3647
fn draw_possible_moves(
3748
mut painter: ShapePainter,
3849
board: Res<CurrentBoard>,
3950
feasible: Res<FeasibleConstellations>,
4051
) {
41-
let feasible = &feasible.0;
42-
for y in 0..Board::SIZE {
43-
for x in 0..Board::SIZE {
44-
for dir in [Dir::North, Dir::East, Dir::South, Dir::West] {
45-
if !board.0.occupied((y, x)) {
46-
continue;
47-
}
48-
if let Some(mov) = board.0.get_legal_move((y, x), dir) {
49-
let start = BoardPosition::from(mov.pos).to_world_space();
50-
let start = Vec3::from((start, MARKER_POS));
51-
let target = BoardPosition::from(mov.target).to_world_space();
52-
let target = Vec3::from((target, MARKER_POS));
53-
painter.set_color(if feasible.contains(&board.0.mov(mov).normalize()) {
54-
Color::srgba(0., 1., 0., 1.)
55-
} else {
56-
Color::srgba(1., 0., 0., 1.)
57-
});
58-
painter.set_translation(Vec3::new(0., 0., 0.1));
59-
painter.thickness_type = ThicknessType::World;
60-
painter.thickness = 0.075;
61-
painter.line(start, start + (target - start) * 0.2);
62-
painter.set_translation(start.xyz());
63-
painter.circle(0.1);
64-
}
65-
}
52+
for mov in board.0.get_legal_moves() {
53+
let start = BoardPosition::from(mov.pos).to_world_space();
54+
let start = Vec3::from((start, MARKER_POS));
55+
let target = BoardPosition::from(mov.target).to_world_space();
56+
let target = Vec3::from((target, MARKER_POS));
57+
58+
let winning = feasible.0.contains(&board.0.mov(mov).normalize());
59+
painter.set_color(if winning {
60+
Color::srgba(0., 1., 0., 1.)
61+
} else {
62+
Color::srgba(1., 0., 0., 1.)
63+
});
64+
painter.thickness_type = ThicknessType::World;
65+
painter.thickness = 0.075;
66+
painter.set_translation(Vec3::new(0., 0., 0.1));
67+
painter.line(start, start + (target - start) * 0.2);
68+
painter.set_translation(start.xyz());
69+
painter.circle(0.1);
70+
}
71+
}
72+
73+
/// Ghosts the jumps the player is already committed to making later on.
74+
///
75+
/// This is the one thing the per-move colouring cannot show, because it is not about the
76+
/// moves available now: these jumps are not legal yet, and may not be for another dozen
77+
/// moves, but every winning continuation from the current position makes them. See
78+
/// [`solitaire_solver::dominators::forced_jumps`] - and in particular why it works in the
79+
/// player's own frame rather than the normalized quotient, which would claim the four
80+
/// symmetric opening moves are one forced step.
81+
///
82+
/// Drawn unlike [`draw_possible_moves`]: the full span from origin to landing slot rather
83+
/// than a stub, amber, and translucent, fading with how far off the jump is. Nothing is drawn
84+
/// while the result is stale - it is recomputed off-thread on every move and near the opening
85+
/// that takes seconds, so a result for a position already left has to be suppressed rather
86+
/// than shown late.
87+
fn draw_forced_jumps(
88+
mut painter: ShapePainter,
89+
board: Res<CurrentBoard>,
90+
forced: Option<Res<ForcedJumps>>,
91+
) {
92+
let Some(forced) = forced else {
93+
return;
94+
};
95+
if forced.board != board.0 {
96+
return;
97+
}
98+
99+
let pegs = board.0.count_pegs();
100+
for jump in &forced.jumps {
101+
// a forced jump *out of the current board* is exactly the single-green-line case, and
102+
// `draw_possible_moves` already draws it
103+
let ahead = pegs.saturating_sub(jump.board.count_pegs());
104+
if ahead == 0 {
105+
continue;
66106
}
107+
108+
let start = BoardPosition::from(jump.mov.pos).to_world_space();
109+
let start = Vec3::from((start, MARKER_POS));
110+
let target = BoardPosition::from(jump.mov.target).to_world_space();
111+
let target = Vec3::from((target, MARKER_POS));
112+
113+
// the soonest forced jump reads strongest; twenty moves out is barely there
114+
let fade = 1.0 - (ahead as f32 / 20.0).clamp(0.0, 1.0);
115+
let alpha = 0.15f32.lerp(0.7, fade);
116+
painter.set_color(Color::srgba(1.0, 0.72, 0.15, alpha));
117+
painter.thickness_type = ThicknessType::World;
118+
painter.thickness = 0.05;
119+
painter.set_translation(Vec3::new(0., 0., 0.1));
120+
painter.line(start, target);
121+
painter.set_translation(target.xyz());
122+
painter.circle(0.06);
67123
}
68124
}

solitaire-game/src/solver.rs

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use futures_lite::future::{self, block_on};
2+
use solitaire_solver::dominators::{ForcedJump, forced_jumps};
23
use solitaire_solver::{HashMap, HashSet, SolutionMultiset};
34

45
use bevy::{
@@ -8,6 +9,7 @@ use bevy::{
89
window::RequestRedraw,
910
winit::{EventLoopProxyWrapper, WinitUserEvent::WakeUp},
1011
};
12+
use crate::CurrentBoard;
1113
use solitaire_solver::Board;
1214

1315
pub struct Solver;
@@ -28,6 +30,11 @@ impl Plugin for Solver {
2830
Update,
2931
calculate_unique_paths.run_if(resource_added::<FeasibleConstellations>),
3032
);
33+
app.add_systems(
34+
Update,
35+
schedule_forced_jumps
36+
.run_if(resource_exists::<UniquePaths>.and_then(not(resource_exists::<ForcedJumpsPending>))),
37+
);
3138
app.add_systems(Update, poll_task);
3239
}
3340
}
@@ -42,7 +49,23 @@ pub struct RandomMoveChances(pub HashMap<Board, f64>);
4249
pub struct UniqueSolutions(pub Vec<SolutionMultiset>);
4350

4451
#[derive(Resource)]
45-
pub struct UniquePaths(pub HashMap<Board, u64>);
52+
pub struct UniquePaths(pub std::sync::Arc<HashMap<Board, u64>>);
53+
54+
/// The jumps every winning continuation from [`board`](Self::board) still has to make.
55+
///
56+
/// Carries the board it was computed for because it is computed off-thread and the player can
57+
/// move meanwhile: a result for a position that has since been left is worse than none, so
58+
/// consumers compare against [`crate::CurrentBoard`] before trusting it.
59+
#[derive(Resource)]
60+
pub struct ForcedJumps {
61+
pub board: Board,
62+
pub jumps: Vec<ForcedJump>,
63+
}
64+
65+
/// Present while a [`ForcedJumps`] computation is in flight, so moves made during a long one
66+
/// queue up as "recompute when it lands" rather than stacking a task per move.
67+
#[derive(Resource)]
68+
struct ForcedJumpsPending;
4669

4770
/// A unit of work running on the async pool, polled by [`poll_task`].
4871
///
@@ -143,7 +166,56 @@ fn calculate_unique_paths(
143166

144167
let mut command_queue = CommandQueue::default();
145168
command_queue.push(move |world: &mut World| {
146-
world.insert_resource(UniquePaths(unique_paths));
169+
world.insert_resource(UniquePaths(std::sync::Arc::new(unique_paths)));
170+
world.entity_mut(entity).remove::<BackgroundTask>();
171+
});
172+
wake.send_event(WakeUp).unwrap();
173+
command_queue
174+
});
175+
commands.entity(entity).insert(BackgroundTask { task });
176+
}
177+
178+
/// Recomputes [`ForcedJumps`] whenever it is missing or stale for the current board.
179+
///
180+
/// Not cheap early on - the traversal covers every still-winnable board reachable from the
181+
/// current one, which near the opening is most of the game: measured at ~16 s and ~190 MB of
182+
/// transient set at 32 pegs, falling to ~460 ms by 26 pegs and under 10 ms by 24. That is why
183+
/// it runs on the async pool and why the result is stamped with its board instead of assumed
184+
/// current. It is also why it is worth doing at all rather than per-frame: the answer only
185+
/// changes when the board does.
186+
///
187+
/// The opening is also where it has nothing to report - the earliest position with a forced
188+
/// jump over 60 sampled winning lines was 27 pegs - so the expensive end of the range is the
189+
/// end that returns empty. Left uncapped anyway: a peg-count cutoff would be a silent claim
190+
/// that nothing is forced above it, which the sampling does not establish.
191+
fn schedule_forced_jumps(
192+
mut commands: Commands,
193+
board: Res<CurrentBoard>,
194+
paths: Res<UniquePaths>,
195+
forced: Option<Res<ForcedJumps>>,
196+
wake: Res<EventLoopProxyWrapper>,
197+
) {
198+
if forced.is_some_and(|forced| forced.board == board.0) {
199+
return;
200+
}
201+
let thread_pool = AsyncComputeTaskPool::get();
202+
let entity = commands.spawn_empty().id();
203+
let target = board.0;
204+
// an `Arc` clone: this runs on every move, and the counts map is one entry per
205+
// feasible board - copying it per move would dwarf the traversal it feeds
206+
let counts = paths.0.clone();
207+
let wake = wake.clone();
208+
commands.insert_resource(ForcedJumpsPending);
209+
let task = thread_pool.spawn(async move {
210+
let jumps = forced_jumps(target, &counts);
211+
212+
let mut command_queue = CommandQueue::default();
213+
command_queue.push(move |world: &mut World| {
214+
world.insert_resource(ForcedJumps {
215+
board: target,
216+
jumps,
217+
});
218+
world.remove_resource::<ForcedJumpsPending>();
147219
world.entity_mut(entity).remove::<BackgroundTask>();
148220
});
149221
wake.send_event(WakeUp).unwrap();

0 commit comments

Comments
 (0)