// Server-side Word Search puzzle generator. // // A puzzle is a square letter grid with a set of theme words hidden inside it, // placed along straight lines in one of 8 directions. Difficulty controls grid // size, how many words are hidden, and which directions are allowed (harder // puzzles add diagonals and backwards placement). // // Self-contained: theme word lists live here, no dependency on the ENABLE list. const A = 'A'.charCodeAt(0); // [rowDelta, colDelta] for each direction. const DIR = { E: [0, 1], W: [0, -1], S: [1, 0], N: [-1, 0], SE: [1, 1], SW: [1, -1], NE: [-1, 1], NW: [-1, -1], }; const DIFFICULTY = { easy: { id: 'easy', size: 10, count: 8, dirs: [DIR.E, DIR.S] }, medium: { id: 'medium', size: 12, count: 10, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE] }, hard: { id: 'hard', size: 14, count: 12, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE, DIR.W, DIR.N, DIR.SW, DIR.NW] }, }; // Curated themes. Each entry: display label + uppercase A–Z words (3–9 letters). const THEMES = { space: { label: 'Space', words: ['COMET', 'PLANET', 'ROCKET', 'GALAXY', 'ORBIT', 'NEBULA', 'METEOR', 'SOLAR', 'COSMOS', 'SATURN', 'VENUS', 'PLUTO', 'GRAVITY', 'ASTEROID', 'MARS', 'MOON'], }, animals: { label: 'Animals', words: ['TIGER', 'ELEPHANT', 'GIRAFFE', 'MONKEY', 'ZEBRA', 'KANGAROO', 'PENGUIN', 'DOLPHIN', 'LEOPARD', 'PANDA', 'RABBIT', 'FALCON', 'OTTER', 'BEAVER', 'COBRA', 'WALRUS'], }, food: { label: 'Food', words: ['PIZZA', 'BURGER', 'PASTA', 'SALAD', 'CHEESE', 'TOMATO', 'BANANA', 'COOKIE', 'WAFFLE', 'PRETZEL', 'MUFFIN', 'NOODLE', 'PEPPER', 'CARROT', 'YOGURT', 'PANCAKE'], }, ocean: { label: 'Ocean', words: ['CORAL', 'SHARK', 'WHALE', 'OCTOPUS', 'DOLPHIN', 'LOBSTER', 'STARFISH', 'SEAWEED', 'JELLYFISH', 'URCHIN', 'OYSTER', 'MARLIN', 'SHRIMP', 'ANCHOR', 'LAGOON', 'CURRENT'], }, sports: { label: 'Sports', words: ['SOCCER', 'TENNIS', 'HOCKEY', 'BOXING', 'RUGBY', 'SKIING', 'CYCLING', 'ARCHERY', 'BOWLING', 'CRICKET', 'DIVING', 'FENCING', 'KARATE', 'ROWING', 'SAILING', 'SURFING'], }, music: { label: 'Music', words: ['GUITAR', 'PIANO', 'VIOLIN', 'DRUMS', 'TRUMPET', 'FLUTE', 'CELLO', 'HARP', 'BANJO', 'MELODY', 'RHYTHM', 'TEMPO', 'CHORD', 'OCTAVE', 'SONATA', 'BALLAD'], }, }; function randomFrom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function resolveDifficulty(id) { return DIFFICULTY[String(id).toLowerCase()] ?? DIFFICULTY.medium; } function resolveTheme(id) { const key = String(id).toLowerCase(); if (key === 'random' || !THEMES[key]) { const keys = Object.keys(THEMES); const pick = randomFrom(keys); return { key: pick, ...THEMES[pick] }; } return { key, ...THEMES[key] }; } // Try to place `word` into `grid` along one of `dirs`. Accepts a placement when // every target cell is empty or already holds the matching letter (crossings). // Returns { row, col, dir } on success, or null after exhausting attempts. function tryPlace(grid, word, dirs, size, attempts = 150) { for (let a = 0; a < attempts; a++) { const [dr, dc] = randomFrom(dirs); const len = word.length; // Valid start range so the whole word stays in bounds. const rowMin = dr < 0 ? (len - 1) : 0; const rowMax = dr > 0 ? (size - len) : (size - 1); const colMin = dc < 0 ? (len - 1) : 0; const colMax = dc > 0 ? (size - len) : (size - 1); if (rowMin > rowMax || colMin > colMax) continue; const row = rowMin + Math.floor(Math.random() * (rowMax - rowMin + 1)); const col = colMin + Math.floor(Math.random() * (colMax - colMin + 1)); let ok = true; for (let i = 0; i < len; i++) { const r = row + dr * i; const c = col + dc * i; const cur = grid[r][c]; if (cur !== '' && cur !== word[i]) { ok = false; break; } } if (!ok) continue; for (let i = 0; i < len; i++) { grid[row + dr * i][col + dc * i] = word[i]; } return { row, col, dir: [dr, dc] }; } return null; } // Build a single puzzle. Returns the grid as an array of row strings plus the // list of words actually placed and their placements. export function generatePuzzle({ difficulty, theme } = {}) { const cfg = resolveDifficulty(difficulty); const themeInfo = resolveTheme(theme ?? 'random'); const { size, count, dirs } = cfg; // Candidate words that fit the grid, longest first so the hard ones place // before the grid fills up. const candidates = shuffle(themeInfo.words.filter(w => w.length <= size)) .sort((x, y) => y.length - x.length); const grid = Array.from({ length: size }, () => Array(size).fill('')); const placedWords = []; const placements = []; for (const word of candidates) { if (placedWords.length >= count) break; const placed = tryPlace(grid, word, dirs, size); if (placed) { placedWords.push(word); placements.push({ word, row: placed.row, col: placed.col, dir: placed.dir }); } } // Fill the remaining empties with random letters. for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { if (grid[r][c] === '') { grid[r][c] = String.fromCharCode(A + Math.floor(Math.random() * 26)); } } } return { difficulty: cfg.id, theme: themeInfo.key, themeLabel: themeInfo.label, size, words: placedWords, placements, grid: grid.map(row => row.join('')), }; } export function listThemes() { return Object.entries(THEMES).map(([id, t]) => ({ id, label: t.label })); }