use crate::game::{Arrows, Dir, Node, NodeId, Piece, PieceId};
use crate::solver;
const ATTEMPTS: usize = 400;
const MAX_REPAIRS: usize = 200;
const MAX_SET_ROLLS: usize = 100;
const MAX_SCRAMBLE_ROLLS: usize = 100;
const RANK_CAP: usize = 1000;
pub(crate) struct Generated {
pub arrows: Vec<Arrows>,
pub scramble: Vec<Option<PieceId>>,
}
struct Candidate {
arrows: Vec<Arrows>,
solution_nodes: Vec<NodeId>,
rank: usize,
}
pub(crate) fn generate(
nodes: &[Node],
pieces: usize,
min_arrows: usize,
max_arrows: usize,
rng: &mut fastrand::Rng,
) -> Generated {
let mut best: Option<Candidate> = None;
for _ in 0..ATTEMPTS {
let Some(solution_nodes) = pick_occupancy(nodes, pieces, rng) else {
continue;
};
let mut arrows = assign_arrows(nodes, &solution_nodes, min_arrows, max_arrows, rng);
let rank = repair_until_unique(nodes, &solution_nodes, &mut arrows, rng);
if rank == 1 {
return finish(
nodes,
Candidate {
arrows,
solution_nodes,
rank,
},
rng,
);
}
if best.as_ref().is_none_or(|best| rank < best.rank) {
best = Some(Candidate {
arrows,
solution_nodes,
rank,
});
}
}
let best = best.expect("every occupancy roll failed; board cannot host pieces");
finish(nodes, best, rng)
}
fn finish(nodes: &[Node], candidate: Candidate, rng: &mut fastrand::Rng) -> Generated {
Generated {
arrows: candidate.arrows.clone(),
scramble: scramble(nodes, &candidate.arrows, &candidate.solution_nodes, rng),
}
}
fn pick_occupancy(nodes: &[Node], pieces: usize, rng: &mut fastrand::Rng) -> Option<Vec<NodeId>> {
let n = nodes.len();
for _ in 0..MAX_SET_ROLLS {
let mut ids: Vec<NodeId> = (0..n).collect();
for i in 0..pieces {
let j = rng.usize(i..n);
ids.swap(i, j);
}
let mut mask = 0u64;
for &id in &ids[..pieces] {
mask |= 1 << id;
}
let all_supported = ids[..pieces].iter().all(|&id| {
nodes[id]
.neighbors
.iter()
.flatten()
.any(|&neighbor| mask >> neighbor & 1 == 1)
});
if all_supported {
return Some(ids[..pieces].to_vec());
}
}
None
}
fn assign_arrows(
nodes: &[Node],
solution_nodes: &[NodeId],
min_arrows: usize,
max_arrows: usize,
rng: &mut fastrand::Rng,
) -> Vec<Arrows> {
let mut arrows: Vec<Arrows> = solution_nodes
.iter()
.map(|&node| {
let available: Vec<Dir> = Dir::ALL
.into_iter()
.filter(|&dir| {
nodes[node].neighbors[dir as usize]
.is_some_and(|neighbor| solution_nodes.contains(&neighbor))
})
.collect();
debug_assert!(
!available.is_empty(),
"occupancy picker only picks supported nodes"
);
let want = rng.usize(min_arrows..=max_arrows).clamp(1, available.len());
let mut available = available;
let mut arrows = Arrows::default();
for i in 0..want {
let j = rng.usize(i..available.len());
available.swap(i, j);
arrows = arrows.with(available[i]);
}
arrows
})
.collect();
for (piece, _) in solution_nodes.iter().enumerate() {
for dir in arrows[piece].iter().collect::<Vec<Dir>>() {
ensure_reciprocal(nodes, solution_nodes, &mut arrows, piece, dir);
}
}
arrows
}
fn ensure_reciprocal(
nodes: &[Node],
solution_nodes: &[NodeId],
arrows: &mut [Arrows],
piece: usize,
dir: Dir,
) {
let target =
nodes[solution_nodes[piece]].neighbors[dir as usize].expect("arrows only point in-board");
let other = solution_nodes
.iter()
.position(|&n| n == target)
.expect("arrows only point at occupied nodes");
let opposite = dir.opposite();
if !arrows[other].contains(opposite) {
arrows[other] = arrows[other].with(opposite);
}
}
fn repair_until_unique(
nodes: &[Node],
solution_nodes: &[NodeId],
arrows: &mut [Arrows],
rng: &mut fastrand::Rng,
) -> usize {
let pieces_of = |arrows: &[Arrows]| -> Vec<Piece> {
arrows.iter().map(|&arrows| Piece { arrows }).collect()
};
let mut repairs = 0;
loop {
let pieces = pieces_of(arrows);
let Some(alternate) = solver::alternate_solution(&pieces, nodes, solution_nodes) else {
return solver::count(&pieces, nodes, RANK_CAP);
};
if repairs == MAX_REPAIRS {
return solver::count(&pieces, nodes, RANK_CAP);
}
let alt_occupied: u64 = alternate.iter().fold(0u64, |mask, &node| mask | 1 << node);
let mut added = 0;
for piece in 0..arrows.len() {
if repairs == MAX_REPAIRS {
break;
}
let mut killers: Vec<Dir> = Vec::new();
let mut others: Vec<Dir> = Vec::new();
for dir in Dir::ALL {
if arrows[piece].contains(dir) {
continue;
}
let Some(target) = nodes[solution_nodes[piece]].neighbors[dir as usize] else {
continue;
};
if !solution_nodes.contains(&target) {
continue;
}
let other = solution_nodes
.iter()
.position(|&n| n == target)
.expect("target is in solution_nodes");
let opposite = dir.opposite();
let kills = [(alternate[piece], dir), (alternate[other], opposite)]
.iter()
.any(|&(from, d)| match nodes[from].neighbors[d as usize] {
Some(neighbor) => alt_occupied >> neighbor & 1 == 0,
None => true,
});
if kills {
killers.push(dir);
} else {
others.push(dir);
}
}
let pool = if killers.is_empty() {
&others
} else {
&killers
};
if !pool.is_empty() {
let dir = pool[rng.usize(..pool.len())];
arrows[piece] = arrows[piece].with(dir);
ensure_reciprocal(nodes, solution_nodes, arrows, piece, dir);
repairs += 1;
added += 1;
}
}
if added == 0 {
return solver::count(&pieces_of(arrows), nodes, RANK_CAP);
}
}
}
fn scramble(
nodes: &[Node],
arrows: &[Arrows],
solution_nodes: &[NodeId],
rng: &mut fastrand::Rng,
) -> Vec<Option<PieceId>> {
let piece_count = solution_nodes.len();
for _ in 0..MAX_SCRAMBLE_ROLLS {
let mut cell: Vec<Option<PieceId>> = vec![None; nodes.len()];
for (piece, &node) in solution_nodes.iter().enumerate() {
cell[node] = Some(piece as PieceId);
}
for _ in 0..2 * piece_count {
let occupied: Vec<(PieceId, NodeId)> = cell
.iter()
.enumerate()
.filter_map(|(node, &occupant)| occupant.map(|piece| (piece, node)))
.collect();
let empties: Vec<NodeId> = (0..nodes.len())
.filter(|&node| cell[node].is_none())
.collect();
if occupied.is_empty() || empties.is_empty() {
break;
}
let (piece, from) = occupied[rng.usize(..occupied.len())];
let to = empties[rng.usize(..empties.len())];
cell[from] = None;
cell[to] = Some(piece);
}
if is_good_scramble(nodes, arrows, &cell, solution_nodes) {
return cell;
}
}
let mut cell: Vec<Option<PieceId>> = vec![None; nodes.len()];
for (piece, &node) in solution_nodes.iter().enumerate() {
cell[node] = Some(piece as PieceId);
}
cell
}
fn is_good_scramble(
nodes: &[Node],
arrows: &[Arrows],
cell: &[Option<PieceId>],
solution_nodes: &[NodeId],
) -> bool {
if is_solved_placement(nodes, arrows, cell) {
return false;
}
if single_move_solves(nodes, arrows, cell) {
return false;
}
if single_swap_solves(nodes, arrows, cell) {
return false;
}
let placed_right = (0..solution_nodes.len())
.filter(|&piece| cell[solution_nodes[piece]] == Some(piece as PieceId))
.count();
2 * placed_right <= solution_nodes.len()
}
fn is_solved_placement(nodes: &[Node], arrows: &[Arrows], cell: &[Option<PieceId>]) -> bool {
cell.iter().enumerate().all(|(node, &occupant)| {
let Some(piece) = occupant else {
return true;
};
arrows[piece]
.iter()
.all(|dir| match nodes[node].neighbors[dir as usize] {
Some(neighbor) => match cell[neighbor] {
Some(other) => arrows[other].contains(dir.opposite()),
None => false,
},
None => false,
})
})
}
fn single_move_solves(nodes: &[Node], arrows: &[Arrows], cell: &[Option<PieceId>]) -> bool {
for from in 0..cell.len() {
let Some(piece) = cell[from] else {
continue;
};
for to in 0..cell.len() {
if cell[to].is_some() {
continue;
}
let mut moved = cell.to_vec();
moved[from] = None;
moved[to] = Some(piece);
if is_solved_placement(nodes, arrows, &moved) {
return true;
}
}
}
false
}
fn single_swap_solves(nodes: &[Node], arrows: &[Arrows], cell: &[Option<PieceId>]) -> bool {
for a in 0..cell.len() {
let Some(piece_a) = cell[a] else {
continue;
};
for b in (a + 1)..cell.len() {
let Some(piece_b) = cell[b] else {
continue;
};
if arrows[piece_a] == arrows[piece_b] {
continue;
}
let mut swapped = cell.to_vec();
swapped[a] = Some(piece_b);
swapped[b] = Some(piece_a);
if is_solved_placement(nodes, arrows, &swapped) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{build_nodes, random_symmetric_board, HexwebGame, Params};
fn presets() -> Vec<(usize, usize, usize, usize)> {
vec![(8, 6, 2, 3), (12, 9, 2, 4), (16, 12, 3, 5)]
}
#[test]
fn presets_generate_unique_unsolved_boards() {
for (nodes, pieces, min_arrows, max_arrows) in presets() {
for seed in 0..60u64 {
let game = HexwebGame::random(
Params {
nodes: random_symmetric_board(nodes, seed),
pieces,
min_arrows,
max_arrows,
},
seed,
);
assert_eq!(game.piece_count(), pieces, "nodes={nodes} seed={seed}");
assert_eq!(game.solution_count(4), 1, "nodes={nodes} seed={seed}");
assert_eq!(game.status(), crate::game::GameStatus::InProgress);
assert!(!game.is_solved(), "nodes={nodes} seed={seed}");
assert!(
!single_move_solves(game.nodes(), &game_pieces(&game), &scramble_of(&game)),
"one move from victory at nodes={nodes} seed={seed}"
);
assert!(
!single_swap_solves(game.nodes(), &game_pieces(&game), &scramble_of(&game)),
"one swap from victory at nodes={nodes} seed={seed}"
);
for piece in 0..game.piece_count() {
assert!(
!game.pieces()[piece].arrows.is_empty(),
"arrowless piece at nodes={nodes} seed={seed}"
);
}
}
}
}
#[test]
fn generated_boards_are_winnable_by_legal_play() {
for (nodes, pieces, min_arrows, max_arrows) in presets() {
for seed in 0..5u64 {
let mut game = HexwebGame::random(
Params {
nodes: random_symmetric_board(nodes, seed),
pieces,
min_arrows,
max_arrows,
},
seed,
);
let solution = game.solution().expect("generated boards are solvable");
for (piece, &dest) in solution.iter().enumerate() {
if game.node_of(piece) == Some(dest) {
continue;
}
if let Some(blocker) = game.piece_at(dest) {
let buffer = (0..game.node_count())
.find(|&node| game.piece_at(node).is_none())
.expect("generated boards always have an empty node");
assert!(game.move_piece(blocker, buffer));
}
assert!(game.move_piece(piece, dest));
}
assert_eq!(
game.status(),
crate::game::GameStatus::Won,
"nodes={nodes} seed={seed}"
);
}
}
}
#[test]
fn assigned_arrows_are_mutual_within_the_solution() {
for seed in 0..10u64 {
let coords = random_symmetric_board(12, seed);
let (nodes, _) = build_nodes(&coords);
let mut rng = fastrand::Rng::with_seed(seed);
let solution_nodes = pick_occupancy(&nodes, 9, &mut rng).expect("occupancy found");
let arrows = assign_arrows(&nodes, &solution_nodes, 2, 4, &mut rng);
for (piece, &node) in solution_nodes.iter().enumerate() {
for dir in arrows[piece].iter() {
let neighbor = nodes[node].neighbors[dir as usize]
.expect("assigned arrows point in-board");
let other = solution_nodes
.iter()
.position(|&n| n == neighbor)
.expect("assigned arrows point at occupied nodes");
assert!(
arrows[other].contains(dir.opposite()),
"arrow {dir:?} of piece {piece} is not reciprocated (seed {seed})"
);
}
}
}
}
#[test]
fn generation_is_reproducible() {
for (nodes, pieces, min_arrows, max_arrows) in presets() {
let params = || Params {
nodes: random_symmetric_board(nodes, 0),
pieces,
min_arrows,
max_arrows,
};
let a = HexwebGame::random(params(), 12345);
let b = HexwebGame::random(params(), 12345);
assert_eq!(format!("{a:?}"), format!("{b:?}"));
let c = HexwebGame::random(params(), 12346);
assert_ne!(format!("{a:?}"), format!("{c:?}"));
}
}
#[test]
fn reset_returns_to_the_scramble() {
let mut game = HexwebGame::random(
Params {
nodes: random_symmetric_board(12, 0),
pieces: 9,
min_arrows: 2,
max_arrows: 4,
},
7,
);
let initial: Vec<Option<_>> = (0..game.node_count())
.map(|node| game.piece_at(node))
.collect();
let mut moved_any = false;
for piece in 0..game.piece_count() {
for node in 0..game.node_count() {
if game.can_move(piece, node) {
assert!(game.move_piece(piece, node));
moved_any = true;
break;
}
}
if moved_any {
break;
}
}
assert!(moved_any);
assert!(game.moves() > 0);
game.reset();
let restored: Vec<Option<_>> = (0..game.node_count())
.map(|node| game.piece_at(node))
.collect();
assert_eq!(initial, restored);
assert_eq!(game.moves(), 0);
assert_eq!(game.status(), crate::game::GameStatus::InProgress);
}
fn game_pieces(game: &HexwebGame) -> Vec<Arrows> {
game.pieces().iter().map(|piece| piece.arrows).collect()
}
fn scramble_of(game: &HexwebGame) -> Vec<Option<PieceId>> {
(0..game.node_count())
.map(|node| game.piece_at(node))
.collect()
}
#[test]
#[ignore = "offline survey: too slow for the normal suite"]
fn difficulty_survey() {
for (nodes, pieces, min_arrows, max_arrows) in presets() {
let start = std::time::Instant::now();
let mut unique = 0;
let mut total_arrows = 0usize;
let samples = 1000;
for seed in 0..samples as u64 {
let game = HexwebGame::random(
Params {
nodes: random_symmetric_board(nodes, seed),
pieces,
min_arrows,
max_arrows,
},
seed,
);
if game.solution_count(2) == 1 {
unique += 1;
}
total_arrows += game.pieces().iter().map(|p| p.arrows.len()).sum::<usize>();
}
println!(
"nodes={nodes}: {unique}/{samples} unique, avg arrows/piece {:.2}, {:?} total",
total_arrows as f64 / (samples * pieces) as f64,
start.elapsed()
);
assert_eq!(unique, samples, "non-unique boards at nodes={nodes}");
}
}
}