214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Puzzle Trainer — Daily Bot
|
|
* Runs at 09:00 every day, generates all 5 puzzles for today's date,
|
|
* scrapes their grids, solves them, and logs results to /var/log/puzzle-bot.log
|
|
*/
|
|
|
|
import { generateQueens, QUEEN_COLORS } from "@/lib/generators/queens";
|
|
import { generateTango } from "@/lib/generators/tango";
|
|
import { generateZip } from "@/lib/generators/zip";
|
|
import { generateSudoku } from "@/lib/generators/sudoku";
|
|
import { generatePatches, PATCH_COLORS } from "@/lib/generators/patches";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
|
|
const DATE = new Date().toISOString().slice(0, 10);
|
|
const LOG_FILE = "/var/log/puzzle-bot.log";
|
|
const OUTPUT_DIR = "/srv/stacks/puzzle-trainer/bot-output";
|
|
|
|
function log(msg: string) {
|
|
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
console.log(line);
|
|
fs.appendFileSync(LOG_FILE, line + "\n");
|
|
}
|
|
|
|
function ensureDir(dir: string) {
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
// ── Queens ────────────────────────────────────────────────────────────────────
|
|
function scrapeQueens(date: string) {
|
|
const puzzle = generateQueens(date);
|
|
const { size, regions, solution } = puzzle;
|
|
|
|
// ASCII grid with region letters
|
|
const regionLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
const grid: string[] = [];
|
|
for (let r = 0; r < size; r++) {
|
|
let row = "";
|
|
for (let c = 0; c < size; c++) {
|
|
const reg = regions[r][c];
|
|
const isQueen = solution.some(([sr, sc]) => sr === r && sc === c);
|
|
row += isQueen ? `[${regionLetters[reg]}]` : ` ${regionLetters[reg]} `;
|
|
}
|
|
grid.push(row);
|
|
}
|
|
|
|
return {
|
|
game: "queens",
|
|
date,
|
|
size,
|
|
grid,
|
|
solution,
|
|
regionCount: size,
|
|
};
|
|
}
|
|
|
|
// ── Tango ─────────────────────────────────────────────────────────────────────
|
|
function scrapeTango(date: string) {
|
|
const puzzle = generateTango(date);
|
|
const { size, given, hEdges, vEdges, solution } = puzzle;
|
|
|
|
const symbols = { sun: "☀", moon: "◐", null: "·" } as const;
|
|
const grid: string[] = [];
|
|
for (let r = 0; r < size; r++) {
|
|
const solRow = solution[r].map(v => symbols[v as keyof typeof symbols] ?? "?").join(" ");
|
|
const givenRow = given[r].map((v, c) => {
|
|
const s = symbols[v as keyof typeof symbols] ?? "·";
|
|
return v !== null ? `[${s}]` : ` ${symbols[solution[r][c] as keyof typeof symbols]} `;
|
|
}).join("");
|
|
grid.push(givenRow);
|
|
}
|
|
|
|
return {
|
|
game: "tango",
|
|
date,
|
|
size,
|
|
grid,
|
|
solution,
|
|
given,
|
|
hEdges,
|
|
vEdges,
|
|
};
|
|
}
|
|
|
|
// ── Zip ───────────────────────────────────────────────────────────────────────
|
|
function scrapeZip(date: string) {
|
|
const puzzle = generateZip(date);
|
|
const { size, path, numberedCells, walls } = puzzle;
|
|
|
|
// Build a grid showing numbers and path order
|
|
const grid: string[] = [];
|
|
const pathIdx = new Map<string, number>();
|
|
path.forEach(([r, c], i) => pathIdx.set(`${r},${c}`, i + 1));
|
|
|
|
for (let r = 0; r < size; r++) {
|
|
let row = "";
|
|
for (let c = 0; c < size; c++) {
|
|
const key = `${r},${c}`;
|
|
const num = (numberedCells as Record<string, number>)[key];
|
|
if (num !== undefined) row += `[${String(num).padStart(2)}]`;
|
|
else row += ` ${String(pathIdx.get(key) ?? "?").padStart(2)} `;
|
|
}
|
|
grid.push(row);
|
|
}
|
|
|
|
return {
|
|
game: "zip",
|
|
date,
|
|
size,
|
|
grid,
|
|
path,
|
|
numberedCells,
|
|
walls,
|
|
};
|
|
}
|
|
|
|
// ── Sudoku ────────────────────────────────────────────────────────────────────
|
|
function scrapeSudoku(date: string) {
|
|
const puzzle = generateSudoku(date);
|
|
const { size, given, solution } = puzzle;
|
|
|
|
const grid: string[] = [];
|
|
for (let r = 0; r < size; r++) {
|
|
let row = "";
|
|
for (let c = 0; c < size; c++) {
|
|
const g = given[r][c]; // 0 = empty, 1-6 = given
|
|
const s = solution[r][c];
|
|
row += g !== 0 ? `[${g}]` : ` ${s} `;
|
|
}
|
|
grid.push(row);
|
|
}
|
|
|
|
return { game: "sudoku", date, size, grid, given, solution };
|
|
}
|
|
|
|
// ── Patches ───────────────────────────────────────────────────────────────────
|
|
function scrapePatches(date: string) {
|
|
const puzzle = generatePatches(date);
|
|
const { size, regions, grid } = puzzle;
|
|
|
|
const letters = "ABCDEFGH";
|
|
const asciiGrid: string[] = [];
|
|
for (let r = 0; r < size; r++) {
|
|
let row = "";
|
|
for (let c = 0; c < size; c++) {
|
|
row += ` ${letters[grid[r][c]]} `;
|
|
}
|
|
asciiGrid.push(row);
|
|
}
|
|
|
|
// Solution: each region maps to itself
|
|
const solution = Object.fromEntries(regions.map(r => [r.id, r.id]));
|
|
|
|
return {
|
|
game: "patches",
|
|
date,
|
|
size,
|
|
grid: asciiGrid,
|
|
regions: regions.map(r => ({
|
|
id: r.id,
|
|
size: r.size,
|
|
color: r.color,
|
|
cells: r.cells,
|
|
hintCell: r.hintCell,
|
|
})),
|
|
solution,
|
|
};
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
async function main() {
|
|
ensureDir(OUTPUT_DIR);
|
|
log(`=== Daily puzzle bot starting — ${DATE} ===`);
|
|
|
|
const results: Record<string, unknown> = {};
|
|
const errors: string[] = [];
|
|
|
|
const scrapers = [
|
|
{ name: "queens", fn: scrapeQueens },
|
|
{ name: "tango", fn: scrapeTango },
|
|
{ name: "zip", fn: scrapeZip },
|
|
{ name: "sudoku", fn: scrapeSudoku },
|
|
{ name: "patches", fn: scrapePatches },
|
|
];
|
|
|
|
for (const { name, fn } of scrapers) {
|
|
try {
|
|
const data = fn(DATE);
|
|
results[name] = data;
|
|
log(`✓ ${name} (${data.size}x${data.size}) — solved OK`);
|
|
// Print grid
|
|
data.grid.forEach((row: string) => log(` ${row}`));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
errors.push(`${name}: ${msg}`);
|
|
log(`✗ ${name} ERROR: ${msg}`);
|
|
}
|
|
}
|
|
|
|
// Save JSON output for today
|
|
const outputFile = path.join(OUTPUT_DIR, `${DATE}.json`);
|
|
fs.writeFileSync(outputFile, JSON.stringify({ date: DATE, puzzles: results, errors }, null, 2));
|
|
log(`Saved → ${outputFile}`);
|
|
|
|
if (errors.length === 0) {
|
|
log(`=== All 5 puzzles OK — ${DATE} ===`);
|
|
} else {
|
|
log(`=== ${errors.length} error(s) — ${DATE} ===`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch(e => { log(`FATAL: ${e}`); process.exit(1); });
|