530 lines
21 KiB
Java
530 lines
21 KiB
Java
package puzzle;
|
|
|
|
import module java.base;
|
|
import anno.ConstGen;
|
|
import anno.DictGen;
|
|
import anno.GenerateNeighbor;
|
|
import anno.GenerateNeighbors;
|
|
import lombok.AllArgsConstructor;
|
|
import lombok.Data;
|
|
import lombok.NoArgsConstructor;
|
|
import lombok.val;
|
|
import puzzle.SwedishGenerator.Rng;
|
|
|
|
import static puzzle.Export.*;
|
|
import static puzzle.SwedishGenerator.*;
|
|
@DictGen(
|
|
packageName = "puzzle.dict800",
|
|
className = "DictData800",
|
|
scv = "/home/mike/dev/puzzle-generator/nl_score_hints_v4.csv",
|
|
simpleMax = 800,
|
|
minLen = 2,
|
|
maxLen = 8
|
|
)
|
|
@ConstGen(C = 9, R = 8, packageName = "precomp", className = "Const9x8")
|
|
@GenerateNeighbors({
|
|
@GenerateNeighbor(C = 9, R = 8, packageName = "precomp", className = "Neighbors9x8", MIN_LEN = 2),
|
|
@GenerateNeighbor(C = 4, R = 3, packageName = "precomp", className = "Neighbors4x3", MIN_LEN = 2),
|
|
@GenerateNeighbor(C = 3, R = 4, packageName = "precomp", className = "Neighbors3x4", MIN_LEN = 2)
|
|
})
|
|
public class Main {
|
|
|
|
final static rci RCI = Masker.IT[0];
|
|
final static String OUT_DIR = envOrDefault("OUT_DIR", "/data/puzzle");
|
|
final static Path PUZZLE_DIR = Paths.get(OUT_DIR, "puzzles");
|
|
static final Path INDEX_FILE = PUZZLE_DIR.resolve("index.json");
|
|
static final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
|
static final String CREATED_AT = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
|
|
static final String FILE_ID = CREATED_AT.replace(":", "-") + "_" + System.currentTimeMillis() / 1000;
|
|
static final String FILE_NAME = FILE_ID + ".json";
|
|
static final Path OUTPUT_PATH = PUZZLE_DIR.resolve(FILE_NAME);
|
|
static final String DATE_STRING = now.toLocalDate().toString();
|
|
static final boolean VERBOSE = false;
|
|
static final AtomicLong TOTAL_NODES = new AtomicLong(0);
|
|
static final AtomicLong TOTAL_BACKTRACKS = new AtomicLong(0);
|
|
static final AtomicLong TOTAL_ATTEMPTS = new AtomicLong(0);
|
|
static final AtomicLong TOTAL_SUCCESS = new AtomicLong(0);
|
|
|
|
@Data
|
|
@AllArgsConstructor
|
|
@NoArgsConstructor
|
|
public static class Opts {
|
|
|
|
static int SSIZE = 23;
|
|
public int seed = (int) (System.nanoTime() ^ System.currentTimeMillis());
|
|
public int clueSize = SSIZE;
|
|
public int pop = SSIZE * 2;
|
|
public int offspring = SSIZE * 3;
|
|
public int gens = 600;
|
|
public double minSimplicity = 0; // 0 means no limit
|
|
public int threads = Math.max(1, Runtime.getRuntime().availableProcessors());
|
|
public int tries = threads;
|
|
public boolean reindex = false;
|
|
public boolean verbose = true;
|
|
|
|
}
|
|
|
|
void main(String[] args) {
|
|
_main(args);
|
|
}
|
|
public void _main(String[] args) {
|
|
var opts = parseArgs(args);
|
|
|
|
if (opts.reindex) {
|
|
|
|
section("Reindex");
|
|
info("OutputDir : " + OUT_DIR);
|
|
rebuildIndex();
|
|
return;
|
|
}
|
|
|
|
section("Puzzle Generator");
|
|
info("OutputDir : " + OUT_DIR);
|
|
|
|
section("Settings");
|
|
printSettings(opts);
|
|
|
|
var res = generatePuzzle(opts);
|
|
if (res == null) {
|
|
err("Search status : UNSOLVED");
|
|
err("Reason : No solution found within tries.");
|
|
System.exit(1);
|
|
return;
|
|
}
|
|
|
|
section("Mask");
|
|
System.out.print(indentLines(res.cluesGridToString()));
|
|
|
|
section("Grid (raw)");
|
|
System.out.print(indentLines(res.gridGridToString()));
|
|
|
|
section("Grid (human)");
|
|
System.out.print(indentLines(res.gridRenderHuman()));
|
|
|
|
var exported = res.exportFormatFromFilled(new Rewards(50, 2, 1), Masker.IT);
|
|
|
|
section("Clues");
|
|
info("status : generating...");
|
|
info("generatedFor : " + exported.words().length);
|
|
info("status : done");
|
|
info("simpel : " + exported.difficulty());
|
|
|
|
section("Words");
|
|
printWordsTable(exported.words());
|
|
|
|
section("Grid");
|
|
for (var row : exported.grid()) System.out.println(" " + row);
|
|
|
|
var theme = "algemeen";
|
|
section("Export");
|
|
info("file : " + OUTPUT_PATH);
|
|
|
|
try {
|
|
Files.createDirectories(PUZZLE_DIR);
|
|
var json = toJson(exported, DATE_STRING, theme);
|
|
Files.writeString(OUTPUT_PATH, json, StandardCharsets.UTF_8);
|
|
|
|
// Update index.json
|
|
var pathInIndex = "/puzzles/" + FILE_NAME;
|
|
var indexRecord = toIndexRecordJson(FILE_ID, pathInIndex, DATE_STRING, theme, exported.difficulty(), CREATED_AT);
|
|
if (1 != 1) updateIndex(PUZZLE_DIR.toString(), indexRecord);
|
|
else rebuildIndex();
|
|
info("indexUpdated : " + INDEX_FILE);
|
|
} catch (IOException e) {
|
|
err("Failed to write: " + FILE_NAME);
|
|
err("Reason : " + e.getMessage());
|
|
System.exit(2);
|
|
}
|
|
}
|
|
|
|
// ---------------- Output helpers ----------------
|
|
|
|
private static void info(String msg) { System.out.println("[INFO ] " + msg); }
|
|
private static void warn(String msg) { System.out.println("[WARN ] " + msg); }
|
|
private static void err(String msg) { System.err.println("[ERROR] " + msg); }
|
|
|
|
private static void section(String title) {
|
|
System.out.println();
|
|
System.out.println(title);
|
|
}
|
|
|
|
private static String envOrDefault(String key, String def) {
|
|
var v = System.getenv(key);
|
|
return v == null || v.isBlank() ? def : v;
|
|
}
|
|
|
|
private static void printSettings(Opts o) {
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "seed", o.seed);
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "clues", o.clueSize);
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "population", o.pop);
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "offspring", o.offspring);
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "generations", o.gens);
|
|
System.out.printf(Locale.ROOT, " %-14s: %.2f%n", "minSimplicity", o.minSimplicity);
|
|
System.out.printf(Locale.ROOT, " %-14s: %d%n", "threads", o.threads);
|
|
}
|
|
|
|
private static String fmtPoint(int r, int c) { return String.format(Locale.ROOT, "(%d,%d)", r, c); }
|
|
|
|
private static void printWordsTable(WordOut[] words) {
|
|
System.out.println(" # WORD CX DIR START ARROW CLUE");
|
|
for (int j = 0; j < words.length; j++) {
|
|
var w = words[j];
|
|
System.out.printf(
|
|
Locale.ROOT,
|
|
" %-2d %-12s %-4s %-3s %-9s %-9s %s%n",
|
|
j,
|
|
safe(w.word(), 12),
|
|
safe("" + w.complex(), 4),
|
|
safe(String.valueOf(w.direction()), 3),
|
|
fmtPoint(w.startRow(), w.startCol()),
|
|
fmtPoint(w.arrowRow(), w.arrowCol()),
|
|
Arrays.toString(w.clue()));
|
|
}
|
|
}
|
|
|
|
private static String safe(String s, int max) {
|
|
if (s == null) return "";
|
|
if (s.length() <= max) return s;
|
|
return s.substring(0, Math.max(0, max - 1)) + "…";
|
|
}
|
|
|
|
static String indentLines(String s) {
|
|
if (s == null || s.isEmpty()) return "";
|
|
var lines = s.split("\\R", -1);
|
|
var sb = new StringBuilder();
|
|
for (var line : lines) sb.append(" ").append(line).append('\n');
|
|
return sb.toString();
|
|
}
|
|
|
|
static void usage() {
|
|
System.out.printf("""
|
|
Usage:
|
|
java puzzle.Main [--seed N] [--clues N] [--pop N] [--offspring N] [--gens N] [--tries N] [--words FILE] [--min-simplicity N.N] [--threads N] [--reindex]
|
|
|
|
Defaults:
|
|
--clues 18
|
|
--pop 40
|
|
--offspring 60
|
|
--gens 500
|
|
--words nl_score_hints.csv
|
|
--min-simplicity 0 (no limit)
|
|
--threads %d
|
|
%n""", Math.max(1, Runtime.getRuntime().availableProcessors()));
|
|
}
|
|
|
|
static Opts parseArgs(String[] argv) {
|
|
var out = new Opts();
|
|
for (var i = 0; i < argv.length; i++) {
|
|
var a = argv[i];
|
|
var 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("--clues")) {
|
|
out.clueSize = Integer.parseInt(v);
|
|
i++;
|
|
} else if (a.equals("--pop")) {
|
|
out.pop = Integer.parseInt(v);
|
|
i++;
|
|
} else if (a.equals("--offspring")) {
|
|
out.offspring = 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("--min-simplicity")) {
|
|
out.minSimplicity = Double.parseDouble(v);
|
|
i++;
|
|
} else if (a.equals("--threads")) {
|
|
out.threads = Integer.parseInt(v);
|
|
i++;
|
|
} else if (a.equals("--reindex")) {
|
|
out.reindex = true;
|
|
} else {
|
|
throw new IllegalArgumentException("Unknown arg: " + a);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------- Generation ----------------
|
|
|
|
// Package-private method for testing
|
|
PuzzleResult generatePuzzle(Opts opts) {
|
|
|
|
var tLoad0 = System.nanoTime();
|
|
Dict dict = puzzle.dict800.DictData800.DICT800;//loadDict(opts.wordsPath);
|
|
var tLoad1 = System.nanoTime();
|
|
|
|
section("Load");
|
|
info(String.format(Locale.ROOT, "words : %,d", dict.length()));
|
|
info(String.format(Locale.ROOT, "loadTime : %.3f s", (tLoad1 - tLoad0) / 1e9));
|
|
|
|
section("Search");
|
|
var tSearch0 = System.currentTimeMillis();
|
|
var deadline = System.currentTimeMillis() + 40_000;
|
|
|
|
PuzzleResult resFinal = null;
|
|
if (opts.threads > 1) {
|
|
info("mode : multi-threaded (" + opts.threads + ")");
|
|
var executor = Executors.newFixedThreadPool(opts.threads);
|
|
var completionService = new ExecutorCompletionService<PuzzleResult>(executor);
|
|
int submitted = 0;
|
|
|
|
try {
|
|
// Keep at least some tasks in flight
|
|
for (int i = 0; i < opts.threads; i++) {
|
|
final int attemptIdx = ++submitted;
|
|
completionService.submit(() -> attempt(new Rng(opts.seed + attemptIdx), dict, opts));
|
|
}
|
|
|
|
while (System.currentTimeMillis() < deadline) {
|
|
var future = completionService.poll(deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
|
|
if (future == null) break;
|
|
|
|
var result = future.get();
|
|
if (result != null) {
|
|
info("status : SOLVED");
|
|
resFinal = result;
|
|
break;
|
|
}
|
|
|
|
// Submit another task if we still have time
|
|
if (System.currentTimeMillis() < deadline) {
|
|
final int attemptIdx = ++submitted;
|
|
completionService.submit(() -> attempt(new Rng(opts.seed + attemptIdx), dict, opts));
|
|
}
|
|
}
|
|
if (resFinal == null) warn("status : UNSOLVED (timeout)");
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
warn("status : INTERRUPTED");
|
|
} catch (ExecutionException e) {
|
|
warn("status : ERROR (" + e.getMessage() + ")");
|
|
} finally {
|
|
executor.shutdownNow();
|
|
try {
|
|
executor.awaitTermination(5, TimeUnit.SECONDS);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
}
|
|
|
|
} else {
|
|
info("mode : single-threaded");
|
|
var rng = new Rng(opts.seed);
|
|
int attempt = 0;
|
|
|
|
while (System.currentTimeMillis() < deadline) {
|
|
attempt++;
|
|
info("try : " + attempt + " (remaining: " + (deadline - System.currentTimeMillis()) / 1000 + "s)");
|
|
|
|
var result = attempt(rng, dict, opts);
|
|
if (result != null) {
|
|
info("status : SOLVED");
|
|
info("foundAtTry : " + attempt);
|
|
resFinal = result;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (resFinal == null) info("status : UNSOLVED (timeout)");
|
|
}
|
|
|
|
var tSearch1 = System.currentTimeMillis();
|
|
var searchTime = (tSearch1 - tSearch0) / 1000.0;
|
|
|
|
section("Performance");
|
|
info(String.format(Locale.ROOT, "totalNodes : %,d", TOTAL_NODES.get()));
|
|
info(String.format(Locale.ROOT, "totalBacks : %,d", TOTAL_BACKTRACKS.get()));
|
|
info(String.format(Locale.ROOT, "searchTime : %.2f s", searchTime));
|
|
info(String.format(Locale.ROOT, "nodes/sec : %,d", (int) (TOTAL_NODES.get() / Math.max(0.001, searchTime))));
|
|
|
|
section("Material");
|
|
info(String.format(Locale.ROOT, "attempts : %,d", TOTAL_ATTEMPTS.get()));
|
|
info(String.format(Locale.ROOT, "successRate : %.1f%%", TOTAL_ATTEMPTS.get() == 0 ? 0 : TOTAL_SUCCESS.get() * 100.0 / TOTAL_ATTEMPTS.get()));
|
|
info(String.format(Locale.ROOT, "dictWords : %,d", dict.length()));
|
|
|
|
return resFinal;
|
|
}
|
|
|
|
static PuzzleResult attempt(Rng rng, Dict dict, Opts opts) {
|
|
try {
|
|
return _attempt(rng, dict, opts);
|
|
} catch (Exception e) {
|
|
System.err.println("Failed to operate" + e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
static Clues generateNewClues(Rng rng, Opts opts) {
|
|
var masker = new Masker_Neighbors9x8(rng, new int[Masker_Neighbors9x8.STACK_SIZE], Clues.createEmpty());
|
|
return masker.generateMask(opts.clueSize, opts.pop, opts.gens, opts.offspring);
|
|
}
|
|
static PuzzleResult _attempt(Rng rng, Dict dict, Opts opts) {
|
|
val multiThreaded = Thread.currentThread().getName().contains("pool");
|
|
long t0 = System.currentTimeMillis();
|
|
TOTAL_ATTEMPTS.incrementAndGet();
|
|
val mask = generateNewClues(rng, opts);
|
|
//val mask = generateClues();
|
|
if (mask == null) return null;
|
|
|
|
val slotInfo = Masker_Neighbors9x8.slots(mask, dict.index(), dict.reversed());
|
|
var grid = Masker_Neighbors9x8.grid(slotInfo);// mask.toGrid();
|
|
var filled = fillMask(rng, slotInfo, grid.lo, grid.hi, grid.g);
|
|
|
|
if (!multiThreaded) {
|
|
System.out.print("\r" + " ".repeat(120 - "".length()) + "\r");
|
|
System.out.flush();
|
|
}
|
|
// print a final progress line
|
|
if (Main.VERBOSE && !multiThreaded) {
|
|
System.out.printf(Locale.ROOT,
|
|
"[######################] %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %.1fs%n",
|
|
Slotinfo.wordCount(0, slotInfo), slotInfo.length, filled.nodes(), filled.backtracks(), filled.lastMRV(), filled.elapsed() * 0.001
|
|
);
|
|
}
|
|
|
|
TOTAL_NODES.addAndGet(filled.nodes());
|
|
TOTAL_BACKTRACKS.addAndGet(filled.backtracks());
|
|
if (filled.ok()) {
|
|
TOTAL_SUCCESS.incrementAndGet();
|
|
}
|
|
|
|
var name = Thread.currentThread().getName();
|
|
var status = filled.ok() ? "SUCCESS" : "FAILED";
|
|
var nps = (int) (filled.nodes() / Math.max(0.001, filled.elapsed() * 0.001));
|
|
var totalTime = (System.currentTimeMillis() - t0) / 1000.0;
|
|
|
|
System.out.printf(Locale.ROOT,
|
|
"[ATTEMPT] thread=%s | status=%s | nodes=%d | backtracks=%d | nps=%d | simplicity=%s | time=%.1fs%n",
|
|
name, status, filled.nodes(), filled.backtracks(), nps, 1, totalTime
|
|
);
|
|
if (!filled.ok()) {
|
|
//System.out.println(Arrays.stream(new Clued(mask).gridToString().split("\n")).map(s -> "\"" + s + "\\n\" +").collect(Collectors.joining("\n")));
|
|
}
|
|
if (filled.ok()) {
|
|
grid.lo = ~mask.lo;
|
|
grid.hi = 0xFFL & ~mask.hi;
|
|
return new PuzzleResult(new Signa(mask), new Puzzle(grid, mask), slotInfo, filled);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------- Export (unchanged logic) ----------------
|
|
|
|
record JsonExportedPuzzle(String date, String theme, int difficulty, Rewards rewards, String[] grid, WordOut[] words) { }
|
|
private static String toJson(ExportedPuzzle puzzle, String date, String theme) {
|
|
return Meta.GSON.toJson(new JsonExportedPuzzle(date, theme, puzzle.difficulty(), puzzle.rewards(), puzzle.grid(), puzzle.words()));
|
|
}
|
|
|
|
private static String escapeJson(String s) {
|
|
return s.replace("\\", "\\\\")
|
|
.replace("\"", "\\\"")
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "\\r")
|
|
.replace("\t", "\\t");
|
|
}
|
|
|
|
private static String toIndexRecordJson(String id, String path, String date, String theme, int difficulty, String createdAt) {
|
|
return String.format(
|
|
Locale.ROOT,
|
|
"{\"id\":\"%s\",\"path\":\"%s\",\"date\":\"%s\",\"theme\":\"%s\",\"difficulty\":%d,\"createdAt\":\"%s\"}",
|
|
escapeJson(id), escapeJson(path), escapeJson(date), escapeJson(theme), difficulty, escapeJson(createdAt)
|
|
);
|
|
}
|
|
|
|
private static void updateIndex(String outDir, String newRecordJson) {
|
|
var indexPath = Paths.get(outDir, "index.json");
|
|
try {
|
|
var content = Files.exists(indexPath) ? Files.readString(indexPath, StandardCharsets.UTF_8).trim() : "";
|
|
|
|
if (content.isEmpty() || content.equals("[]")) {
|
|
content = "[\n " + newRecordJson + "\n]";
|
|
} else {
|
|
var firstBracket = content.indexOf('[');
|
|
if (firstBracket != -1) {
|
|
content = content.substring(0, firstBracket + 1) + "\n " + newRecordJson + "," + content.substring(firstBracket + 1);
|
|
} else {
|
|
content = "[\n " + newRecordJson + "\n]";
|
|
}
|
|
}
|
|
|
|
Files.writeString(indexPath, content, StandardCharsets.UTF_8);
|
|
info("indexUpdated : " + indexPath);
|
|
} catch (IOException e) {
|
|
err("Failed to update index.json: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
private void rebuildIndex() {
|
|
|
|
if (!Files.exists(PUZZLE_DIR)) {
|
|
err("Puzzles directory does not exist: " + PUZZLE_DIR);
|
|
return;
|
|
}
|
|
|
|
info("Rebuilding index from: " + PUZZLE_DIR);
|
|
|
|
var records = new ArrayList<String>();
|
|
try (var stream = Files.list(PUZZLE_DIR)) {
|
|
stream.filter(p -> p.toString().endsWith(".json") && !p.getFileName().toString().equals("index.json"))
|
|
.sorted(Comparator.comparing(Path::getFileName).reversed())
|
|
.forEach(path -> {
|
|
try {
|
|
var filename = path.getFileName().toString();
|
|
var id = filename.substring(0, filename.length() - 5);
|
|
var content = Files.readString(path, StandardCharsets.UTF_8);
|
|
|
|
var date = extractValue(content, "date");
|
|
var theme = extractValue(content, "theme");
|
|
var difficulty = 1;
|
|
try { difficulty = Integer.parseInt(extractValue(content, "difficulty")); } catch (Exception ignored) { }
|
|
|
|
var createdAt = id;
|
|
if (id.length() >= 20 && id.charAt(10) == 'T') {
|
|
var parts = id.split("_");
|
|
var dtPart = parts[0]; // 2025-12-24T04-25-06Z
|
|
if (dtPart.length() >= 19) {
|
|
createdAt = dtPart.substring(0, 13) + ":" + dtPart.substring(14, 16) + ":" + dtPart.substring(17);
|
|
}
|
|
}
|
|
|
|
var pathInIndex = "/puzzles/" + filename;
|
|
records.add(toIndexRecordJson(id, pathInIndex, date, theme, difficulty, createdAt));
|
|
} catch (IOException e) {
|
|
err("Failed to read " + path + ": " + e.getMessage());
|
|
}
|
|
});
|
|
} catch (IOException e) {
|
|
err("Failed to list puzzles: " + e.getMessage());
|
|
return;
|
|
}
|
|
|
|
var indexPath = PUZZLE_DIR.resolve("index.json");
|
|
var content = "[\n " + String.join(",\n ", records) + "\n]";
|
|
try {
|
|
Files.writeString(indexPath, content, StandardCharsets.UTF_8);
|
|
info("Successfully rebuilt index.json with " + records.size() + " records.");
|
|
} catch (IOException e) {
|
|
err("Failed to write index.json: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
private static String extractValue(String json, String key) {
|
|
var pattern = java.util.regex.Pattern.compile("\"" + key + "\":\\s*\"?([^\",\\n\\r}]*)\"?");
|
|
var matcher = pattern.matcher(json);
|
|
if (matcher.find()) return matcher.group(1).trim();
|
|
return "";
|
|
}
|
|
}
|