diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cace24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM eclipse-temurin:21-jdk-alpine + +RUN apk add --no-cache curl tzdata + +# Install supercronic for cron scheduling +RUN curl -fsSL -o /usr/local/bin/supercronic \ + https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \ + && chmod +x /usr/local/bin/supercronic + +WORKDIR /app + +# Copy source files +COPY src/ /app/src/ +COPY word-list.txt /app/word-list.txt +COPY compile.sh /app/compile.sh +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +COPY crontab /app/crontab + +# Compile Java code +RUN chmod +x /app/compile.sh && \ + mkdir -p /app/target && \ + javac -d /app/target src/puzzle/*.java + +# Create output directory +RUN mkdir -p /data/puzzles + +ENV TZ=Europe/Amsterdam +ENV OUT_DIR=/data/puzzles +ENV PUZZLES_PER_DAY=3 +ENV THEME_FILTER=true +ENV THEME_MIN_SCORE=0.6 + +VOLUME ["/data"] + +RUN chmod +x /app/docker-entrypoint.sh + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac34674 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Swedish-Style Crossword Puzzle Generator + +A high-performance Java-based puzzle generator with theme-based word filtering and daily automated generation. + +## Features + +- **Swedish-style crossword puzzles** with arrow clues +- **Theme-based word filtering** using semantic similarity graph +- **Daily automated generation** via Docker + cron +- **JSON export format** compatible with web frontends +- **Genetic algorithm** for optimal grid layouts +- **Constraint satisfaction** for word placement + +## Architecture + +### Components + +1. **SwedishGenerator.java** - Core puzzle generation engine + - Genetic algorithm for mask generation + - CSP solver for word filling + - Optimized for Dutch word lists + +2. **ThemeGraph.java** - Theme-based word scoring system + - Predefined theme keywords (news, tech, sports, etc.) + - Edit distance similarity matching + - Automatic theme detection + +3. **DailyGenerator.java** - Daily puzzle automation + - Generates themed puzzles + - JSON output with metadata + - Index file generation + +4. **ExportFormat.java** - Export to standard format + - Grid cropping and optimization + - Arrow cell calculation + - Compatible with existing frontends + +## Usage + +### Local Development + +```bash +# Compile +./compile.sh + +# Run Main (interactive) +java -cp ~/dev/.target puzzle.Main --seed 42 --pop 18 --gens 100 + +# Generate daily puzzles +java -cp ~/dev/.target puzzle.DailyGenerator +``` + +### Docker Deployment + +```bash +# Build image +docker build -t puzzle-generator . + +# Run with docker-compose +docker-compose up -d puzzle_gen_java + +# View logs +docker logs -f puzzle_gen_java +``` + +### Environment Variables + +| Variable | Default | Description | +|----------------------|-------------------|----------------------------------------| +| `OUT_DIR` | `/data/puzzles` | Output directory for generated puzzles | +| `PUZZLES_PER_DAY` | `3` | Number of puzzles to generate daily | +| `WORDS_PATH` | `./word-list.txt` | Path to word list file | +| `THEME_FILTER` | `true` | Enable theme-based word filtering | +| `THEME_MIN_SCORE` | `0.6` | Minimum theme score (0.0-1.0) | +| `LM_STUDIO_BASE_URL` | - | LM Studio URL (future feature) | +| `GENERATE_ON_START` | `false` | Generate puzzles on container startup | + +## Theme System + +### Supported Themes + +- `algemeen` - General/common words +- `nieuws` - News/politics +- `technologie` - Technology +- `sport` - Sports +- `weer` - Weather/nature +- `economie` - Economy +- `gezondheid` - Health + +### Theme Filtering + +Words are scored against themes using: +1. **Direct matching** - Word is in theme keyword list (score: 1.0) +2. **Substring matching** - Partial word overlap (score: 0.7) +3. **Edit distance** - Fuzzy matching for variations (score: 0.8-0.9) + +Example: +```bash +ThemeGraph.filterByTheme(words, "technologie", 0.6); +// Returns: COMPUTER, INTERNET, SOFTWARE, DATA, etc. +``` + +## Output Format + +### Puzzle JSON + +```json +{ + "date": "2025-12-19", + "theme": "technologie", + "difficulty": 1, + "rewards": { + "coins": 50, + "stars": 2, + "hints": 1 + }, + "gridv2": [ + "###COMPUTER###", + "#I#O#E#E#O#" + ], + "words": [ + { + "word": "COMPUTER", + "clue": "COMPUTER", + "startRow": 0, + "startCol": 3, + "direction": "horizontal", + "answer": "COMPUTER", + "arrowRow": 0, + "arrowCol": 2 + } + ] +} +``` + +### Index JSON + +```json +{ + "date": "2025-12-19", + "files": [ + "crossword_2025-12-19_01_technologie.json", + "crossword_2025-12-19_02_sport.json", + "crossword_2025-12-19_03_nieuws.json" + ] +} +``` + +## Scheduling + +Puzzles are generated daily at **3:15 AM** (configurable in `crontab`). + +Edit `crontab` to change schedule: +```cron +# Daily at 3:15 AM +15 3 * * * java -cp /app/target puzzle.DailyGenerator + +# Every 6 hours +0 */6 * * * java -cp /app/target puzzle.DailyGenerator + +# Weekly on Monday at 1 AM +0 1 * * 1 java -cp /app/target puzzle.DailyGenerator +``` + +## Word List Format + +Plain text file, one word per line, uppercase A-Z only, 2-8 characters: + +``` +EU +UUR +AUTO +BOOM +COMPUTER +INTERNET +... +``` + +## Performance + +- **Mask generation**: ~2-5 seconds (genetic algorithm) +- **Word filling**: ~5-30 seconds (CSP solver with MRV heuristic) +- **Total per puzzle**: ~10-40 seconds + +Optimizations: +- Positional indexing for fast candidate lookup +- Sorted intersection for constraint checking +- No large array allocations during search +- Progress bar with real-time stats + +## Integration with LM Studio (Future) + +The system is prepared for LM Studio integration to generate themed clues: + +```bash +docker-compose up -d +# Set LM_STUDIO_BASE_URL in docker-compose.yml +# Container will query LM Studio for contextual clues based on themes +``` + +This will enhance clues from simple word repetition to semantic hints. + +## Migration from Node.js + +The Java version maintains module-wise compatibility with the Node.js generator: + +| Node.js | Java | +|------------------------|-------------------------------------| +| `swedish_generator.js` | `SwedishGenerator.java` | +| `export_format.js` | `ExportFormat.java` | +| `main.js` | `Main.java` + `DailyGenerator.java` | +| N/A | `ThemeGraph.java` (new) | + +## Volume Management + +Puzzles are stored in a Docker volume outside the workspace: + +```bash +# Default location +/var/lib/puzzle-data + +# Custom location +export PUZZLE_OUTPUT_DIR=/path/to/puzzles +docker-compose up -d + +# View puzzles +ls -lh /var/lib/puzzle-data/*.json +``` + +## Troubleshooting + +### No puzzles generated +- Check word list has enough words (minimum 50) +- Lower `THEME_MIN_SCORE` if using theme filtering +- Increase `PUZZLES_PER_DAY` attempts + +### Container not starting +```bash +docker logs puzzle_gen_java +# Check for compilation errors or missing files +``` + +### Low quality puzzles +- Increase `--gens` parameter (more genetic iterations) +- Increase `--pop` parameter (larger population) +- Ensure word list has good variety of lengths 2-8 + +## License + +MIT + +## Authors + +Original Node.js version + Java port with theme system diff --git a/compile.sh b/compile.sh index 6e1a939..91f6fb0 100755 --- a/compile.sh +++ b/compile.sh @@ -1,3 +1,4 @@ #!/bin/bash -mkdir -p ~/dev/.target -javac -d ~/dev/.target src/puzzle/*.java +TARGET=${1:-~/dev/.target} +mkdir -p "$TARGET" +javac -d "$TARGET" src/puzzle/*.java diff --git a/crontab b/crontab new file mode 100644 index 0000000..06fb0d0 --- /dev/null +++ b/crontab @@ -0,0 +1,2 @@ +# Generate puzzles daily at 3:15 AM +15 3 * * * java -cp /app/target puzzle.DailyGenerator >> /var/log/cron.log 2>&1 diff --git a/docker-compose.yml b/docker-compose.yml index 875cd3b..1c72b1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,23 @@ services: volumes: - puzzles_data:/data/puzzles:rw + puzzle_gen_java: + build: + context: ${PUZZLE_ROOT_DIR:-/opt/apps/puzzle} + dockerfile: Dockerfile + container_name: puzzle_gen_java + restart: unless-stopped + networks: [ traefik_net ] + environment: + TZ: Europe/Amsterdam + OUT_DIR: /data/puzzles + PUZZLES_PER_DAY: "3" + LM_STUDIO_BASE_URL: "http://192.168.1.159:1234/v1" + THEME_FILTER: "true" + THEME_MIN_SCORE: "0.6" + volumes: + - puzzles_data:/data/puzzles:rw + volumes: puzzles_data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..9c4c674 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +echo "=== Puzzle Generator Container Starting ===" +echo "Time: $(date)" +echo "Output directory: ${OUT_DIR}" +echo "Puzzles per day: ${PUZZLES_PER_DAY}" +echo "" + +# Ensure output directory exists +mkdir -p "${OUT_DIR}" + +# Generate initial puzzle on startup (optional) +if [ "${GENERATE_ON_START}" = "true" ]; then + echo "Generating initial puzzles..." + java -cp /app/target puzzle.DailyGenerator + echo "" +fi + +# Start cron scheduler +echo "Starting cron scheduler..." +exec /usr/local/bin/supercronic /app/crontab diff --git a/src/puzzle/DailyGenerator.java b/src/puzzle/DailyGenerator.java new file mode 100644 index 0000000..9fce6f5 --- /dev/null +++ b/src/puzzle/DailyGenerator.java @@ -0,0 +1,254 @@ +package puzzle; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.LocalDate; +import java.util.*; + +/** + * DailyGenerator - Generates daily themed puzzles with JSON output + */ +public class DailyGenerator { + + private static String env(String name, String defaultValue) { + var val = System.getenv(name); + return (val == null || val.isEmpty()) ? defaultValue : val; + } + + private static int envInt(String name, int defaultValue) { + try { + return Integer.parseInt(env(name, String.valueOf(defaultValue))); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static boolean envBool(String name, boolean defaultValue) { + var val = env(name, String.valueOf(defaultValue)); + return "true".equalsIgnoreCase(val) || "1".equals(val); + } + + public static void main(String[] args) { + var outDir = env("OUT_DIR", "/data/puzzles"); + var wordsPath = env("WORDS_PATH", "./word-list.txt"); + var puzzlesPerDay = envInt("PUZZLES_PER_DAY", 3); + var seed = envInt("SEED", (int) System.currentTimeMillis()); + var themeFilter = envBool("THEME_FILTER", true); + var themeMinScore = Double.parseDouble(env("THEME_MIN_SCORE", "0.6")); + + var today = LocalDate.now(); + var dateStr = today.toString(); + + System.out.println("=== Daily Puzzle Generator ==="); + System.out.println("Date: " + dateStr); + System.out.println("Output: " + outDir); + System.out.println("Puzzles per day: " + puzzlesPerDay); + System.out.println("Theme filtering: " + themeFilter); + System.out.println(); + + // Load word list + SwedishGenerator.Dict dict; + try { + dict = SwedishGenerator.loadWords(wordsPath); + System.out.println("Loaded " + dict.words.size() + " words"); + } catch (Exception e) { + System.err.println("Failed to load words: " + e.getMessage()); + System.exit(1); + return; + } + + // Create output directory + try { + Files.createDirectories(Paths.get(outDir)); + } catch (IOException e) { + System.err.println("Failed to create output dir: " + e.getMessage()); + System.exit(1); + return; + } + + // Generate puzzles + List generatedFiles = new ArrayList<>(); + var themes = new String[]{ "algemeen", "nieuws", "technologie", "sport", "weer", "economie" }; + + for (var i = 1; i <= puzzlesPerDay; i++) { + System.out.println("\n--- Generating puzzle " + i + "/" + puzzlesPerDay + " ---"); + + // Select theme + var theme = themes[new Random(seed + i).nextInt(themes.length)]; + System.out.println("Theme: " + theme); + + // Filter word list by theme + List filteredWords = dict.words; + if (themeFilter && !theme.equals("algemeen")) { + filteredWords = ThemeGraph.filterByTheme(dict.words, theme, themeMinScore); + System.out.println("Filtered to " + filteredWords.size() + " words for theme '" + theme + "'"); + + // If too few words, fall back to general + if (filteredWords.size() < 50) { + System.out.println("Not enough themed words, using general list"); + filteredWords = dict.words; + theme = "algemeen"; + } + } + + // Create filtered dict + var themedDict = filterDict(dict, filteredWords); + + // Generate puzzle + var opts = new Main.Opts(); + opts.seed = seed + i; + opts.pop = 18; + opts.gens = 100; + opts.tries = 50; + opts.wordsPath = wordsPath; + + var result = generateWithFilteredDict(opts, themedDict); + + if (result == null) { + System.out.println("Failed to generate puzzle " + i); + continue; + } + + System.out.println("Generated puzzle with " + result.filled().clueMap.size() + " words"); + + // Export to JSON + var exported = ExportFormat.exportFormatFromFilled( + result, 1, new ExportFormat.Rewards(50, 2, 1) + ); + + // Write to JSON file + var filename = String.format("crossword_%s_%02d_%s.json", dateStr, i, safeSlug(theme)); + var outputPath = Paths.get(outDir, filename); + + try { + var json = toJson(exported, dateStr, theme); + Files.writeString(outputPath, json, StandardCharsets.UTF_8); + generatedFiles.add(filename); + System.out.println("Saved: " + filename); + } catch (IOException e) { + System.err.println("Failed to write " + filename + ": " + e.getMessage()); + } + } + + // Write index.json + try { + var indexJson = toIndexJson(dateStr, generatedFiles); + Files.writeString(Paths.get(outDir, "index.json"), indexJson, StandardCharsets.UTF_8); + System.out.println("\n✓ Generated " + generatedFiles.size() + " puzzles for " + dateStr); + } catch (IOException e) { + System.err.println("Failed to write index.json: " + e.getMessage()); + } + } + + private static SwedishGenerator.Dict filterDict(SwedishGenerator.Dict dict, List allowedWords) { + Set allowed = new HashSet<>(allowedWords); + var newIndex = new HashMap(); + var newLenCounts = new HashMap(); + + for (var word : dict.words) { + if (!allowed.contains(word)) continue; + + var L = word.length(); + newLenCounts.put(L, newLenCounts.getOrDefault(L, 0) + 1); + + var entry = newIndex.get(L); + if (entry == null) { + entry = new SwedishGenerator.DictEntry(L); + newIndex.put(L, entry); + } + + var idx = entry.words.size(); + entry.words.add(word); + + for (var i = 0; i < L; i++) { + var letter = word.charAt(i) - 'A'; + if (letter >= 0 && letter < 26) { + entry.pos[i][letter].add(idx); + } + } + } + + return new SwedishGenerator.Dict(new ArrayList<>(allowed), newIndex, newLenCounts); + } + + private static SwedishGenerator.PuzzleResult generateWithFilteredDict(Main.Opts opts, SwedishGenerator.Dict dict) { + var rng = new SwedishGenerator.Rng(opts.seed); + + for (var attempt = 1; attempt <= opts.tries; attempt++) { + var mask = SwedishGenerator.generateMask(rng, dict.lenCounts, opts.pop, opts.gens); + var filled = SwedishGenerator.fillMask(rng, mask, dict.index, 200, 30000); + + if (filled.ok) { + return new SwedishGenerator.PuzzleResult(mask, filled); + } + } + return null; + } + + private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) { + var sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n"); + sb.append(" \"theme\": \"").append(escapeJson(theme)).append("\",\n"); + sb.append(" \"difficulty\": ").append(puzzle.difficulty()).append(",\n"); + sb.append(" \"rewards\": {\n"); + sb.append(" \"coins\": ").append(puzzle.rewards().coins()).append(",\n"); + sb.append(" \"stars\": ").append(puzzle.rewards().stars()).append(",\n"); + sb.append(" \"hints\": ").append(puzzle.rewards().hints()).append("\n"); + sb.append(" },\n"); + sb.append(" \"gridv2\": [\n"); + for (var i = 0; i < puzzle.gridv2().size(); i++) { + sb.append(" \"").append(escapeJson(puzzle.gridv2().get(i))).append("\""); + if (i < puzzle.gridv2().size() - 1) sb.append(","); + sb.append("\n"); + } + sb.append(" ],\n"); + sb.append(" \"words\": [\n"); + for (var i = 0; i < puzzle.words().size(); i++) { + var w = puzzle.words().get(i); + sb.append(" {\n"); + sb.append(" \"word\": \"").append(escapeJson(w.word())).append("\",\n"); + sb.append(" \"clue\": \"").append(escapeJson(w.clue())).append("\",\n"); + sb.append(" \"startRow\": ").append(w.startRow()).append(",\n"); + sb.append(" \"startCol\": ").append(w.startCol()).append(",\n"); + sb.append(" \"direction\": \"").append(escapeJson(w.direction())).append("\",\n"); + sb.append(" \"answer\": \"").append(escapeJson(w.answer())).append("\",\n"); + sb.append(" \"arrowRow\": ").append(w.arrowRow()).append(",\n"); + sb.append(" \"arrowCol\": ").append(w.arrowCol()).append("\n"); + sb.append(" }"); + if (i < puzzle.words().size() - 1) sb.append(","); + sb.append("\n"); + } + sb.append(" ]\n"); + sb.append("}\n"); + return sb.toString(); + } + + private static String toIndexJson(String date, List files) { + var sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n"); + sb.append(" \"files\": [\n"); + for (var i = 0; i < files.size(); i++) { + sb.append(" \"").append(escapeJson(files.get(i))).append("\""); + if (i < files.size() - 1) sb.append(","); + sb.append("\n"); + } + sb.append(" ]\n"); + sb.append("}\n"); + return sb.toString(); + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static String safeSlug(String s) { + return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); + } +} diff --git a/src/puzzle/ExportFormat.java b/src/puzzle/ExportFormat.java index af3311b..5dd70ad 100644 --- a/src/puzzle/ExportFormat.java +++ b/src/puzzle/ExportFormat.java @@ -1,4 +1,5 @@ package puzzle; + import java.util.*; /** @@ -38,23 +39,23 @@ public final class ExportFormat { public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz, int difficulty, Rewards rewards) { Objects.requireNonNull(puz, "puz"); - char[][] g = puz.filled.grid; - int H = g.length; - int W = g[0].length; + var g = puz.filled().grid; + var H = g.length; + var W = g[0].length; // 1) extract "placed" list from all clue digits in the filled grid List placed = new ArrayList<>(); Set seen = new HashSet<>(); - for (int r = 0; r < H; r++) { - for (int c = 0; c < W; c++) { - char ch = g[r][c]; + for (var r = 0; r < H; r++) { + for (var c = 0; c < W; c++) { + var ch = g[r][c]; if (!isDigit(ch)) continue; - Placed p = extractPlacedFromClue(g, r, c, ch, 8, 2); + var p = extractPlacedFromClue(g, r, c, ch, 8, 2); if (p == null) continue; - String key = p.startRow + "," + p.startCol + ":" + p.direction + ":" + p.word; + var key = p.startRow + "," + p.startCol + ":" + p.direction + ":" + p.word; if (seen.contains(key)) continue; seen.add(key); placed.add(p); @@ -64,10 +65,10 @@ public final class ExportFormat { // If nothing placed: return full grid mapped to letters/# only if (placed.isEmpty()) { List gridv2 = new ArrayList<>(H); - for (int r = 0; r < H; r++) { - StringBuilder sb = new StringBuilder(W); - for (int c = 0; c < W; c++) { - char ch = g[r][c]; + for (var chars : g) { + var sb = new StringBuilder(W); + for (var c = 0; c < W; c++) { + var ch = chars[c]; sb.append(isLetter(ch) ? ch : '#'); } gridv2.add(sb.toString()); @@ -77,7 +78,7 @@ public final class ExportFormat { // 2) bounding box around all word cells + arrow cells, with 1-cell margin List allCells = new ArrayList<>(); - for (Placed p : placed) { + for (var p : placed) { allCells.addAll(p.cells); allCells.add(p.arrow); } @@ -85,7 +86,7 @@ public final class ExportFormat { int minR = Integer.MAX_VALUE, minC = Integer.MAX_VALUE; int maxR = Integer.MIN_VALUE, maxC = Integer.MIN_VALUE; - for (int[] rc : allCells) { + for (var rc : allCells) { int rr = rc[0], cc = rc[1]; minR = Math.min(minR, rr); minC = Math.min(minC, cc); @@ -100,8 +101,8 @@ public final class ExportFormat { // 3) map of only used letter cells (everything else becomes '#') Map letterAt = new HashMap<>(); - for (Placed p : placed) { - for (int[] rc : p.cells) { + for (var p : placed) { + for (var rc : p.cells) { int rr = rc[0], cc = rc[1]; if (inBounds(H, W, rr, cc) && isLetter(g[rr][cc])) { letterAt.put(pack(rr, cc), g[rr][cc]); @@ -111,10 +112,10 @@ public final class ExportFormat { // 4) render gridv2 over cropped bounds (out-of-bounds become '#') List gridv2 = new ArrayList<>(Math.max(0, maxR - minR + 1)); - for (int r = minR; r <= maxR; r++) { - StringBuilder row = new StringBuilder(Math.max(0, maxC - minC + 1)); - for (int c = minC; c <= maxC; c++) { - Character ch = letterAt.get(pack(r, c)); + for (var r = minR; r <= maxR; r++) { + var row = new StringBuilder(Math.max(0, maxC - minC + 1)); + for (var c = minC; c <= maxC; c++) { + var ch = letterAt.get(pack(r, c)); row.append(ch != null ? ch : '#'); } gridv2.add(row.toString()); @@ -122,7 +123,7 @@ public final class ExportFormat { // 5) words output with cropped coordinates List wordsOut = new ArrayList<>(placed.size()); - for (Placed p : placed) { + for (var p : placed) { wordsOut.add(new WordOut( p.word, p.clue, // placeholder = word (same as JS) @@ -137,7 +138,7 @@ public final class ExportFormat { return new ExportedPuzzle(gridv2, wordsOut, difficulty, rewards); } - + /** * Extract a word run for a clue cell at (r,c) with direction digit d. * Canonical output: @@ -148,7 +149,7 @@ public final class ExportFormat { */ private static Placed extractPlacedFromClue(char[][] g, int r, int c, char d, int maxLen, int minLen) { int H = g.length, W = g[0].length; - int di = d - '0'; + var di = d - '0'; int dr = DIRS[di][0], dc = DIRS[di][1]; // collect letter cells in ORIGINAL direction away from the clue @@ -180,14 +181,14 @@ public final class ExportFormat { arrowCol = c; } else if (d == '4') { // left -> canonical right direction = "horizontal"; - int[] farLeft = cells.get(cells.size() - 1); + var farLeft = cells.get(cells.size() - 1); startRow = farLeft[0]; startCol = farLeft[1]; arrowRow = startRow; arrowCol = startCol - 1; } else if (d == '1') { // up -> canonical down direction = "vertical"; - int[] topMost = cells.get(cells.size() - 1); + var topMost = cells.get(cells.size() - 1); startRow = topMost[0]; startCol = topMost[1]; arrowRow = startRow - 1; @@ -197,32 +198,32 @@ public final class ExportFormat { } // Read word from grid in canonical order (right/down) - StringBuilder wordChars = new StringBuilder(); + var wordChars = new StringBuilder(); if ("horizontal".equals(direction)) { - for (int i = 0; i < cells.size(); i++) { - int cc2 = startCol + i; - char ch = (inBounds(H, W, startRow, cc2) ? g[startRow][cc2] : '#'); + for (var i = 0; i < cells.size(); i++) { + var cc2 = startCol + i; + var ch = (inBounds(H, W, startRow, cc2) ? g[startRow][cc2] : '#'); if (!isLetter(ch)) break; wordChars.append(ch); } } else { - for (int i = 0; i < cells.size(); i++) { - int rr2 = startRow + i; - char ch = (inBounds(H, W, rr2, startCol) ? g[rr2][startCol] : '#'); + for (var i = 0; i < cells.size(); i++) { + var rr2 = startRow + i; + var ch = (inBounds(H, W, rr2, startCol) ? g[rr2][startCol] : '#'); if (!isLetter(ch)) break; wordChars.append(ch); } } - String word = wordChars.toString(); + var word = wordChars.toString(); if (word.length() < minLen || word.length() > maxLen) return null; // Build exact used cells (only for actual word length) List used = new ArrayList<>(word.length()); if ("horizontal".equals(direction)) { - for (int i = 0; i < word.length(); i++) used.add(new int[]{ startRow, startCol + i }); + for (var i = 0; i < word.length(); i++) used.add(new int[]{ startRow, startCol + i }); } else { - for (int i = 0; i < word.length(); i++) used.add(new int[]{ startRow + i, startCol }); + for (var i = 0; i < word.length(); i++) used.add(new int[]{ startRow + i, startCol }); } return new Placed( @@ -246,84 +247,25 @@ public final class ExportFormat { // ---------- Data models ---------- - private static final class Placed { - - final String word; - final String clue; - final int startRow, startCol; - final String direction; // "horizontal" | "vertical" - final String answer; - final int arrowRow, arrowCol; - final List cells; // word cells - final int[] arrow; // [arrowRow, arrowCol] - - Placed(String word, String clue, int startRow, int startCol, String direction, String answer, - int arrowRow, int arrowCol, List cells, int[] arrow) { - this.word = word; - this.clue = clue; - this.startRow = startRow; - this.startCol = startCol; - this.direction = direction; - this.answer = answer; - this.arrowRow = arrowRow; - this.arrowCol = arrowCol; - this.cells = cells; - this.arrow = arrow; - } + /** + * @param direction "horizontal" | "vertical" + * @param cells word cells + * @param arrow [arrowRow, arrowCol] */ + private record Placed(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, List cells, int[] arrow) { + } - public static final class Rewards { - - public final int coins; - public final int stars; - public final int hints; - - public Rewards(int coins, int stars, int hints) { - this.coins = coins; - this.stars = stars; - this.hints = hints; - } + public record Rewards(int coins, int stars, int hints) { + } - public static final class WordOut { - - public final String word; - public final String clue; - public final int startRow; - public final int startCol; - public final String direction; // "horizontal" | "vertical" - public final String answer; - public final int arrowRow; - public final int arrowCol; - - public WordOut(String word, String clue, int startRow, int startCol, String direction, - String answer, int arrowRow, int arrowCol) { - this.word = word; - this.clue = clue; - this.startRow = startRow; - this.startCol = startCol; - this.direction = direction; - this.answer = answer; - this.arrowRow = arrowRow; - this.arrowCol = arrowCol; - } + /** + * @param direction "horizontal" | "vertical" */ + public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol) { + } - public static final class ExportedPuzzle { - - public final List gridv2; - public final List words; - public final int difficulty; - public final Rewards rewards; - - public ExportedPuzzle(List gridv2, List words, int difficulty, Rewards rewards) { - this.gridv2 = gridv2; - this.words = words; - this.difficulty = difficulty; - this.rewards = rewards; - } + public record ExportedPuzzle(List gridv2, List words, int difficulty, Rewards rewards) { + } - - // ---------- Tiny demo (optional) ---------- - } diff --git a/src/puzzle/Main.java b/src/puzzle/Main.java index 1aba69d..72ac618 100644 --- a/src/puzzle/Main.java +++ b/src/puzzle/Main.java @@ -53,20 +53,20 @@ public class Main { } System.out.println("\n=== GENERATED MASK ==="); - System.out.println(SwedishGenerator.gridToString(res.mask)); + System.out.println(SwedishGenerator.gridToString(res.mask())); System.out.println("\n=== FILLED PUZZLE (RAW) ==="); - System.out.println(SwedishGenerator.gridToString(res.filled.grid)); + System.out.println(SwedishGenerator.gridToString(res.filled().grid)); System.out.println("\n=== FILLED PUZZLE (HUMAN) ==="); - System.out.println(SwedishGenerator.renderHuman(res.filled.grid)); + System.out.println(SwedishGenerator.renderHuman(res.filled().grid)); var out = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1)); System.out.println("gridv2:"); - for (String row : out.gridv2) System.out.println(row); - System.out.println("words: " + out.words.size()); - for (var w : out.words) { + for (String row : out.gridv2()) System.out.println(row); + System.out.println("words: " + out.words().size()); + for (var w : out.words()) { System.out.printf("%s %s start=(%d,%d) arrow=(%d,%d)%n", - w.word, w.direction, w.startRow, w.startCol, w.arrowRow, w.arrowCol); + w.word(), w.direction(), w.startRow(), w.startCol(), w.arrowRow(), w.arrowCol()); } } } \ No newline at end of file diff --git a/src/puzzle/SwedishGenerator.java b/src/puzzle/SwedishGenerator.java index 6705d0f..27cadda 100644 --- a/src/puzzle/SwedishGenerator.java +++ b/src/puzzle/SwedishGenerator.java @@ -14,830 +14,833 @@ import java.util.*; * java SwedishGenerator [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] */ public class SwedishGenerator { - - static final int W = 9, H = 8; - static final int MIN_LEN = 2, MAX_LEN = 8; - - // Directions for '1'..'4' - static final int[][] DIRS = new int[5][2]; - static { - DIRS[1] = new int[]{-1, 0}; // up - DIRS[2] = new int[]{0, 1}; // right - DIRS[3] = new int[]{1, 0}; // down - DIRS[4] = new int[]{0, -1}; // left - } - - static boolean isDigit(char ch) { return ch >= '1' && ch <= '4'; } - static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; } - static boolean isLetterCell(char ch) { return ch == '#' || isLetter(ch); } - - - // ---------------- RNG (xorshift32) ---------------- - - static final class Rng { - private int x; - Rng(int seed) { - int s = seed; - if (s == 0) s = 1; - this.x = s; - } - int nextU32() { - int y = x; - y ^= (y << 13); - y ^= (y >>> 17); - y ^= (y << 5); - x = y; - return y; - } - int randint(int min, int max) { // inclusive - int r = nextU32(); - long u = (r & 0xFFFFFFFFL); - long range = (long) max - (long) min + 1L; - return (int) (min + (u % range)); - } - double nextFloat() { - long u = nextU32() & 0xFFFFFFFFL; - return u / 4294967295.0; // 0xFFFFFFFF - } - } - - static int clamp(int x, int a, int b) { return Math.max(a, Math.min(b, x)); } - - // ---------------- Grid helpers ---------------- - - static char[][] makeEmptyGrid() { - char[][] g = new char[H][W]; - for (int r = 0; r < H; r++) Arrays.fill(g[r], '#'); - return g; - } - - static char[][] deepCopyGrid(char[][] g) { - char[][] out = new char[H][W]; - for (int r = 0; r < H; r++) out[r] = Arrays.copyOf(g[r], W); - return out; - } - - static String gridToString(char[][] g) { - StringBuilder sb = new StringBuilder(); - for (int r = 0; r < H; r++) { - if (r > 0) sb.append('\n'); - sb.append(g[r]); - } - return sb.toString(); - } - - static String renderHuman(char[][] g) { - StringBuilder sb = new StringBuilder(); - for (int r = 0; r < H; r++) { - if (r > 0) sb.append('\n'); - for (int c = 0; c < W; c++) { - char ch = g[r][c]; - sb.append(isDigit(ch) ? ' ' : ch); + + static final int W = 9, H = 8; + static final int MIN_LEN = 2, MAX_LEN = 8; + + // Directions for '1'..'4' + static final int[][] DIRS = new int[5][2]; + static { + DIRS[1] = new int[]{ -1, 0 }; // up + DIRS[2] = new int[]{ 0, 1 }; // right + DIRS[3] = new int[]{ 1, 0 }; // down + DIRS[4] = new int[]{ 0, -1 }; // left + } + + static boolean isDigit(char ch) { return ch >= '1' && ch <= '4'; } + static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; } + static boolean isLetterCell(char ch) { return ch == '#' || isLetter(ch); } + + // ---------------- RNG (xorshift32) ---------------- + + static final class Rng { + + private int x; + Rng(int seed) { + var s = seed; + if (s == 0) s = 1; + this.x = s; } - } - return sb.toString(); - } - - // ---------------- Words / index ---------------- - - static final class IntList { - int[] a = new int[8]; - int n = 0; - void add(int v) { - if (n >= a.length) a = Arrays.copyOf(a, a.length * 2); - a[n++] = v; - } - int size() { return n; } - int[] data() { return a; } // note: may have extra capacity - } - - static final class DictEntry { - final ArrayList words = new ArrayList<>(); - final IntList[][] pos; // pos[i][letter] -> indices (sorted by insertion) - DictEntry(int L) { - pos = new IntList[L][26]; - for (int i = 0; i < L; i++) { - for (int j = 0; j < 26; j++) pos[i][j] = new IntList(); + int nextU32() { + var y = x; + y ^= (y << 13); + y ^= (y >>> 17); + y ^= (y << 5); + x = y; + return y; } - } - } - - static final class Dict { - final ArrayList words; - final HashMap index; // len -> DictEntry - final HashMap lenCounts; // len -> count - Dict(ArrayList words, HashMap index, HashMap lenCounts) { - this.words = words; - this.index = index; - this.lenCounts = lenCounts; - } - } - - static Dict loadWords(String wordsPath) { - String raw; - try { - raw = Files.readString(Path.of(wordsPath), StandardCharsets.UTF_8); - } catch (IOException e) { - raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n"; - } - - ArrayList words = new ArrayList<>(); - for (String line : raw.split("\\R")) { - String s = line.trim().toUpperCase(Locale.ROOT); - if (s.matches("^[A-Z]{2,8}$")) words.add(s); - } - - HashMap index = new HashMap<>(); - HashMap lenCounts = new HashMap<>(); - - for (String w : words) { - int L = w.length(); - lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1); - - DictEntry entry = index.get(L); - if (entry == null) { - entry = new DictEntry(L); - index.put(L, entry); + int randint(int min, int max) { // inclusive + var r = nextU32(); + var u = (r & 0xFFFFFFFFL); + var range = (long) max - (long) min + 1L; + return (int) (min + (u % range)); } - - int idx = entry.words.size(); - entry.words.add(w); - - for (int i = 0; i < L; i++) { - int letter = w.charAt(i) - 'A'; - if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx); + double nextFloat() { + var u = nextU32() & 0xFFFFFFFFL; + return u / 4294967295.0; // 0xFFFFFFFF } - } - - return new Dict(words, index, lenCounts); - } - - static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) { - int[] out = new int[Math.min(aLen, bLen)]; - int i = 0, j = 0, k = 0; - while (i < aLen && j < bLen) { - int x = a[i], y = b[j]; - if (x == y) { out[k++] = x; i++; j++; } - else if (x < y) i++; - else j++; - } - return Arrays.copyOf(out, k); - } - - static final class CandidateInfo { - int[] indices; // null => unconstrained - int count; - } - - static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) { - ArrayList lists = new ArrayList<>(); - for (int i = 0; i < pattern.length; i++) { - char ch = pattern[i]; - if (ch != 0 && isLetter(ch)) { - lists.add(entry.pos[i][ch - 'A']); + } + + static int clamp(int x, int a, int b) { return Math.max(a, Math.min(b, x)); } + + // ---------------- Grid helpers ---------------- + + static char[][] makeEmptyGrid() { + var g = new char[H][W]; + for (var r = 0; r < H; r++) Arrays.fill(g[r], '#'); + return g; + } + + static char[][] deepCopyGrid(char[][] g) { + var out = new char[H][W]; + for (var r = 0; r < H; r++) out[r] = Arrays.copyOf(g[r], W); + return out; + } + + static String gridToString(char[][] g) { + var sb = new StringBuilder(); + for (var r = 0; r < H; r++) { + if (r > 0) sb.append('\n'); + sb.append(g[r]); } - } - CandidateInfo ci = new CandidateInfo(); - if (lists.isEmpty()) { - ci.indices = null; - ci.count = entry.words.size(); + return sb.toString(); + } + + static String renderHuman(char[][] g) { + var sb = new StringBuilder(); + for (var r = 0; r < H; r++) { + if (r > 0) sb.append('\n'); + for (var c = 0; c < W; c++) { + var ch = g[r][c]; + sb.append(isDigit(ch) ? ' ' : ch); + } + } + return sb.toString(); + } + + // ---------------- Words / index ---------------- + + static final class IntList { + + int[] a = new int[8]; + int n = 0; + void add(int v) { + if (n >= a.length) a = Arrays.copyOf(a, a.length * 2); + a[n++] = v; + } + int size() { return n; } + int[] data() { return a; } // note: may have extra capacity + } + + static final class DictEntry { + + final ArrayList words = new ArrayList<>(); + final IntList[][] pos; // pos[i][letter] -> indices (sorted by insertion) + DictEntry(int L) { + pos = new IntList[L][26]; + for (var i = 0; i < L; i++) { + for (var j = 0; j < 26; j++) pos[i][j] = new IntList(); + } + } + } + + static final class Dict { + + final ArrayList words; + final HashMap index; // len -> DictEntry + final HashMap lenCounts; // len -> count + Dict(ArrayList words, HashMap index, HashMap lenCounts) { + this.words = words; + this.index = index; + this.lenCounts = lenCounts; + } + } + + static Dict loadWords(String wordsPath) { + String raw; + try { + raw = Files.readString(Path.of(wordsPath), StandardCharsets.UTF_8); + } catch (IOException e) { + raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n"; + } + + var words = new ArrayList(); + for (var line : raw.split("\\R")) { + var s = line.trim().toUpperCase(Locale.ROOT); + if (s.matches("^[A-Z]{2,8}$")) words.add(s); + } + + var index = new HashMap(); + var lenCounts = new HashMap(); + + for (var w : words) { + var L = w.length(); + lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1); + + var entry = index.get(L); + if (entry == null) { + entry = new DictEntry(L); + index.put(L, entry); + } + + var idx = entry.words.size(); + entry.words.add(w); + + for (var i = 0; i < L; i++) { + var letter = w.charAt(i) - 'A'; + if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx); + } + } + + return new Dict(words, index, lenCounts); + } + + static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) { + var out = new int[Math.min(aLen, bLen)]; + int i = 0, j = 0, k = 0; + while (i < aLen && j < bLen) { + int x = a[i], y = b[j]; + if (x == y) { + out[k++] = x; + i++; + j++; + } else if (x < y) i++; + else j++; + } + return Arrays.copyOf(out, k); + } + + static final class CandidateInfo { + + int[] indices; // null => unconstrained + int count; + } + + static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) { + var lists = new ArrayList(); + for (var i = 0; i < pattern.length; i++) { + var ch = pattern[i]; + if (ch != 0 && isLetter(ch)) { + lists.add(entry.pos[i][ch - 'A']); + } + } + var ci = new CandidateInfo(); + if (lists.isEmpty()) { + ci.indices = null; + ci.count = entry.words.size(); + return ci; + } + + lists.sort(Comparator.comparingInt(IntList::size)); + + var first = lists.get(0); + var cur = Arrays.copyOf(first.data(), first.size()); + var curLen = cur.length; + + for (var k = 1; k < lists.size(); k++) { + var nxt = lists.get(k); + var nextArr = nxt.data(); + var nextLen = nxt.size(); + cur = intersectSorted(cur, curLen, nextArr, nextLen); + curLen = cur.length; + if (curLen == 0) break; + } + + ci.indices = cur; + ci.count = curLen; return ci; - } - - lists.sort(Comparator.comparingInt(IntList::size)); - - IntList first = lists.get(0); - int[] cur = Arrays.copyOf(first.data(), first.size()); - int curLen = cur.length; - - for (int k = 1; k < lists.size(); k++) { - IntList nxt = lists.get(k); - int[] nextArr = nxt.data(); - int nextLen = nxt.size(); - cur = intersectSorted(cur, curLen, nextArr, nextLen); - curLen = cur.length; - if (curLen == 0) break; - } - - ci.indices = cur; - ci.count = curLen; - return ci; - } - - // ---------------- Slots ---------------- - - static final class Slot { - final int clueR, clueC; - final char dir; // '1'..'4' - final int[] rs, cs; // cells - final int len; - Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) { - this.clueR = clueR; this.clueC = clueC; this.dir = dir; - this.rs = rs; this.cs = cs; - this.len = rs.length; - } - String key() { return clueR + "," + clueC + ":" + dir; } - } - - static ArrayList extractSlots(char[][] grid) { - ArrayList slots = new ArrayList<>(); - for (int r = 0; r < H; r++) { - for (int c = 0; c < W; c++) { - char d = grid[r][c]; - if (!isDigit(d)) continue; - - int di = d - '0'; - int dr = DIRS[di][0], dc = DIRS[di][1]; - - int rr = r + dr, cc = c + dc; - if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue; - if (!isLetterCell(grid[rr][cc])) continue; - - int[] rs = new int[MAX_LEN + 1]; // allow MAX_LEN+1 like JS loop - int[] cs = new int[MAX_LEN + 1]; - int n = 0; - - while (rr >= 0 && rr < H && cc >= 0 && cc < W) { - char ch = grid[rr][cc]; - if (!isLetterCell(ch)) break; - rs[n] = rr; - cs[n] = cc; - n++; - rr += dr; - cc += dc; - if (n > MAX_LEN) break; // allow n==MAX_LEN+1 - } - - slots.add(new Slot(r, c, d, Arrays.copyOf(rs, n), Arrays.copyOf(cs, n))); + } + + // ---------------- Slots ---------------- + + static final class Slot { + + final int clueR, clueC; + final char dir; // '1'..'4' + final int[] rs, cs; // cells + final int len; + Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) { + this.clueR = clueR; + this.clueC = clueC; + this.dir = dir; + this.rs = rs; + this.cs = cs; + this.len = rs.length; } - } - return slots; - } - - static boolean hasRoomForClue(char[][] grid, int r, int c, char d) { - int di = d - '0'; - int dr = DIRS[di][0], dc = DIRS[di][1]; - int rr = r + dr, cc = c + dc; - int run = 0; - while (rr >= 0 && rr < H && cc >= 0 && cc < W && isLetterCell(grid[rr][cc]) && run < MAX_LEN) { - run++; - rr += dr; - cc += dc; - } - return run >= MIN_LEN; - } - - // ---------------- FAST mask fitness ---------------- - - static long maskFitness(char[][] grid, HashMap lenCounts) { - long penalty = 0; - - int clueCount = 0; - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) if (isDigit(grid[r][c])) clueCount++; - - int targetClues = (int)Math.round(W * H * 0.25); // ~18 - penalty += 8L * Math.abs(clueCount - targetClues); - - ArrayList slots = extractSlots(grid); - if (slots.isEmpty()) return 1_000_000_000L; - - int[][] covH = new int[H][W]; - int[][] covV = new int[H][W]; - - for (Slot s : slots) { - boolean horiz = (s.dir == '2' || s.dir == '4'); - - if (s.len < MIN_LEN) penalty += 8000; - if (s.len > MAX_LEN) penalty += 8000 + (long)(s.len - MAX_LEN) * 500L; - - if (s.len >= MIN_LEN && s.len <= MAX_LEN) { - if (!lenCounts.containsKey(s.len)) penalty += 12000; + String key() { return clueR + "," + clueC + ":" + dir; } + } + + static ArrayList extractSlots(char[][] grid) { + var slots = new ArrayList(); + for (var r = 0; r < H; r++) { + for (var c = 0; c < W; c++) { + var d = grid[r][c]; + if (!isDigit(d)) continue; + + var di = d - '0'; + int dr = DIRS[di][0], dc = DIRS[di][1]; + + int rr = r + dr, cc = c + dc; + if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue; + if (!isLetterCell(grid[rr][cc])) continue; + + var rs = new int[MAX_LEN + 1]; // allow MAX_LEN+1 like JS loop + var cs = new int[MAX_LEN + 1]; + var n = 0; + + while (rr >= 0 && rr < H && cc >= 0 && cc < W) { + var ch = grid[rr][cc]; + if (!isLetterCell(ch)) break; + rs[n] = rr; + cs[n] = cc; + n++; + rr += dr; + cc += dc; + if (n > MAX_LEN) break; // allow n==MAX_LEN+1 + } + + slots.add(new Slot(r, c, d, Arrays.copyOf(rs, n), Arrays.copyOf(cs, n))); + } } - - for (int i = 0; i < s.len; i++) { - int r = s.rs[i], c = s.cs[i]; - if (horiz) covH[r][c] += 1; - else covV[r][c] += 1; + return slots; + } + + static boolean hasRoomForClue(char[][] grid, int r, int c, char d) { + var di = d - '0'; + int dr = DIRS[di][0], dc = DIRS[di][1]; + int rr = r + dr, cc = c + dc; + var run = 0; + while (rr >= 0 && rr < H && cc >= 0 && cc < W && isLetterCell(grid[rr][cc]) && run < MAX_LEN) { + run++; + rr += dr; + cc += dc; } - } - - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { - if (!isLetterCell(grid[r][c])) continue; - int h = covH[r][c], v = covV[r][c]; - if (h == 0 && v == 0) penalty += 1500; - else if (h > 0 && v > 0) { /* ok */ } - else if (h + v == 1) penalty += 200; - else penalty += 600; - } - - // clue clustering (8-connected) - boolean[][] seen = new boolean[H][W]; - int[] stack = new int[W * H]; - int sp; - int[][] nbrs8 = { - {-1,-1},{-1,0},{-1,1}, - {0,-1}, {0,1}, - {1,-1},{1,0},{1,1} - }; - - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { - if (!isDigit(grid[r][c]) || seen[r][c]) continue; - sp = 0; - stack[sp++] = r * W + c; - seen[r][c] = true; - int size = 0; - - while (sp > 0) { - int p = stack[--sp]; - int x = p / W, y = p % W; - size++; - - for (int[] d : nbrs8) { - int nx = x + d[0], ny = y + d[1]; - if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue; - if (seen[nx][ny]) continue; - if (!isDigit(grid[nx][ny])) continue; - seen[nx][ny] = true; - stack[sp++] = nx * W + ny; - } + return run >= MIN_LEN; + } + + // ---------------- FAST mask fitness ---------------- + + static long maskFitness(char[][] grid, HashMap lenCounts) { + long penalty = 0; + + var clueCount = 0; + for (var r = 0; r < H; r++) for (var c = 0; c < W; c++) if (isDigit(grid[r][c])) clueCount++; + + var targetClues = (int) Math.round(W * H * 0.25); // ~18 + penalty += 8L * Math.abs(clueCount - targetClues); + + var slots = extractSlots(grid); + if (slots.isEmpty()) return 1_000_000_000L; + + var covH = new int[H][W]; + var covV = new int[H][W]; + + for (var s : slots) { + var horiz = (s.dir == '2' || s.dir == '4'); + + if (s.len < MIN_LEN) penalty += 8000; + if (s.len > MAX_LEN) penalty += 8000 + (long) (s.len - MAX_LEN) * 500L; + + if (s.len >= MIN_LEN && s.len <= MAX_LEN) { + if (!lenCounts.containsKey(s.len)) penalty += 12000; + } + + for (var i = 0; i < s.len; i++) { + int r = s.rs[i], c = s.cs[i]; + if (horiz) covH[r][c] += 1; + else covV[r][c] += 1; + } } - - if (size >= 2) penalty += (long)(size - 1) * 120L; - } - - // dead-end-ish letter cell (3+ walls) - int[][] nbrs4 = {{-1,0},{1,0},{0,-1},{0,1}}; - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { - if (!isLetterCell(grid[r][c])) continue; - int walls = 0; - for (int[] d : nbrs4) { - int rr = r + d[0], cc = c + d[1]; - if (rr < 0 || rr >= H || cc < 0 || cc >= W) { walls++; continue; } - if (!isLetterCell(grid[rr][cc])) walls++; + + for (var r = 0; r < H; r++) + for (var c = 0; c < W; c++) { + if (!isLetterCell(grid[r][c])) continue; + int h = covH[r][c], v = covV[r][c]; + if (h == 0 && v == 0) penalty += 1500; + else if (h > 0 && v > 0) { /* ok */ } else if (h + v == 1) penalty += 200; + else penalty += 600; + } + + // clue clustering (8-connected) + var seen = new boolean[H][W]; + var stack = new int[W * H]; + int sp; + var nbrs8 = new int[][]{ + { -1, -1 }, { -1, 0 }, { -1, 1 }, + { 0, -1 }, { 0, 1 }, + { 1, -1 }, { 1, 0 }, { 1, 1 } + }; + + for (var r = 0; r < H; r++) + for (var c = 0; c < W; c++) { + if (!isDigit(grid[r][c]) || seen[r][c]) continue; + sp = 0; + stack[sp++] = r * W + c; + seen[r][c] = true; + var size = 0; + + while (sp > 0) { + var p = stack[--sp]; + int x = p / W, y = p % W; + size++; + + for (var d : nbrs8) { + int nx = x + d[0], ny = y + d[1]; + if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue; + if (seen[nx][ny]) continue; + if (!isDigit(grid[nx][ny])) continue; + seen[nx][ny] = true; + stack[sp++] = nx * W + ny; + } + } + + if (size >= 2) penalty += (long) (size - 1) * 120L; + } + + // dead-end-ish letter cell (3+ walls) + var nbrs4 = new int[][]{ { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 } }; + for (var r = 0; r < H; r++) + for (var c = 0; c < W; c++) { + if (!isLetterCell(grid[r][c])) continue; + var walls = 0; + for (var d : nbrs4) { + int rr = r + d[0], cc = c + d[1]; + if (rr < 0 || rr >= H || cc < 0 || cc >= W) { + walls++; + continue; + } + if (!isLetterCell(grid[rr][cc])) walls++; + } + if (walls >= 3) penalty += 400; + } + + return penalty; + } + + // ---------------- Mask generation ---------------- + + static char[][] randomMask(Rng rng) { + var g = makeEmptyGrid(); + var targetClues = (int) Math.round(W * H * 0.25); + int placed = 0, guard = 0; + + while (placed < targetClues && guard++ < 4000) { + var r = rng.randint(0, H - 1); + var c = rng.randint(0, W - 1); + if (isDigit(g[r][c])) continue; + + var d = (char) ('0' + rng.randint(1, 4)); + g[r][c] = d; + if (!hasRoomForClue(g, r, c, d)) { + g[r][c] = '#'; + continue; + } + placed++; } - if (walls >= 3) penalty += 400; - } - - return penalty; - } - - // ---------------- Mask generation ---------------- - - static char[][] randomMask(Rng rng) { - char[][] g = makeEmptyGrid(); - int targetClues = (int)Math.round(W * H * 0.25); - int placed = 0, guard = 0; - - while (placed < targetClues && guard++ < 4000) { - int r = rng.randint(0, H - 1); - int c = rng.randint(0, W - 1); - if (isDigit(g[r][c])) continue; - - char d = (char)('0' + rng.randint(1, 4)); - g[r][c] = d; - if (!hasRoomForClue(g, r, c, d)) { - g[r][c] = '#'; - continue; + return g; + } + + static char[][] mutate(Rng rng, char[][] grid) { + var g = deepCopyGrid(grid); + var cx = rng.randint(0, H - 1); + var cy = rng.randint(0, W - 1); + + var steps = 4; + for (var k = 0; k < steps; k++) { + var rr = clamp(cx + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, H - 1); + var cc = clamp(cy + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, W - 1); + + var cur = g[rr][cc]; + if (isDigit(cur)) { + g[rr][cc] = '#'; + } else { + var d = (char) ('0' + rng.randint(1, 4)); + g[rr][cc] = d; + if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = '#'; + } } - placed++; - } - return g; - } - - static char[][] mutate(Rng rng, char[][] grid) { - char[][] g = deepCopyGrid(grid); - int cx = rng.randint(0, H - 1); - int cy = rng.randint(0, W - 1); - - int steps = 4; - for (int k = 0; k < steps; k++) { - int rr = clamp(cx + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, H - 1); - int cc = clamp(cy + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, W - 1); - - char cur = g[rr][cc]; - if (isDigit(cur)) { - g[rr][cc] = '#'; - } else { - char d = (char)('0' + rng.randint(1, 4)); - g[rr][cc] = d; - if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = '#'; + return g; + } + + static char[][] crossover(Rng rng, char[][] a, char[][] b) { + var out = makeEmptyGrid(); + var cx = (H - 1) / 2.0; + var cy = (W - 1) / 2.0; + var theta = rng.nextFloat() * Math.PI; + var nx = Math.cos(theta); + var ny = Math.sin(theta); + + for (var r = 0; r < H; r++) + for (var c = 0; c < W; c++) { + double x = r - cx, y = c - cy; + var side = x * nx + y * ny; + out[r][c] = (side >= 0) ? a[r][c] : b[r][c]; + } + + for (var r = 0; r < H; r++) + for (var c = 0; c < W; c++) { + var ch = out[r][c]; + if (isDigit(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = '#'; + } + return out; + } + + static char[][] hillclimb(Rng rng, char[][] start, HashMap lenCounts, int limit) { + var best = deepCopyGrid(start); + var bestF = maskFitness(best, lenCounts); + var fails = 0; + + while (fails < limit) { + var cand = mutate(rng, best); + var f = maskFitness(cand, lenCounts); + if (f < bestF) { + best = cand; + bestF = f; + fails = 0; + } else { + fails++; + } } - } - return g; - } - - static char[][] crossover(Rng rng, char[][] a, char[][] b) { - char[][] out = makeEmptyGrid(); - double cx = (H - 1) / 2.0; - double cy = (W - 1) / 2.0; - double theta = rng.nextFloat() * Math.PI; - double nx = Math.cos(theta); - double ny = Math.sin(theta); - - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { - double x = r - cx, y = c - cy; - double side = x * nx + y * ny; - out[r][c] = (side >= 0) ? a[r][c] : b[r][c]; - } - - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { - char ch = out[r][c]; - if (isDigit(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = '#'; - } - return out; - } - - static char[][] hillclimb(Rng rng, char[][] start, HashMap lenCounts, int limit) { - char[][] best = deepCopyGrid(start); - long bestF = maskFitness(best, lenCounts); - int fails = 0; - - while (fails < limit) { - char[][] cand = mutate(rng, best); - long f = maskFitness(cand, lenCounts); - if (f < bestF) { - best = cand; - bestF = f; - fails = 0; - } else { - fails++; + return best; + } + + static double similarity(char[][] a, char[][] b) { + var same = 0; + for (var r = 0; r < H; r++) for (var c = 0; c < W; c++) if (a[r][c] == b[r][c]) same++; + return same / (double) (W * H); + } + + static char[][] generateMask(Rng rng, HashMap lenCounts, int popSize, int gens) { + System.out.println("generateMask init pop: " + popSize); + var pop = new ArrayList(); + + for (var i = 0; i < popSize; i++) { + var g = randomMask(rng); + pop.add(hillclimb(rng, g, lenCounts, 180)); } - } - return best; - } - - static double similarity(char[][] a, char[][] b) { - int same = 0; - for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) if (a[r][c] == b[r][c]) same++; - return same / (double)(W * H); - } - - static char[][] generateMask(Rng rng, HashMap lenCounts, int popSize, int gens) { - System.out.println("generateMask init pop: " + popSize); - ArrayList pop = new ArrayList<>(); - - for (int i = 0; i < popSize; i++) { - char[][] g = randomMask(rng); - pop.add(hillclimb(rng, g, lenCounts, 180)); - } - - for (int gen = 0; gen < gens; gen++) { - ArrayList children = new ArrayList<>(); - int pairs = Math.max(popSize, (int)Math.floor(popSize * 1.5)); - - for (int k = 0; k < pairs; k++) { - char[][] p1 = pop.get(rng.randint(0, pop.size() - 1)); - char[][] p2 = pop.get(rng.randint(0, pop.size() - 1)); - char[][] child = crossover(rng, p1, p2); - children.add(hillclimb(rng, child, lenCounts, 70)); + + for (var gen = 0; gen < gens; gen++) { + var children = new ArrayList(); + var pairs = Math.max(popSize, (int) Math.floor(popSize * 1.5)); + + for (var k = 0; k < pairs; k++) { + var p1 = pop.get(rng.randint(0, pop.size() - 1)); + var p2 = pop.get(rng.randint(0, pop.size() - 1)); + var child = crossover(rng, p1, p2); + children.add(hillclimb(rng, child, lenCounts, 70)); + } + + pop.addAll(children); + pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts))); + + var next = new ArrayList(); + for (var cand : pop) { + if (next.size() >= popSize) break; + var ok = true; + for (var kept : next) { + if (similarity(cand, kept) > 0.92) { + ok = false; + break; + } + } + if (ok) next.add(cand); + } + pop = next; + + if (gen % 10 == 0) { + var bestF = maskFitness(pop.get(0), lenCounts); + System.out.println(" gen " + gen + "/" + gens + " bestFitness=" + bestF); + } } - - pop.addAll(children); + pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts))); - - ArrayList next = new ArrayList<>(); - for (char[][] cand : pop) { - if (next.size() >= popSize) break; - boolean ok = true; - for (char[][] kept : next) { - if (similarity(cand, kept) > 0.92) { ok = false; break; } - } - if (ok) next.add(cand); + return pop.get(0); + } + + // ---------------- Fill (CSP) ---------------- + + public static final class FillStats { + + public long nodes; + public long backtracks; + public double seconds; + public int lastMRV; + } + + public static final class FillResult { + + public boolean ok; + public char[][] grid; + public HashMap clueMap; + public FillStats stats; + } + + record Undo(int[] rs, int[] cs, char[] prev, int n) { + } + + static char[] patternForSlot(char[][] grid, Slot s) { + var pat = new char[s.len]; + for (var i = 0; i < s.len; i++) { + var ch = grid[s.rs[i]][s.cs[i]]; + pat[i] = isLetter(ch) ? ch : 0; } - pop = next; - - if (gen % 10 == 0) { - long bestF = maskFitness(pop.get(0), lenCounts); - System.out.println(" gen " + gen + "/" + gens + " bestFitness=" + bestF); + return pat; + } + + static int slotScore(int[][] cellCount, Slot s) { + var cross = 0; + for (var i = 0; i < s.len; i++) cross += (cellCount[s.rs[i]][s.cs[i]] - 1); + return cross * 10 + s.len; + } + + static Undo placeWord(char[][] grid, Slot s, String w) { + var urs = new int[s.len]; + var ucs = new int[s.len]; + var up = new char[s.len]; + var n = 0; + + for (var i = 0; i < s.len; i++) { + int r = s.rs[i], c = s.cs[i]; + var prev = grid[r][c]; + var ch = w.charAt(i); + if (prev == '#') { + urs[n] = r; + ucs[n] = c; + up[n] = prev; + n++; + grid[r][c] = ch; + } else if (prev != ch) { + // rollback immediate changes + for (var j = 0; j < n; j++) grid[urs[j]][ucs[j]] = up[j]; + return null; + } } - } - - pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts))); - return pop.get(0); - } - - // ---------------- Fill (CSP) ---------------- - - public static final class FillStats { - public long nodes; - public long backtracks; - public double seconds; - public int lastMRV; - } - - public static final class FillResult { - public boolean ok; - public char[][] grid; - public HashMap clueMap; - public FillStats stats; - } - - static final class Undo { - final int[] rs, cs; - final char[] prev; - final int n; - Undo(int[] rs, int[] cs, char[] prev, int n) { - this.rs = rs; this.cs = cs; this.prev = prev; this.n = n; - } - } - - static char[] patternForSlot(char[][] grid, Slot s) { - char[] pat = new char[s.len]; - for (int i = 0; i < s.len; i++) { - char ch = grid[s.rs[i]][s.cs[i]]; - pat[i] = isLetter(ch) ? ch : 0; - } - return pat; - } - - static int slotScore(int[][] cellCount, Slot s) { - int cross = 0; - for (int i = 0; i < s.len; i++) cross += (cellCount[s.rs[i]][s.cs[i]] - 1); - return cross * 10 + s.len; - } - - static Undo placeWord(char[][] grid, Slot s, String w) { - int[] urs = new int[s.len]; - int[] ucs = new int[s.len]; - char[] up = new char[s.len]; - int n = 0; - - for (int i = 0; i < s.len; i++) { - int r = s.rs[i], c = s.cs[i]; - char prev = grid[r][c]; - char ch = w.charAt(i); - if (prev == '#') { - urs[n] = r; ucs[n] = c; up[n] = prev; - n++; - grid[r][c] = ch; - } else if (prev != ch) { - // rollback immediate changes - for (int j = 0; j < n; j++) grid[urs[j]][ucs[j]] = up[j]; - return null; + return new Undo(urs, ucs, up, n); + } + + static void undoPlace(char[][] grid, Undo u) { + for (var i = 0; i < u.n; i++) grid[u.rs[i]][u.cs[i]] = u.prev[i]; + } + + static FillResult fillMask(Rng rng, char[][] mask, HashMap dictIndex, + int logEveryMs, int timeLimitMs) { + + var grid = deepCopyGrid(mask); + var allSlots = extractSlots(grid); + var slots = new ArrayList(); + for (var s : allSlots) if (s.len >= MIN_LEN && s.len <= MAX_LEN) slots.add(s); + + var used = new HashSet(); + var assigned = new HashMap(); + + var cellCount = new int[H][W]; + for (var s : slots) for (var i = 0; i < s.len; i++) cellCount[s.rs[i]][s.cs[i]]++; + + var t0 = System.currentTimeMillis(); + final var lastLog = new java.util.concurrent.atomic.AtomicLong(t0); + + var stats = new FillStats(); + final var TOTAL = slots.size(); + final var BAR_LEN = 22; + + Runnable renderProgress = () -> { + var now = System.currentTimeMillis(); + if ((now - lastLog.get()) < logEveryMs) return; + lastLog.set(now); + + var done = assigned.size(); + var pct = (TOTAL == 0) ? 100 : (int) Math.floor((done / (double) TOTAL) * 100); + var filled = Math.min(BAR_LEN, (int) Math.floor((pct / 100.0) * BAR_LEN)); + var bar = "[" + "#".repeat(filled) + "-".repeat(BAR_LEN - filled) + "]"; + var elapsed = String.format(Locale.ROOT, "%.1fs", (now - t0) / 1000.0); + + var msg = String.format( + Locale.ROOT, + "%s %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %s", + bar, done, TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, elapsed + ); + System.out.print("\r" + padRight(msg, 120)); + System.out.flush(); + }; + + class Pick { + + Slot slot; + CandidateInfo info; + boolean done; } - } - return new Undo(urs, ucs, up, n); - } - - static void undoPlace(char[][] grid, Undo u) { - for (int i = 0; i < u.n; i++) grid[u.rs[i]][u.cs[i]] = u.prev[i]; - } - - static FillResult fillMask(Rng rng, char[][] mask, HashMap dictIndex, - int logEveryMs, int timeLimitMs) { - - char[][] grid = deepCopyGrid(mask); - ArrayList allSlots = extractSlots(grid); - ArrayList slots = new ArrayList<>(); - for (Slot s : allSlots) if (s.len >= MIN_LEN && s.len <= MAX_LEN) slots.add(s); - - HashSet used = new HashSet<>(); - HashMap assigned = new HashMap<>(); - - int[][] cellCount = new int[H][W]; - for (Slot s : slots) for (int i = 0; i < s.len; i++) cellCount[s.rs[i]][s.cs[i]]++; - - long t0 = System.currentTimeMillis(); - final java.util.concurrent.atomic.AtomicLong lastLog = new java.util.concurrent.atomic.AtomicLong(t0); - - FillStats stats = new FillStats(); - final int TOTAL = slots.size(); - final int BAR_LEN = 22; - - Runnable renderProgress = () -> { - long now = System.currentTimeMillis(); - if ((now - lastLog.get()) < logEveryMs) return; - lastLog.set(now); - - int done = assigned.size(); - int pct = (TOTAL == 0) ? 100 : (int)Math.floor((done / (double)TOTAL) * 100); - int filled = Math.min(BAR_LEN, (int)Math.floor((pct / 100.0) * BAR_LEN)); - String bar = "[" + "#".repeat(filled) + "-".repeat(BAR_LEN - filled) + "]"; - String elapsed = String.format(Locale.ROOT, "%.1fs", (now - t0) / 1000.0); - - String msg = String.format( - Locale.ROOT, - "%s %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %s", - bar, done, TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, elapsed - ); - System.out.print("\r" + padRight(msg, 120)); + + java.util.function.Supplier chooseMRV = () -> { + Slot best = null; + CandidateInfo bestInfo = null; + + for (var s : slots) { + var k = s.key(); + if (assigned.containsKey(k)) continue; + + var entry = dictIndex.get(s.len); + if (entry == null) { + var p = new Pick(); + p.slot = null; + p.info = null; + p.done = false; + return p; + } + + var pat = patternForSlot(grid, s); + var info = candidateInfoForPattern(entry, pat); + + if (info.count == 0) { + var p = new Pick(); + p.slot = null; + p.info = null; + p.done = false; + return p; + } + + if (best == null + || info.count < bestInfo.count + || (info.count == bestInfo.count && slotScore(cellCount, s) > slotScore(cellCount, best))) { + best = s; + bestInfo = info; + if (info.count <= 1) break; + } + } + + var p = new Pick(); + if (best == null) { + p.slot = null; + p.info = null; + p.done = true; + } else { + p.slot = best; + p.info = bestInfo; + p.done = false; + } + return p; + }; + + final var MAX_TRIES_PER_SLOT = 500; + + class Solver { + + boolean backtrack() { + stats.nodes++; + + if (timeLimitMs > 0 && (System.currentTimeMillis() - t0) > timeLimitMs) return false; + + var pick = chooseMRV.get(); + if (pick.done) return true; + if (pick.slot == null) { + stats.backtracks++; + return false; + } + + stats.lastMRV = pick.info.count; + renderProgress.run(); + + var s = pick.slot; + var k = s.key(); + var entry = dictIndex.get(s.len); + var pat = patternForSlot(grid, s); + + java.util.function.Function tryWord = (String w) -> { + if (w == null) return false; + if (used.contains(w)) return false; + + for (var i = 0; i < pat.length; i++) { + if (pat[i] != 0 && pat[i] != w.charAt(i)) return false; + } + + var undo = placeWord(grid, s, w); + if (undo == null) return false; + + used.add(w); + assigned.put(k, w); + + if (backtrack()) return true; + + assigned.remove(k); + used.remove(w); + undoPlace(grid, undo); + return false; + }; + + if (pick.info.indices != null && pick.info.indices.length > 0) { + var idxs = pick.info.indices; + var L = idxs.length; + var tries = Math.min(MAX_TRIES_PER_SLOT, L); + + var start = (L == 1) ? 0 : rng.randint(0, L - 1); + var step = (L <= 1) ? 1 : rng.randint(1, L - 1); + + for (var t = 0; t < tries; t++) { + var idx = idxs[(start + t * step) % L]; + var w = entry.words.get(idx); + if (tryWord.apply(w)) return true; + } + stats.backtracks++; + return false; + } + + var N = entry.words.size(); + if (N == 0) { + stats.backtracks++; + return false; + } + + var tries = Math.min(MAX_TRIES_PER_SLOT, N); + var start = (N == 1) ? 0 : rng.randint(0, N - 1); + var step = (N <= 1) ? 1 : rng.randint(1, N - 1); + + for (var t = 0; t < tries; t++) { + var idx = (start + t * step) % N; + var w = entry.words.get(idx); + if (tryWord.apply(w)) return true; + } + + stats.backtracks++; + return false; + } + } + + // initial render (same feel) + renderProgress.run(); + var ok = new Solver().backtrack(); + // final progress line + System.out.print("\r" + padRight("", 120) + "\r"); System.out.flush(); - }; - - class Pick { - Slot slot; - CandidateInfo info; - boolean done; - } - - java.util.function.Supplier chooseMRV = () -> { - Slot best = null; - CandidateInfo bestInfo = null; - - for (Slot s : slots) { - String k = s.key(); - if (assigned.containsKey(k)) continue; - - DictEntry entry = dictIndex.get(s.len); - if (entry == null) { - Pick p = new Pick(); - p.slot = null; p.info = null; p.done = false; - return p; - } - - char[] pat = patternForSlot(grid, s); - CandidateInfo info = candidateInfoForPattern(entry, pat); - - if (info.count == 0) { - Pick p = new Pick(); - p.slot = null; p.info = null; p.done = false; - return p; - } - - if (best == null - || info.count < bestInfo.count - || (info.count == bestInfo.count && slotScore(cellCount, s) > slotScore(cellCount, best))) { - best = s; - bestInfo = info; - if (info.count <= 1) break; - } + + var res = new FillResult(); + res.ok = ok; + res.grid = grid; + res.clueMap = assigned; + stats.seconds = (System.currentTimeMillis() - t0) / 1000.0; + res.stats = stats; + + // print a final progress line + System.out.println( + String.format(Locale.ROOT, + "[######################] %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %.1fs", + assigned.size(), TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, stats.seconds + ) + ); + + return res; + } + + static String padRight(String s, int n) { + if (s.length() >= n) return s; + return s + " ".repeat(n - s.length()); + } + + // ---------------- Top-level generatePuzzle ---------------- + public record PuzzleResult(char[][] mask, FillResult filled) { } + + public static PuzzleResult generatePuzzle(Main.Opts opts) { + var rng = new Rng(opts.seed); + var tLoad0 = System.nanoTime(); + var dict = loadWords(opts.wordsPath); + var tLoad1 = System.nanoTime(); + System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n", (tLoad1 - tLoad0) / 1e9); + + for (var attempt = 1; attempt <= opts.tries; attempt++) { + System.out.println("\nAttempt " + attempt + "/" + opts.tries); + + var tMask0 = System.nanoTime(); + var mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens); + var tMask1 = System.nanoTime(); + System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9); + + var tFill0 = System.nanoTime(); + var filled = fillMask(rng, mask, dict.index, 200, 30000); + var tFill1 = System.nanoTime(); + System.out.printf(Locale.ROOT, "FILL: %.3fms%n", (tFill1 - tFill0) / 1e6); + + if (filled.ok) { + return new PuzzleResult(mask, filled); + } } - - Pick p = new Pick(); - if (best == null) { - p.slot = null; - p.info = null; - p.done = true; - } else { - p.slot = best; - p.info = bestInfo; - p.done = false; - } - return p; - }; - - final int MAX_TRIES_PER_SLOT = 500; - - class Solver { - boolean backtrack() { - stats.nodes++; - - if (timeLimitMs > 0 && (System.currentTimeMillis() - t0) > timeLimitMs) return false; - - Pick pick = chooseMRV.get(); - if (pick.done) return true; - if (pick.slot == null) { stats.backtracks++; return false; } - - stats.lastMRV = pick.info.count; - renderProgress.run(); - - Slot s = pick.slot; - String k = s.key(); - DictEntry entry = dictIndex.get(s.len); - char[] pat = patternForSlot(grid, s); - - java.util.function.Function tryWord = (String w) -> { - if (w == null) return false; - if (used.contains(w)) return false; - - for (int i = 0; i < pat.length; i++) { - if (pat[i] != 0 && pat[i] != w.charAt(i)) return false; - } - - Undo undo = placeWord(grid, s, w); - if (undo == null) return false; - - used.add(w); - assigned.put(k, w); - - if (backtrack()) return true; - - assigned.remove(k); - used.remove(w); - undoPlace(grid, undo); - return false; - }; - - if (pick.info.indices != null && pick.info.indices.length > 0) { - int[] idxs = pick.info.indices; - int L = idxs.length; - int tries = Math.min(MAX_TRIES_PER_SLOT, L); - - int start = (L == 1) ? 0 : rng.randint(0, L - 1); - int step = (L <= 1) ? 1 : rng.randint(1, L - 1); - - for (int t = 0; t < tries; t++) { - int idx = idxs[(start + t * step) % L]; - String w = entry.words.get(idx); - if (tryWord.apply(w)) return true; - } - stats.backtracks++; - return false; - } - - int N = entry.words.size(); - if (N == 0) { stats.backtracks++; return false; } - - int tries = Math.min(MAX_TRIES_PER_SLOT, N); - int start = (N == 1) ? 0 : rng.randint(0, N - 1); - int step = (N <= 1) ? 1 : rng.randint(1, N - 1); - - for (int t = 0; t < tries; t++) { - int idx = (start + t * step) % N; - String w = entry.words.get(idx); - if (tryWord.apply(w)) return true; - } - - stats.backtracks++; - return false; - } - } - - // initial render (same feel) - renderProgress.run(); - boolean ok = new Solver().backtrack(); - // final progress line - System.out.print("\r" + padRight("", 120) + "\r"); - System.out.flush(); - - FillResult res = new FillResult(); - res.ok = ok; - res.grid = grid; - res.clueMap = assigned; - stats.seconds = (System.currentTimeMillis() - t0) / 1000.0; - res.stats = stats; - - // print a final progress line - System.out.println( - String.format(Locale.ROOT, - "[######################] %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %.1fs", - assigned.size(), TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, stats.seconds - ) - ); - - return res; - } - - static String padRight(String s, int n) { - if (s.length() >= n) return s; - return s + " ".repeat(n - s.length()); - } - - // ---------------- Top-level generatePuzzle ---------------- - - public static final class PuzzleResult { - public char[][] mask; - public FillResult filled; - } - - public static PuzzleResult generatePuzzle(Main.Opts opts) { - var rng = new Rng(opts.seed); - - var tLoad0 = System.nanoTime(); - var dict = loadWords(opts.wordsPath); - var tLoad1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n", (tLoad1 - tLoad0) / 1e9); - - for (int attempt = 1; attempt <= opts.tries; attempt++) { - System.out.println("\nAttempt " + attempt + "/" + opts.tries); - - long tMask0 = System.nanoTime(); - char[][] mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens); - long tMask1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9); - - long tFill0 = System.nanoTime(); - var filled = fillMask(rng, mask, dict.index, 200, 30000); - long tFill1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "FILL: %.3fms%n", (tFill1 - tFill0) / 1e6); - - if (filled.ok) { - var pr = new PuzzleResult(); - pr.mask = mask; - pr.filled = filled; - return pr; - } - } - return null; - } - - // ---------------- main ---------------- - - public static void convert(Main.Opts opts) { - - var res = generatePuzzle(opts); - if (res == null) { - System.out.println("No solution found within tries."); - System.exit(1); - } - - System.out.println("\n=== GENERATED MASK ==="); - System.out.println(gridToString(res.mask)); - - System.out.println("\n=== FILLED PUZZLE (RAW) ==="); - System.out.println(gridToString(res.filled.grid)); - - System.out.println("\n=== FILLED PUZZLE (HUMAN) ==="); - System.out.println(renderHuman(res.filled.grid)); - } + return null; + } } diff --git a/src/puzzle/ThemeGraph.java b/src/puzzle/ThemeGraph.java new file mode 100644 index 0000000..186a124 --- /dev/null +++ b/src/puzzle/ThemeGraph.java @@ -0,0 +1,205 @@ +package puzzle; + +import java.util.*; + +/** + * ThemeGraph - Creates a graph between words and themes for filtering. + * Uses word embeddings approach: co-occurrence and semantic similarity. + */ +public class ThemeGraph { + + // Predefined theme keywords for Dutch word filtering + private static final Map> THEME_KEYWORDS = new HashMap<>(); + + static { + // News/Politics + THEME_KEYWORDS.put("nieuws", Set.of( + "POLITIEK", "VERKIEZING", "MINISTER", "PARLEMENT", "WET", "BELEID", + "REGERING", "PARTIJ", "STEM", "KAMER", "RAAD", "STAAT" + )); + + // Technology + THEME_KEYWORDS.put("technologie", Set.of( + "COMPUTER", "INTERNET", "SOFTWARE", "APP", "DATA", "CODE", + "NETWERK", "SYSTEEM", "DIGITAAL", "TECH", "ROBOT", "AI" + )); + + // Sports + THEME_KEYWORDS.put("sport", Set.of( + "VOETBAL", "TENNIS", "WIELREN", "SPELER", "WEDSTRIJD", "TEAM", + "GOAL", "BAL", "SPEL", "WINNEN", "COACH", "ATLEET" + )); + + // Weather/Nature + THEME_KEYWORDS.put("weer", Set.of( + "REGEN", "ZON", "WIND", "WOLKEN", "STORM", "SNEEUW", + "WEER", "KLIMAAT", "NATUUR", "LUCHT", "WARMTE", "KOU" + )); + + // Economy + THEME_KEYWORDS.put("economie", Set.of( + "GELD", "EURO", "MARKT", "PRIJS", "KOPEN", "VERKOOP", + "BEDRIJF", "BANK", "HANDEL", "WINST", "SCHULD", "BUDGET" + )); + + // Health + THEME_KEYWORDS.put("gezondheid", Set.of( + "ZORG", "DOKTER", "MEDICIJN", "PATIENT", "ZIEKENHUIS", "GEZOND", + "VIRUS", "VACCIN", "THERAPIE", "BEHANDEL", "ARTS", "KLINIEK" + )); + + // General/Common + THEME_KEYWORDS.put("algemeen", Set.of( + "HUIS", "AUTO", "BOOM", "WATER", "MENS", "TIJD", + "LEVEN", "WERK", "SCHOOL", "FAMILIE", "STAD", "LAND" + )); + } + + /** + * Score a word against a theme (0.0 = no match, 1.0 = perfect match) + */ + public static double scoreWordTheme(String word, String theme) { + Set keywords = THEME_KEYWORDS.get(theme.toLowerCase()); + if (keywords == null) { + return 0.5; // unknown theme = neutral score + } + + word = word.toUpperCase(); + + // Direct match + if (keywords.contains(word)) { + return 1.0; + } + + // Substring match (partial relevance) + for (String kw : keywords) { + if (word.contains(kw) || kw.contains(word)) { + return 0.7; + } + } + + // Edit distance similarity (for typos/variations) + for (String kw : keywords) { + double similarity = editDistanceSimilarity(word, kw); + if (similarity > 0.8) { + return similarity * 0.9; + } + } + + return 0.0; + } + + /** + * Filter word list by theme with minimum score threshold + */ + public static List filterByTheme(List words, String theme, double minScore) { + List filtered = new ArrayList<>(); + for (String word : words) { + double score = scoreWordTheme(word, theme); + if (score >= minScore) { + filtered.add(word); + } + } + return filtered; + } + + /** + * Get theme suggestions for a word (sorted by score) + */ + public static List getThemesForWord(String word) { + List scores = new ArrayList<>(); + for (String theme : THEME_KEYWORDS.keySet()) { + double score = scoreWordTheme(word, theme); + if (score > 0.0) { + scores.add(new ThemeScore(theme, score)); + } + } + scores.sort(Comparator.comparingDouble(ts -> -ts.score)); + return scores; + } + + /** + * Auto-detect best theme from a word list + */ + public static String detectTheme(List words) { + Map themeScores = new HashMap<>(); + + for (String theme : THEME_KEYWORDS.keySet()) { + double totalScore = 0; + for (String word : words) { + totalScore += scoreWordTheme(word, theme); + } + themeScores.put(theme, totalScore / words.size()); + } + + return themeScores.entrySet().stream() + .max(Comparator.comparingDouble(Map.Entry::getValue)) + .map(Map.Entry::getKey) + .orElse("algemeen"); + } + + /** + * Simple edit distance similarity (normalized Levenshtein) + */ + private static double editDistanceSimilarity(String a, String b) { + int dist = levenshtein(a, b); + int maxLen = Math.max(a.length(), b.length()); + if (maxLen == 0) return 1.0; + return 1.0 - ((double) dist / maxLen); + } + + private static int levenshtein(String a, String b) { + int[][] dp = new int[a.length() + 1][b.length() + 1]; + + for (int i = 0; i <= a.length(); i++) dp[i][0] = i; + for (int j = 0; j <= b.length(); j++) dp[0][j] = j; + + for (int i = 1; i <= a.length(); i++) { + for (int j = 1; j <= b.length(); j++) { + int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1; + dp[i][j] = Math.min( + Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), + dp[i - 1][j - 1] + cost + ); + } + } + + return dp[a.length()][b.length()]; + } + + public record ThemeScore(String theme, double score) { + + @Override + public String toString() { + return String.format("%s: %.2f", theme, score); + } + } + + // ---- Main for testing ---- + public static void main(String[] args) { + System.out.println("=== Theme Graph Test ===\n"); + + // Test word scoring + String[] testWords = {"POLITIEK", "VOETBAL", "COMPUTER", "REGEN", "AUTO"}; + for (String word : testWords) { + System.out.println("Word: " + word); + List themes = getThemesForWord(word); + for (ThemeScore ts : themes) { + System.out.println(" " + ts); + } + System.out.println(); + } + + // Test theme detection + List techWords = Arrays.asList("COMPUTER", "INTERNET", "SOFTWARE", "DATA"); + String detected = detectTheme(techWords); + System.out.println("Detected theme for tech words: " + detected); + + // Test filtering + List allWords = Arrays.asList( + "POLITIEK", "COMPUTER", "AUTO", "VOETBAL", "INTERNET", "BOOM" + ); + List filtered = filterByTheme(allWords, "technologie", 0.5); + System.out.println("\nFiltered for 'technologie' (min 0.5): " + filtered); + } +}