use super::Component;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WordSearchCell {
pub char: String,
pub is_solution: bool,
}
struct SimpleRng {
state: u64,
}
impl SimpleRng {
fn new(seed: u64) -> Self {
Self {
state: if seed == 0 { 0x853c49e6748fea9b } else { seed },
}
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
fn next_usize(&mut self, max: usize) -> usize {
if max == 0 {
0
} else {
(self.next_u64() % max as u64) as usize
}
}
fn random_char(&mut self) -> char {
let idx = self.next_usize(26) as u8;
(b'A' + idx) as char
}
}
#[derive(Debug, Clone, Copy)]
struct Direction {
dx: isize,
dy: isize,
}
impl Direction {
const RIGHT: Self = Self { dx: 1, dy: 0 };
const DOWN: Self = Self { dx: 0, dy: 1 };
const DOWN_RIGHT: Self = Self { dx: 1, dy: 1 };
const UP_RIGHT: Self = Self { dx: 1, dy: -1 };
const LEFT: Self = Self { dx: -1, dy: 0 };
const UP: Self = Self { dx: 0, dy: -1 };
const UP_LEFT: Self = Self { dx: -1, dy: -1 };
const DOWN_LEFT: Self = Self { dx: -1, dy: 1 };
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WordSearch {
pub title: Option<String>,
pub description: Option<String>,
pub words: Vec<String>,
pub width: usize,
pub height: usize,
pub allow_diagonal: bool,
pub allow_reverse: bool,
pub seed: Option<u64>,
pub show_solution: bool,
pub columns_word_list: usize,
pub language: String,
pub translations: Vec<Option<String>>,
}
impl Default for WordSearch {
fn default() -> Self {
Self {
title: Some("Wortsuchrätsel".into()),
description: Some("Finde alle versteckten Wörter im Buchstabengitter!".into()),
words: Vec::new(),
width: 12,
height: 12,
allow_diagonal: true,
allow_reverse: false,
seed: Some(42),
show_solution: false,
columns_word_list: 3,
language: "de".into(),
translations: Vec::new(),
}
}
}
fn word_list_label(language: &str) -> &'static str {
match language {
"en" => "Words to find",
"fr" => "Mots à trouver",
"es" => "Palabras a encontrar",
_ => "Zu suchende Wörter",
}
}
fn clean_word(word: &str) -> String {
word.to_uppercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
impl WordSearch {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> WordSearchBuilder {
WordSearchBuilder::new()
}
}
pub struct WordSearchBuilder {
inner: WordSearch,
}
impl WordSearchBuilder {
pub fn new() -> Self {
Self {
inner: WordSearch::default(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.inner.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.inner.description = Some(description.into());
self
}
pub fn words<I, S>(mut self, words: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.inner.words = words.into_iter().map(Into::into).collect();
self
}
pub fn grid_size(mut self, width: usize, height: usize) -> Self {
self.inner.width = width.max(3);
self.inner.height = height.max(3);
self
}
pub fn allow_diagonal(mut self, allow: bool) -> Self {
self.inner.allow_diagonal = allow;
self
}
pub fn allow_reverse(mut self, allow: bool) -> Self {
self.inner.allow_reverse = allow;
self
}
pub fn seed(mut self, seed: u64) -> Self {
self.inner.seed = Some(seed);
self
}
pub fn show_solution(mut self, show: bool) -> Self {
self.inner.show_solution = show;
self
}
pub fn columns_word_list(mut self, cols: usize) -> Self {
self.inner.columns_word_list = cols.max(1);
self
}
pub fn language(mut self, language: impl Into<String>) -> Self {
self.inner.language = language.into();
self
}
pub fn translations<I>(mut self, translations: I) -> Self
where
I: IntoIterator<Item = Option<String>>,
{
self.inner.translations = translations.into_iter().collect();
self
}
pub fn build(self) -> crate::Result<WordSearch> {
Ok(self.inner)
}
}
impl Component for WordSearch {
fn component_id(&self) -> &str {
"word-search"
}
fn to_data(&self) -> serde_json::Value {
let mut rng = SimpleRng::new(self.seed.unwrap_or(42));
let width = self.width;
let height = self.height;
let mut grid: Vec<Vec<Option<char>>> = vec![vec![None; width]; height];
let mut solution_cells: HashSet<(usize, usize)> = HashSet::new();
let mut clean_words: Vec<String> = self
.words
.iter()
.map(|w| {
w.to_uppercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
})
.filter(|w| !w.is_empty())
.collect();
clean_words.sort_by(|a, b| b.len().cmp(&a.len()));
let mut directions = vec![Direction::RIGHT, Direction::DOWN];
if self.allow_diagonal {
directions.push(Direction::DOWN_RIGHT);
directions.push(Direction::UP_RIGHT);
}
if self.allow_reverse {
directions.push(Direction::LEFT);
directions.push(Direction::UP);
if self.allow_diagonal {
directions.push(Direction::UP_LEFT);
directions.push(Direction::DOWN_LEFT);
}
}
let mut placed_words = Vec::new();
for word in &clean_words {
let chars: Vec<char> = word.chars().collect();
let len = chars.len();
for _ in 0..300 {
let dir = directions[rng.next_usize(directions.len())];
let start_col = rng.next_usize(width);
let start_row = rng.next_usize(height);
let end_col = start_col as isize + dir.dx * (len as isize - 1);
let end_row = start_row as isize + dir.dy * (len as isize - 1);
if end_col >= 0
&& end_col < width as isize
&& end_row >= 0
&& end_row < height as isize
{
let cell_at = |i: usize| -> (usize, usize) {
let c = start_col as isize + dir.dx * i as isize;
let r = start_row as isize + dir.dy * i as isize;
(c as usize, r as usize)
};
let mut fits = true;
for (i, &ch) in chars.iter().enumerate() {
let (c, r) = cell_at(i);
if let Some(existing) = grid[r][c] {
if existing != ch {
fits = false;
break;
}
}
}
if fits {
for (i, &ch) in chars.iter().enumerate() {
let (c, r) = cell_at(i);
grid[r][c] = Some(ch);
solution_cells.insert((c, r));
}
placed_words.push(word.clone());
break;
}
}
}
}
let rendered_grid: Vec<Vec<WordSearchCell>> = (0..height)
.map(|r| {
(0..width)
.map(|c| {
let ch = grid[r][c].unwrap_or_else(|| rng.random_char());
let is_solution = solution_cells.contains(&(c, r));
WordSearchCell {
char: ch.to_string(),
is_solution,
}
})
.collect()
})
.collect();
let display_words = if placed_words.is_empty() {
self.words.clone()
} else {
placed_words
};
let translation_by_clean_word: HashMap<String, String> = self
.words
.iter()
.zip(self.translations.iter().cloned().chain(std::iter::repeat(None)))
.filter_map(|(word, translation)| {
translation.map(|t| (clean_word(word), t))
})
.collect();
let word_entries: Vec<serde_json::Value> = display_words
.iter()
.map(|w| {
serde_json::json!({
"text": w,
"translation": translation_by_clean_word.get(&clean_word(w)),
})
})
.collect();
serde_json::json!({
"title": self.title,
"description": self.description,
"width": width,
"height": height,
"grid": rendered_grid,
"words": word_entries,
"show_solution": self.show_solution,
"columns_word_list": self.columns_word_list,
"word_list_label": word_list_label(&self.language),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wordsearch_builder_defaults() {
let ws = WordSearch::builder()
.words(vec!["RUST", "TYPST"])
.grid_size(10, 10)
.build()
.unwrap();
assert_eq!(ws.component_id(), "word-search");
assert_eq!(ws.width, 10);
assert_eq!(ws.height, 10);
assert_eq!(ws.words.len(), 2);
}
#[test]
fn test_wordsearch_grid_generation() {
let ws = WordSearch::builder()
.words(vec!["APPLE", "BANANA", "CHERRY"])
.grid_size(8, 8)
.seed(12345)
.build()
.unwrap();
let data = ws.to_data();
assert_eq!(data["width"], 8);
assert_eq!(data["height"], 8);
let grid = data["grid"].as_array().expect("grid should be array");
assert_eq!(grid.len(), 8);
assert_eq!(grid[0].as_array().unwrap().len(), 8);
let mut solution_count = 0;
for row in grid {
for cell in row.as_array().unwrap() {
if cell["is_solution"].as_bool().unwrap_or(false) {
solution_count += 1;
}
}
}
assert!(solution_count > 0);
}
#[test]
fn test_wordsearch_deterministic_seed() {
let ws1 = WordSearch::builder()
.words(vec!["ALPHA", "BETA", "GAMMA"])
.seed(999)
.build()
.unwrap();
let ws2 = WordSearch::builder()
.words(vec!["ALPHA", "BETA", "GAMMA"])
.seed(999)
.build()
.unwrap();
assert_eq!(ws1.to_data(), ws2.to_data());
}
}