import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; /** * SwedishGenerator.java * * Usage: * javac SwedishGenerator.java * 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); } // ---------------- CLI ---------------- static class Opts { int seed = 1; int pop = 18; int gens = 100; int tries = 50; String wordsPath = "./word-list.txt"; } static void usage() { System.out.println(""" Usage: java SwedishGenerator [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] Defaults: --seed 1 --pop 18 --gens 100 --tries 50 --words ./word-list.txt """); } static SwedishGenerator.Opts parseArgs(String[] argv) { var out = new SwedishGenerator.Opts(); for (int i = 0; i < argv.length; i++) { String a = argv[i]; String v = (i + 1 < argv.length) ? argv[i + 1] : null; if (a.equals("--help") || a.equals("-h")) { usage(); System.exit(0); } if (a.equals("--seed")) { out.seed = Integer.parseInt(v); i++; } else if (a.equals("--pop")) { out.pop = Integer.parseInt(v); i++; } else if (a.equals("--gens")) { out.gens = Integer.parseInt(v); i++; } else if (a.equals("--tries")) { out.tries = Integer.parseInt(v); i++; } else if (a.equals("--words")) { out.wordsPath = v; i++; } else throw new IllegalArgumentException("Unknown arg: " + a); } return out; } // ---------------- 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); } } 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(); } } } 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 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); } } 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']); } } CandidateInfo ci = new CandidateInfo(); if (lists.isEmpty()) { ci.indices = null; ci.count = entry.words.size(); 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))); } } 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; } 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; } } 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; } } 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++; } 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; } 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) { 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) { 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)); } 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); } pop = next; if (gen % 10 == 0) { long bestF = maskFitness(pop.get(0), lenCounts); System.out.println(" gen " + gen + "/" + gens + " bestFitness=" + bestF); } } pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts))); return pop.get(0); } // ---------------- Fill (CSP) ---------------- static final class FillStats { long nodes; long backtracks; double seconds; int lastMRV; } static final class FillResult { boolean ok; char[][] grid; HashMap clueMap; 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 (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)); 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; } } 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 ---------------- static final class PuzzleResult { char[][] mask; FillResult filled; } static SwedishGenerator.PuzzleResult generatePuzzle(SwedishGenerator.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 main(String[] args) { var opts = parseArgs(args); 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)); } }