pattern walking for the fill
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
use crate::config::GenerationConfig;
|
||||
use crate::error::{CrucivError, Result};
|
||||
|
||||
use super::{Cell, Grid};
|
||||
use super::slot::extract_slots;
|
||||
use super::symmetry::mirror_coords;
|
||||
|
||||
const MAX_WORD_LEN_HUGE: usize = 7; // grids 25x25+
|
||||
const MAX_WORD_LEN_LARGE: usize = 9; // grids 19x19+
|
||||
|
||||
/// Try to load a grid from pre-made templates first (fast path for 15x15 and 21x21).
|
||||
/// Falls back to random generation if no templates available.
|
||||
pub fn generate_pattern(config: &GenerationConfig) -> Result<Grid> {
|
||||
let mut rng = match config.seed {
|
||||
Some(s) => StdRng::seed_from_u64(s),
|
||||
None => StdRng::from_os_rng(),
|
||||
};
|
||||
|
||||
// try template-based generation for large grids
|
||||
if config.width >= 15 && config.height >= 15 {
|
||||
if let Some(grid) = load_template(config, &mut rng) {
|
||||
return Ok(grid);
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..100 {
|
||||
let grid = try_generate(config, &mut rng);
|
||||
if let Some(grid) = grid {
|
||||
return Ok(grid);
|
||||
}
|
||||
}
|
||||
|
||||
Err(CrucivError::Generation(
|
||||
"failed to generate valid grid pattern after 100 attempts".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn load_template(config: &GenerationConfig, rng: &mut StdRng) -> Option<Grid> {
|
||||
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
|
||||
let filename = format!("grids_{}x{}.txt", config.width, config.height);
|
||||
let path = std::path::Path::new(&data_dir).join("grid-templates").join(&filename);
|
||||
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let templates: Vec<&str> = content.split("\n\n").filter(|s| !s.trim().is_empty()).collect();
|
||||
if templates.is_empty() { return None; }
|
||||
|
||||
// try a few random templates
|
||||
for _ in 0..20 {
|
||||
let idx = rng.random_range(0..templates.len());
|
||||
let template = templates[idx].trim();
|
||||
let lines: Vec<&str> = template.lines().collect();
|
||||
if lines.len() != config.height { continue; }
|
||||
if lines[0].len() != config.width { continue; }
|
||||
|
||||
let mut grid = Grid::new(config.width, config.height);
|
||||
for (r, line) in lines.iter().enumerate() {
|
||||
for (c, ch) in line.chars().enumerate() {
|
||||
if ch == '#' {
|
||||
grid.set(r, c, Cell::Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_connected(&grid) && has_valid_words(&grid, config.min_word_length) {
|
||||
let slots = extract_slots(&grid, config.min_word_length);
|
||||
if !slots.is_empty() {
|
||||
tracing::debug!("loaded {}x{} template (index {})", config.width, config.height, idx);
|
||||
return Some(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
fn try_generate(config: &GenerationConfig, rng: &mut StdRng) -> Option<Grid> {
|
||||
let w = config.width;
|
||||
let h = config.height;
|
||||
|
||||
let max_word_len = if w >= 25 || h >= 25 { MAX_WORD_LEN_HUGE }
|
||||
else if w >= 19 || h >= 19 { MAX_WORD_LEN_LARGE }
|
||||
else { w.max(h) };
|
||||
|
||||
let min_density = if w >= 25 || h >= 25 { 0.30 }
|
||||
else if w >= 19 || h >= 19 { 0.25 }
|
||||
else { 0.0 };
|
||||
let effective_density = config.black_cell_density.max(min_density);
|
||||
|
||||
// for high density (sparse puzzles), carve slots out of a black grid
|
||||
// for normal density, add black cells to a white grid
|
||||
let mut grid = if effective_density > 0.50 {
|
||||
try_generate_sparse(w, h, effective_density, config, rng)?
|
||||
} else {
|
||||
try_generate_dense(w, h, effective_density, config, rng)?
|
||||
};
|
||||
|
||||
if has_long_runs(&grid, max_word_len) {
|
||||
break_long_runs(&mut grid, max_word_len, config, rng);
|
||||
}
|
||||
|
||||
if !is_connected(&grid) || !has_valid_words(&grid, config.min_word_length) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slots = extract_slots(&grid, config.min_word_length);
|
||||
if slots.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(grid)
|
||||
}
|
||||
|
||||
/// Standard approach: start white, add black cells while maintaining connectivity
|
||||
fn try_generate_dense(
|
||||
w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng,
|
||||
) -> Option<Grid> {
|
||||
let mut grid = Grid::new(w, h);
|
||||
let total = w * h;
|
||||
let target_black = (total as f64 * density) as usize;
|
||||
|
||||
let mut positions: Vec<(usize, usize)> = Vec::new();
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
positions.push((r, c));
|
||||
}
|
||||
}
|
||||
shuffle(&mut positions, rng);
|
||||
|
||||
let mut placed = 0;
|
||||
for &(r, c) in &positions {
|
||||
if placed >= target_black { break; }
|
||||
if grid.get(r, c).is_black() { continue; }
|
||||
|
||||
let coords = mirror_coords(r, c, w, h, config.symmetry);
|
||||
let would_place = coords.iter().filter(|&&(mr, mc)| !grid.get(mr, mc).is_black()).count();
|
||||
if placed + would_place > target_black + 2 { continue; }
|
||||
|
||||
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Black); }
|
||||
|
||||
if !has_valid_words(&grid, config.min_word_length) || !is_connected(&grid) {
|
||||
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
|
||||
continue;
|
||||
}
|
||||
|
||||
placed = grid.black_count();
|
||||
}
|
||||
|
||||
Some(grid)
|
||||
}
|
||||
|
||||
/// Sparse approach: start black, carve connected word slots
|
||||
fn try_generate_sparse(
|
||||
w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng,
|
||||
) -> Option<Grid> {
|
||||
let mut grid = Grid::new_black(w, h);
|
||||
let total = w * h;
|
||||
let target_white = ((1.0 - density) * total as f64).max(6.0) as usize;
|
||||
let min_len = config.min_word_length;
|
||||
|
||||
// start with one slot near the center to seed connectivity
|
||||
let cr = h / 2;
|
||||
let cc = w / 2;
|
||||
let first_len = min_len + rng.random_range(0..=2);
|
||||
let start_c = cc.saturating_sub(first_len / 2);
|
||||
for i in 0..first_len {
|
||||
if start_c + i < w {
|
||||
let coords = mirror_coords(cr, start_c + i, w, h, config.symmetry);
|
||||
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
|
||||
}
|
||||
}
|
||||
|
||||
// carve more slots, preferring positions that intersect existing white cells
|
||||
for _ in 0..300 {
|
||||
if grid.white_count() >= target_white { break; }
|
||||
|
||||
let horizontal = rng.random_bool(0.5);
|
||||
let len = min_len + rng.random_range(0..=3);
|
||||
|
||||
// try to find a position that crosses an existing white cell
|
||||
let mut best = None;
|
||||
for _ in 0..20 {
|
||||
let r = rng.random_range(0..h);
|
||||
let c = rng.random_range(0..w);
|
||||
|
||||
let (end_r, end_c) = if horizontal { (r, c + len - 1) } else { (r + len - 1, c) };
|
||||
if end_r >= h || end_c >= w { continue; }
|
||||
|
||||
// check if this slot touches any existing white cell
|
||||
let mut touches = false;
|
||||
for i in 0..len {
|
||||
let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) };
|
||||
if grid.get(pr, pc).is_white() { touches = true; break; }
|
||||
// also check adjacent cells
|
||||
for (dr, dc) in [(-1i32,0),(1,0),(0,-1i32),(0,1)] {
|
||||
let nr = pr as i32 + dr;
|
||||
let nc = pc as i32 + dc;
|
||||
if nr >= 0 && nc >= 0 && (nr as usize) < h && (nc as usize) < w {
|
||||
if grid.get(nr as usize, nc as usize).is_white() { touches = true; break; }
|
||||
}
|
||||
}
|
||||
if touches { break; }
|
||||
}
|
||||
|
||||
// first slot always ok, subsequent must touch existing
|
||||
if grid.white_count() <= first_len * 2 || touches {
|
||||
best = Some((r, c));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some((r, c)) = best else { continue };
|
||||
|
||||
for i in 0..len {
|
||||
let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) };
|
||||
if pr < h && pc < w {
|
||||
let coords = mirror_coords(pr, pc, w, h, config.symmetry);
|
||||
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_connected(&grid) || !has_valid_words(&grid, min_len) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slots = extract_slots(&grid, min_len);
|
||||
if slots.len() < 2 { return None; }
|
||||
|
||||
Some(grid)
|
||||
}
|
||||
|
||||
fn has_long_runs(grid: &Grid, max_len: usize) -> bool {
|
||||
for r in 0..grid.height {
|
||||
let mut run = 0;
|
||||
for c in 0..grid.width {
|
||||
if grid.get(r, c).is_white() {
|
||||
run += 1;
|
||||
if run > max_len { return true; }
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..grid.width {
|
||||
let mut run = 0;
|
||||
for r in 0..grid.height {
|
||||
if grid.get(r, c).is_white() {
|
||||
run += 1;
|
||||
if run > max_len { return true; }
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn break_long_runs(grid: &mut Grid, max_len: usize, config: &GenerationConfig, _rng: &mut StdRng) {
|
||||
for _ in 0..100 {
|
||||
if !has_long_runs(grid, max_len) {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut broke_something = false;
|
||||
|
||||
// find and break a long row run
|
||||
'rows: for r in 0..grid.height {
|
||||
let mut start = None;
|
||||
let mut run = 0;
|
||||
for c in 0..=grid.width {
|
||||
if c < grid.width && grid.get(r, c).is_white() {
|
||||
if start.is_none() { start = Some(c); }
|
||||
run += 1;
|
||||
} else {
|
||||
if run > max_len {
|
||||
let s = start.unwrap();
|
||||
let mid = s + run / 2;
|
||||
if try_place_black(grid, r, mid, config) {
|
||||
broke_something = true;
|
||||
break 'rows;
|
||||
}
|
||||
}
|
||||
start = None;
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find and break a long column run
|
||||
'cols: for c in 0..grid.width {
|
||||
let mut start = None;
|
||||
let mut run = 0;
|
||||
for r in 0..=grid.height {
|
||||
if r < grid.height && grid.get(r, c).is_white() {
|
||||
if start.is_none() { start = Some(r); }
|
||||
run += 1;
|
||||
} else {
|
||||
if run > max_len {
|
||||
let s = start.unwrap();
|
||||
let mid = s + run / 2;
|
||||
if try_place_black(grid, mid, c, config) {
|
||||
broke_something = true;
|
||||
break 'cols;
|
||||
}
|
||||
}
|
||||
start = None;
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !broke_something {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_place_black(grid: &mut Grid, r: usize, c: usize, config: &GenerationConfig) -> bool {
|
||||
let coords = mirror_coords(r, c, grid.width, grid.height, config.symmetry);
|
||||
for &(mr, mc) in &coords {
|
||||
grid.set(mr, mc, Cell::Black);
|
||||
}
|
||||
if is_connected(grid) && has_valid_words(grid, config.min_word_length) {
|
||||
true
|
||||
} else {
|
||||
for &(mr, mc) in &coords {
|
||||
grid.set(mr, mc, Cell::Empty);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn has_valid_words(grid: &Grid, min_len: usize) -> bool {
|
||||
// every white cell must belong to at least one run of min_len+ in some direction
|
||||
// first compute across run length for each cell
|
||||
let w = grid.width;
|
||||
let h = grid.height;
|
||||
let mut across_len = vec![vec![0usize; w]; h];
|
||||
for r in 0..h {
|
||||
let mut start = 0;
|
||||
while start < w {
|
||||
if !grid.get(r, start).is_white() { start += 1; continue; }
|
||||
let mut end = start;
|
||||
while end < w && grid.get(r, end).is_white() { end += 1; }
|
||||
let len = end - start;
|
||||
for c in start..end { across_len[r][c] = len; }
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
// compute down run length for each cell
|
||||
let mut down_len = vec![vec![0usize; w]; h];
|
||||
for c in 0..w {
|
||||
let mut start = 0;
|
||||
while start < h {
|
||||
if !grid.get(start, c).is_white() { start += 1; continue; }
|
||||
let mut end = start;
|
||||
while end < h && grid.get(end, c).is_white() { end += 1; }
|
||||
let len = end - start;
|
||||
for r in start..end { down_len[r][c] = len; }
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
// check: every white cell must be in valid-length runs in BOTH directions
|
||||
// this ensures every cell is "checked" (solvable through crossings)
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
if !grid.get(r, c).is_white() { continue; }
|
||||
if across_len[r][c] < min_len || down_len[r][c] < min_len {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// also reject any run of 2..min_len (too short to be a word but not a single crossing cell)
|
||||
for r in 0..h {
|
||||
let mut run = 0;
|
||||
for c in 0..w {
|
||||
if grid.get(r, c).is_white() {
|
||||
run += 1;
|
||||
} else {
|
||||
if run > 0 && run < min_len {
|
||||
return false;
|
||||
}
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
if run > 0 && run < min_len {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for c in 0..w {
|
||||
let mut run = 0;
|
||||
for r in 0..h {
|
||||
if grid.get(r, c).is_white() {
|
||||
run += 1;
|
||||
} else {
|
||||
if run > 0 && run < min_len {
|
||||
return false;
|
||||
}
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
if run > 0 && run < min_len {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_connected(grid: &Grid) -> bool {
|
||||
// BFS from the first white cell - all white cells must be reachable
|
||||
let mut start = None;
|
||||
let mut white_count = 0;
|
||||
|
||||
for r in 0..grid.height {
|
||||
for c in 0..grid.width {
|
||||
if grid.get(r, c).is_white() {
|
||||
white_count += 1;
|
||||
if start.is_none() {
|
||||
start = Some((r, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((sr, sc)) = start else {
|
||||
return true; // no white cells, trivially connected
|
||||
};
|
||||
|
||||
if white_count == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut visited = vec![vec![false; grid.width]; grid.height];
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((sr, sc));
|
||||
visited[sr][sc] = true;
|
||||
let mut reached = 1;
|
||||
|
||||
while let Some((r, c)) = queue.pop_front() {
|
||||
for (dr, dc) in [(-1i32, 0), (1, 0), (0, -1i32), (0, 1)] {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = c as i32 + dc;
|
||||
if nr < 0 || nc < 0 {
|
||||
continue;
|
||||
}
|
||||
let nr = nr as usize;
|
||||
let nc = nc as usize;
|
||||
if nr >= grid.height || nc >= grid.width {
|
||||
continue;
|
||||
}
|
||||
if !visited[nr][nc] && grid.get(nr, nc).is_white() {
|
||||
visited[nr][nc] = true;
|
||||
reached += 1;
|
||||
queue.push_back((nr, nc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reached == white_count
|
||||
}
|
||||
|
||||
fn shuffle<T>(v: &mut [T], rng: &mut StdRng) {
|
||||
for i in (1..v.len()).rev() {
|
||||
let j = rng.random_range(0..=i);
|
||||
v.swap(i, j);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{GenerationConfig, SymmetryType};
|
||||
|
||||
#[test]
|
||||
fn generates_5x5() {
|
||||
let config = GenerationConfig {
|
||||
width: 5,
|
||||
height: 5,
|
||||
seed: Some(42),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
assert_eq!(grid.width, 5);
|
||||
assert_eq!(grid.height, 5);
|
||||
assert!(is_connected(&grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_9x9() {
|
||||
let config = GenerationConfig {
|
||||
width: 9,
|
||||
height: 9,
|
||||
seed: Some(123),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
assert!(is_connected(&grid));
|
||||
assert!(has_valid_words(&grid, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_15x15() {
|
||||
let config = GenerationConfig {
|
||||
width: 15,
|
||||
height: 15,
|
||||
seed: Some(999),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
assert!(is_connected(&grid));
|
||||
let slots = extract_slots(&grid, 3);
|
||||
assert!(!slots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_symmetry_180() {
|
||||
let config = GenerationConfig {
|
||||
width: 7,
|
||||
height: 7,
|
||||
symmetry: SymmetryType::Rotational180,
|
||||
seed: Some(77),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
for r in 0..7 {
|
||||
for c in 0..7 {
|
||||
assert_eq!(
|
||||
grid.get(r, c).is_black(),
|
||||
grid.get(6 - r, 6 - c).is_black(),
|
||||
"symmetry violated at ({}, {})",
|
||||
r,
|
||||
c
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_mirror_symmetry() {
|
||||
let config = GenerationConfig {
|
||||
width: 7,
|
||||
height: 7,
|
||||
symmetry: SymmetryType::Mirror,
|
||||
seed: Some(55),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
for r in 0..7 {
|
||||
for c in 0..7 {
|
||||
assert_eq!(
|
||||
grid.get(r, c).is_black(),
|
||||
grid.get(r, 6 - c).is_black(),
|
||||
"mirror symmetry violated at ({}, {})",
|
||||
r,
|
||||
c
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_short_words() {
|
||||
let config = GenerationConfig {
|
||||
width: 9,
|
||||
height: 9,
|
||||
min_word_length: 3,
|
||||
seed: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
assert!(has_valid_words(&grid, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connectivity() {
|
||||
let mut g = Grid::new(5, 5);
|
||||
// split grid in half with a black column
|
||||
for r in 0..5 {
|
||||
g.set(r, 2, Cell::Black);
|
||||
}
|
||||
assert!(!is_connected(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_in_range() {
|
||||
let config = GenerationConfig {
|
||||
width: 11,
|
||||
height: 11,
|
||||
black_cell_density: 0.25,
|
||||
seed: Some(300),
|
||||
..Default::default()
|
||||
};
|
||||
let grid = generate_pattern(&config).unwrap();
|
||||
let d = grid.density();
|
||||
// allow some tolerance since symmetry constraints mean we can't hit exact density
|
||||
assert!(d > 0.10 && d < 0.40, "density {} out of range", d);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user