Crossword Changes and added Tectonic

This commit is contained in:
Brian Fertig 2026-06-05 23:24:11 -06:00
parent e07f48a85c
commit 9cb05f5f44
16 changed files with 20492 additions and 256 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

View File

@ -24,9 +24,13 @@ const TITLE_GOLD = '#d4a017';
const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 };
// ── Grid geometry ────────────────────────────────────────────────────────────────
const CELL_SIZE = 116;
const GRID_LEFT = 300;
const GRID_TOP = 300;
// The grid is sized to fit the left region (clue panel lives on the right at
// x>=1010), so 5x5 / 6x6 / 7x7 all fit without overlap. Cell size, position and
// font sizes are derived per-puzzle in computeGeometry().
const GRID_AREA = 680; // target pixel extent of the larger grid dimension
const GRID_LEFT_X = 90; // left edge of the region the grid is centered within
const GRID_REGION = 880; // width of that region
const GRID_TOP = 280;
export default class MiniCrosswordGame extends Phaser.Scene {
constructor() { super('MiniCrosswordGame'); }
@ -91,21 +95,25 @@ export default class MiniCrosswordGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 90,
'Fill the 5×5 grid so every across and down answer\nmatches its clue. Click a cell or clue to start typing.', {
'Fill the grid so every across and down answer matches\nits clue. Bigger grid, bigger challenge. Click a cell or\nclue to start typing.', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex,
align: 'center', lineSpacing: 8,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose difficulty', {
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose a grid size', {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
[['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']].forEach(([label, id], i) => {
const b = new Button(this, cx - 270 + i * 270, cy + 110, label,
[['Easy', '5×5', 'easy'], ['Medium', '6×6', 'medium'], ['Hard', '7×7', 'hard']].forEach(([label, size, id], i) => {
const x = cx - 270 + i * 270;
const b = new Button(this, x, cy + 110, label,
() => this.startPuzzle(id),
{ width: 230, height: 68, fontSize: 28, bgHover: GOLD });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
this.startObjs.push(this.add.text(x, cy + 168, size, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
});
}
@ -136,7 +144,21 @@ export default class MiniCrosswordGame extends Phaser.Scene {
// ── Board ──────────────────────────────────────────────────────────────────────
// Derive cell size, grid origin and font sizes from the puzzle dimensions so
// every grid size fits the left region and stays clear of the clue panel.
computeGeometry() {
const cols = this.grid[0].length;
const rows = this.grid.length;
this.cellSize = Math.max(88, Math.min(128, Math.floor(GRID_AREA / Math.max(rows, cols))));
const gridW = cols * this.cellSize;
this.gridLeft = GRID_LEFT_X + (GRID_REGION - gridW) / 2;
this.gridTop = GRID_TOP;
this.letterFont = Math.round(this.cellSize * 0.5);
this.numberFont = Math.round(this.cellSize * 0.19);
}
buildBoard() {
this.computeGeometry();
this.buildClueBanner();
this.buildGrid();
this.buildCluePanel();
@ -154,8 +176,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
cellCenter(r, c) {
return {
x: GRID_LEFT + c * CELL_SIZE + CELL_SIZE / 2,
y: GRID_TOP + r * CELL_SIZE + CELL_SIZE / 2,
x: this.gridLeft + c * this.cellSize + this.cellSize / 2,
y: this.gridTop + r * this.cellSize + this.cellSize / 2,
};
}
@ -165,7 +187,7 @@ export default class MiniCrosswordGame extends Phaser.Scene {
const { x, y } = this.cellCenter(r, c);
const block = this.grid[r][c] === '#';
const rect = this.add.rectangle(x, y, CELL_SIZE - 4, CELL_SIZE - 4,
const rect = this.add.rectangle(x, y, this.cellSize - 4, this.cellSize - 4,
block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell);
if (block) continue;
@ -173,13 +195,13 @@ export default class MiniCrosswordGame extends Phaser.Scene {
rect.on('pointerdown', () => this.onCellClick(r, c));
const txt = this.add.text(x, y + 6, '', {
fontFamily: 'Righteous', fontSize: '58px', color: INK_DARK,
fontFamily: 'Righteous', fontSize: `${this.letterFont}px`, color: INK_DARK,
}).setOrigin(0.5).setDepth(DEPTH.cellTxt);
const num = this.numbers[`${r},${c}`];
if (num) {
this.add.text(x - CELL_SIZE / 2 + 12, y - CELL_SIZE / 2 + 8, String(num), {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: INK_DARK,
this.add.text(x - this.cellSize / 2 + 10, y - this.cellSize / 2 + 6, String(num), {
fontFamily: '"Julius Sans One"', fontSize: `${this.numberFont}px`, color: INK_DARK,
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
}
@ -189,8 +211,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
}
buildClueBanner() {
const gridW = this.grid[0].length * CELL_SIZE;
this.banner = this.add.text(GRID_LEFT + gridW / 2, GRID_TOP - 70, '', {
const gridW = this.grid[0].length * this.cellSize;
this.banner = this.add.text(this.gridLeft + gridW / 2, this.gridTop - 70, '', {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
align: 'center', wordWrap: { width: gridW + 40 },
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
@ -211,13 +233,17 @@ export default class MiniCrosswordGame extends Phaser.Scene {
{ title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 },
];
// Space clues to fit the tallest column (up to 8 entries for a 7x7).
const maxRows = Math.max(this.across.length, this.down.length, 1);
const step = Math.min(76, Math.floor((PH - 130) / maxRows));
for (const col of columns) {
this.add.text(col.x, PY + 36, col.title, {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
}).setDepth(DEPTH.cellTxt);
col.slots.forEach((slot, i) => {
const y = PY + 100 + i * 76;
const y = PY + 100 + i * step;
const t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
wordWrap: { width: 360 },
@ -230,10 +256,10 @@ export default class MiniCrosswordGame extends Phaser.Scene {
}
buildControls() {
const y = GRID_TOP + this.grid.length * CELL_SIZE + 70;
this.checkBtn = new Button(this, GRID_LEFT + 120, y, 'Check',
const y = this.gridTop + this.grid.length * this.cellSize + 70;
this.checkBtn = new Button(this, this.gridLeft + 120, y, 'Check',
() => this.checkGrid(), { width: 200, height: 60, fontSize: 24 });
this.revealBtn = new Button(this, GRID_LEFT + 350, y, 'Reveal Word',
this.revealBtn = new Button(this, this.gridLeft + 350, y, 'Reveal Word',
() => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD });
[this.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui));

View File

@ -0,0 +1,645 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { HINT_LIMITS, DIFFICULTY_SCORES, isBoardComplete, getHintCell } from './TectonicLogic.js';
// ── Palette (graph-paper notebook) ─────────────────────────────────────────────
const PAPER = 0xFCFEFF; // bright graph-paper white
const PAPER_EDGE = 0xD9E4EC;
const GRAPH_BLUE = 0x9DC4E6; // fine printed grid lines
const CELL_BLUE = 0x6FA3CF; // puzzle cell separators
const REGION_INK = 0x223547; // bold cage outlines
const INK = '#21303f'; // given numbers (ink)
const INK_N = 0x21303f;
const FADED = '#8aa6bd';
const TITLE_BLUE = '#27506e';
const RED_MARK = 0xc0392b;
const PLAYER_INK = '#1c6fb0'; // player-entered numbers (blue pen)
const DEPTH = {
bg: 0, paper: 1, graph: 2, cell: 3, cellLines: 4, region: 5,
number: 6, selector: 7, hit: 10, ui: 20, overlay: 40,
};
// ── Layout ──────────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
// Paper sheet
const PX = 60, PY = 40, PW = 1800, PH = 1000;
const GRAPH_PITCH = 45;
// Grid (8×8, centered in the left panel)
const N = 8;
const CELL = 90;
const GRID_W = N * CELL; // 720
const GRID_H = N * CELL; // 720
const TITLE_CX = 540; // centre of the left panel
const GRID_X = TITLE_CX - GRID_W / 2; // 180
const GRID_Y = 200;
// Right-panel number selector (15 + eraser)
const NUM_X = 1440;
const NUM_START_Y = 282;
const NUM_STEP = 108;
const ERASER_CY = NUM_START_Y + 5 * NUM_STEP + 6; // below the "5"
const HINT_BTN_Y = ERASER_CY + 86;
const BTN_Y = PY + PH - 48;
const DIFF_LABELS = { easy: 'Easy', medium: 'Medium', hard: 'Hard' };
export default class TectonicGame extends Phaser.Scene {
constructor() { super('TectonicGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.grid = null;
this.solution = null;
this.regions = null;
this.regionSize = null; // 2D: size of the region each cell belongs to
this.neededFor = null; // neededFor[v] = how many regions hold value v
this.difficulty = '';
this.selectedNum = 1;
this.hintsLeft = null;
this.hintsUsed = 0;
this.gameEnded = false;
this.hoveredCell = null;
this.givenCells = [];
this.cellState = []; // 0=empty, 1=correct, 2=wrong
this.cellBgObjs = [];
this.cellTextObjs = [];
this.cellHitObjs = [];
this.numCircleGfx = null;
this.numStrikeGfx = null;
this.numTextObjs = {};
this.numHitObjs = {};
this.hintBtn = null;
this.startObjs = [];
this.revealed = false;
}
async create() {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
await this.showStartPanel();
}
// ── Start panel ───────────────────────────────────────────────────────────────
async showStartPanel() {
const cx = CX, cy = CY;
const sheet = this.add.graphics().setDepth(DEPTH.paper);
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
sheet.fillStyle(PAPER, 1);
sheet.fillRoundedRect(cx - 600, cy - 320, 1200, 640, 18);
sheet.lineStyle(3, PAPER_EDGE, 1);
sheet.strokeRoundedRect(cx - 600, cy - 320, 1200, 640, 18);
this.startObjs.push(sheet);
// graph lines inside the start sheet
const g = this.add.graphics().setDepth(DEPTH.graph);
g.lineStyle(1, GRAPH_BLUE, 0.4);
for (let x = cx - 600 + GRAPH_PITCH; x < cx + 600; x += GRAPH_PITCH)
g.lineBetween(x, cy - 320, x, cy + 320);
for (let y = cy - 320 + GRAPH_PITCH; y < cy + 320; y += GRAPH_PITCH)
g.lineBetween(cx - 600, y, cx + 600, y);
this.startObjs.push(g);
this.startObjs.push(
this.add.text(cx, cy - 226, 'Tectonic', {
fontFamily: 'YummyCupcakes', fontSize: '104px', color: TITLE_BLUE,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 118, 'Fill each cage 1N · no touching twins', {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: '#5a7a92',
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 44, 'Choose difficulty', {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
const diffs = [['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']];
diffs.forEach(([label, id], i) => {
const b = new Button(this, cx + (i - 1) * 260, cy + 70, label,
() => this.startGame(id), { width: 230, height: 70, fontSize: 26 });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
const leave = new Button(this, cx, cy + 210, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
leave.setDepth(DEPTH.ui);
this.startObjs.push(leave);
}
destroyStart() {
this.startObjs.forEach(o => o.destroy());
this.startObjs = [];
}
// ── Game start ────────────────────────────────────────────────────────────────
async startGame(difficulty) {
this.destroyStart();
playSound(this, SFX.PIECE_CLICK);
let data;
try {
data = await api.get(`/words/tectonic/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[tectonic] failed to fetch puzzle:', err);
await this.showStartPanel();
return;
}
this.grid = data.grid.map(row => [...row]);
this.solution = data.solution;
this.regions = data.regions;
this.difficulty = difficulty;
this.selectedNum = 1;
this.hintsLeft = HINT_LIMITS[difficulty] ?? 0;
this.hintsUsed = 0;
this.gameEnded = false;
this.hoveredCell = null;
this.revealed = false;
this.computeRegionMeta();
this.givenCells = this.grid.map(row => row.map(v => v !== 0));
this.cellState = Array.from({ length: N }, () => Array(N).fill(0));
this.buildPaper();
this.buildTitle();
this.buildCellLines();
this.buildRegionBorders();
this.buildCells();
this.buildSelector();
if (this.hintsLeft > 0) this.buildHintBtn();
this.buildControls();
this.refreshSelector();
this.updateCompletedNums();
}
// Region size per cell + how many regions contain each value 1..5.
computeRegionMeta() {
const counts = {};
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const id = this.regions[r][c];
counts[id] = (counts[id] || 0) + 1;
}
this.regionSize = this.regions.map(row => row.map(id => counts[id]));
this.neededFor = [0, 0, 0, 0, 0, 0];
for (const id in counts) {
const size = counts[id];
for (let v = 1; v <= size; v++) this.neededFor[v]++;
}
}
// ── Paper & graph lines ─────────────────────────────────────────────────────
buildPaper() {
const g = this.add.graphics().setDepth(DEPTH.paper);
g.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.55);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PX, PY, PW, PH, 16);
g.lineStyle(2, PAPER_EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 16);
// Printed graph-paper grid across the whole sheet.
const gl = this.add.graphics().setDepth(DEPTH.graph);
gl.lineStyle(1, GRAPH_BLUE, 0.45);
for (let x = PX + GRAPH_PITCH; x < PX + PW; x += GRAPH_PITCH)
gl.lineBetween(x, PY + 6, x, PY + PH - 6);
for (let y = PY + GRAPH_PITCH; y < PY + PH; y += GRAPH_PITCH)
gl.lineBetween(PX + 6, y, PX + PW - 6, y);
}
buildTitle() {
const titleTxt = this.add.text(TITLE_CX, 116, 'Tectonic', {
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BLUE,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(titleTxt.x + titleTxt.width / 2 + 24, titleTxt.y + 14,
DIFF_LABELS[this.difficulty] ?? this.difficulty, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
}
// ── Grid lines & cage borders ────────────────────────────────────────────────
buildCellLines() {
const gfx = this.add.graphics().setDepth(DEPTH.cellLines);
gfx.lineStyle(1.5, CELL_BLUE, 0.5);
for (let i = 1; i < N; i++) {
gfx.lineBetween(GRID_X + i * CELL, GRID_Y, GRID_X + i * CELL, GRID_Y + GRID_H);
gfx.lineBetween(GRID_X, GRID_Y + i * CELL, GRID_X + GRID_W, GRID_Y + i * CELL);
}
}
buildRegionBorders() {
const gfx = this.add.graphics().setDepth(DEPTH.region);
gfx.lineStyle(5, REGION_INK, 0.92);
const idAt = (r, c) => (r < 0 || r >= N || c < 0 || c >= N) ? -1 : this.regions[r][c];
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
const id = this.regions[r][c];
const x = GRID_X + c * CELL;
const y = GRID_Y + r * CELL;
if (idAt(r - 1, c) !== id) gfx.lineBetween(x, y, x + CELL, y);
if (idAt(r, c - 1) !== id) gfx.lineBetween(x, y, x, y + CELL);
if (idAt(r + 1, c) !== id) gfx.lineBetween(x, y + CELL, x + CELL, y + CELL);
if (idAt(r, c + 1) !== id) gfx.lineBetween(x + CELL, y, x + CELL, y + CELL);
}
}
}
// ── Cells ─────────────────────────────────────────────────────────────────────
buildCells() {
this.cellBgObjs = [];
this.cellTextObjs = [];
this.cellHitObjs = [];
for (let r = 0; r < N; r++) {
this.cellBgObjs.push([]);
this.cellTextObjs.push([]);
this.cellHitObjs.push([]);
for (let c = 0; c < N; c++) {
const cx = GRID_X + c * CELL + CELL / 2;
const cy = GRID_Y + r * CELL + CELL / 2;
const given = this.givenCells[r][c];
const val = this.grid[r][c];
const bg = this.add.graphics().setDepth(DEPTH.cell);
this.cellBgObjs[r].push(bg);
const txt = this.add.text(cx, cy, given ? String(val) : '', {
fontFamily: given ? 'Righteous' : 'YummyCupcakes',
fontSize: given ? '46px' : '50px',
color: given ? INK : PLAYER_INK,
}).setOrigin(0.5).setDepth(DEPTH.number);
this.cellTextObjs[r].push(txt);
if (given) {
this.cellHitObjs[r].push(null);
} else {
const hit = this.add.rectangle(cx, cy, CELL - 2, CELL - 2, 0xffffff, 0.001)
.setDepth(DEPTH.hit)
.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => this.onCellHover(r, c, true));
hit.on('pointerout', () => this.onCellHover(r, c, false));
hit.on('pointerdown', () => this.onCellClick(r, c));
this.cellHitObjs[r].push(hit);
}
}
}
}
// ── Number selector ───────────────────────────────────────────────────────────
buildSelector() {
this.numCircleGfx = this.add.graphics().setDepth(DEPTH.selector);
this.numStrikeGfx = this.add.graphics().setDepth(DEPTH.selector + 2);
this.numHitObjs = {};
this.numTextObjs = {};
for (let n = 1; n <= 5; n++) {
const ny = NUM_START_Y + (n - 1) * NUM_STEP;
const numTxt = this.add.text(NUM_X, ny, String(n), {
fontFamily: '"Julius Sans One"', fontSize: '74px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
this.numTextObjs[n] = numTxt;
const hit = this.add.rectangle(NUM_X, ny, 220, NUM_STEP - 4, 0xffffff, 0.001)
.setDepth(DEPTH.hit + 1)
.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.selectNum(n));
this.numHitObjs[n] = hit;
}
// Eraser ×
this.add.text(NUM_X, ERASER_CY, '×', {
fontFamily: '"Julius Sans One"', fontSize: '60px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
const eraserHit = this.add.rectangle(NUM_X, ERASER_CY, 220, NUM_STEP - 4, 0xffffff, 0.001)
.setDepth(DEPTH.hit + 1)
.setInteractive({ useHandCursor: true });
eraserHit.on('pointerdown', () => this.selectNum(0));
this.numHitObjs[0] = eraserHit;
}
refreshSelector() {
const gfx = this.numCircleGfx;
if (!gfx) return;
gfx.clear();
let cy;
if (this.selectedNum >= 1 && this.selectedNum <= 5) {
cy = NUM_START_Y + (this.selectedNum - 1) * NUM_STEP;
} else if (this.selectedNum === 0) {
cy = ERASER_CY;
} else {
return;
}
this.drawHandCircle(gfx, NUM_X, cy);
}
drawHandCircle(gfx, cx, cy) {
gfx.lineStyle(3, RED_MARK, 0.88);
gfx.strokeEllipse(cx, cy, 92, 80);
gfx.lineStyle(2, RED_MARK, 0.28);
gfx.strokeEllipse(cx + 3, cy - 2, 96, 84);
}
updateCompletedNums() {
const gfx = this.numStrikeGfx;
if (!gfx) return;
gfx.clear();
for (let n = 1; n <= 5; n++) {
let count = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (this.grid[r][c] === n) count++;
const txt = this.numTextObjs[n];
if (!txt) continue;
if (count >= this.neededFor[n]) {
txt.setColor('#9bb0c2');
const ny = NUM_START_Y + (n - 1) * NUM_STEP;
gfx.lineStyle(3, 0x7a92a6, 0.8);
gfx.lineBetween(NUM_X - 38, ny - 6, NUM_X + 38, ny - 6);
} else {
txt.setColor(INK);
}
}
}
// ── Hint button ───────────────────────────────────────────────────────────────
buildHintBtn() {
const isLimited = isFinite(this.hintsLeft);
const label = isLimited ? `Hint (${this.hintsLeft} left)` : 'Hint';
this.hintBtn = new Button(this, NUM_X, HINT_BTN_Y, label,
() => this.useHint(), { width: 240, height: 56, fontSize: 22 });
this.hintBtn.setDepth(DEPTH.ui);
}
updateHintBtn() {
if (!this.hintBtn) return;
const isLimited = isFinite(this.hintsLeft);
this.hintBtn.setLabel(isLimited ? `Hint (${this.hintsLeft} left)` : 'Hint');
if (this.hintsLeft === 0) this.hintBtn.setEnabled(false);
}
// ── Controls ──────────────────────────────────────────────────────────────────
buildControls() {
new Button(this, PX + 160, BTN_Y, 'New puzzle',
() => this.scene.restart(this._initData),
{ variant: 'ghost', width: 230, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
new Button(this, PX + PW - 130, BTN_Y, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
}
// ── Input ─────────────────────────────────────────────────────────────────────
selectNum(num) {
this.selectedNum = num;
this.refreshSelector();
playSound(this, SFX.PIECE_CLICK);
}
onCellHover(r, c, entering) {
this.hoveredCell = entering ? { r, c } : null;
this.drawCellBg(r, c, entering);
}
onCellClick(r, c) {
if (this.gameEnded || this.givenCells[r][c]) return;
playSound(this, SFX.PIECE_CLICK);
if (this.selectedNum === 0) {
this.clearCell(r, c);
} else if (this.grid[r][c] === this.selectedNum) {
this.clearCell(r, c);
} else {
this.placeNum(r, c, this.selectedNum);
}
}
placeNum(r, c, num) {
this.grid[r][c] = num;
this.cellTextObjs[r][c].setText(String(num));
const hovered = this.hoveredCell?.r === r && this.hoveredCell?.c === c;
if (this.isEasyMode() || this.revealed) {
const correct = (num === this.solution[r][c]);
this.cellState[r][c] = correct ? 1 : 2;
this.drawCellBg(r, c, hovered);
if (correct && isBoardComplete(this.grid, this.solution)) {
this.time.delayedCall(300, () => this.handleWin());
}
} else {
this.cellState[r][c] = 0;
this.drawCellBg(r, c, hovered);
if (this.isAllFilled()) {
this.time.delayedCall(200, () => this.revealAll());
}
}
this.updateCompletedNums();
}
isEasyMode() {
return this.difficulty === 'easy';
}
isAllFilled() {
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (!this.givenCells[r][c] && this.grid[r][c] === 0) return false;
return true;
}
revealAll() {
this.revealed = true;
let allCorrect = true;
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
if (this.givenCells[r][c]) continue;
const correct = this.grid[r][c] === this.solution[r][c];
if (!correct) allCorrect = false;
this.cellState[r][c] = correct ? 1 : 2;
this.drawCellBg(r, c, false);
}
}
this.updateCompletedNums();
if (allCorrect) {
this.time.delayedCall(300, () => this.handleWin());
}
}
clearCell(r, c) {
if (this.givenCells[r][c]) return;
this.grid[r][c] = 0;
this.cellTextObjs[r][c].setText('');
this.cellState[r][c] = 0;
const hovered = this.hoveredCell?.r === r && this.hoveredCell?.c === c;
this.drawCellBg(r, c, hovered);
this.updateCompletedNums();
}
drawCellBg(r, c, isHovered) {
const bg = this.cellBgObjs[r][c];
if (!bg) return;
bg.clear();
const x = GRID_X + c * CELL + 1;
const y = GRID_Y + r * CELL + 1;
const sz = CELL - 2;
const state = this.cellState[r][c];
if (state === 1) {
bg.fillStyle(0x3c8a4e, isHovered ? 0.30 : 0.20);
bg.fillRect(x, y, sz, sz);
} else if (state === 2) {
bg.fillStyle(0xb03a2e, isHovered ? 0.27 : 0.16);
bg.fillRect(x, y, sz, sz);
} else if (isHovered) {
bg.fillStyle(INK_N, 0.07);
bg.fillRect(x, y, sz, sz);
}
}
// ── Hint ──────────────────────────────────────────────────────────────────────
useHint() {
if (this.gameEnded) return;
if (isFinite(this.hintsLeft) && this.hintsLeft <= 0) return;
const cell = getHintCell(this.grid);
if (!cell) return;
const { r, c } = cell;
const bg = this.cellBgObjs[r][c];
bg.clear();
bg.fillStyle(0xd4a017, 0.5);
bg.fillRect(GRID_X + c * CELL + 1, GRID_Y + r * CELL + 1, CELL - 2, CELL - 2);
this.time.delayedCall(550, () => {
this.grid[r][c] = this.solution[r][c];
this.cellTextObjs[r][c].setText(String(this.solution[r][c]));
this.cellState[r][c] = 1;
this.drawCellBg(r, c, false);
this.updateCompletedNums();
if (isBoardComplete(this.grid, this.solution)) {
this.time.delayedCall(200, () => this.handleWin());
}
});
if (isFinite(this.hintsLeft)) {
this.hintsLeft--;
this.hintsUsed++;
this.updateHintBtn();
}
}
// ── Win ───────────────────────────────────────────────────────────────────────
handleWin() {
if (this.gameEnded) return;
this.gameEnded = true;
this.recordResult();
this.showWin();
}
showWin() {
const cx = CX, cy = CY;
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.42)
.setDepth(DEPTH.overlay);
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
panel.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.55);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 460, cy - 230, 920, 460, 18);
panel.lineStyle(3, PAPER_EDGE, 1);
panel.strokeRoundedRect(cx - 460, cy - 230, 920, 460, 18);
this.add.text(cx, cy - 130, 'Puzzle Solved!', {
fontFamily: 'YummyCupcakes', fontSize: '88px', color: '#3c8a4e',
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
const diffLabel = DIFF_LABELS[this.difficulty] ?? this.difficulty;
const score = this.calcScore();
this.add.text(cx, cy - 26, `${diffLabel} · ${score} pts`, {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: TITLE_BLUE,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
if (this.hintsUsed > 0) {
this.add.text(cx, cy + 28, `${this.hintsUsed} hint${this.hintsUsed > 1 ? 's' : ''} used`, {
fontFamily: 'YummyCupcakes', fontSize: '32px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
}
new Button(this, cx - 175, cy + 130, 'New puzzle',
() => this.scene.restart(this._initData),
{ width: 280, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 3);
new Button(this, cx + 175, cy + 130, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 3);
}
calcScore() {
const base = DIFFICULTY_SCORES[this.difficulty] ?? 10;
const hintPenalty = this.difficulty === 'medium' ? this.hintsUsed * 4 : 0;
return Math.max(0, base - hintPenalty);
}
async recordResult() {
try {
await api.post('/history/single-player', {
slug: 'tectonic', score: this.calcScore(), opponentScores: [], result: 'win',
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,28 @@
export const HINT_LIMITS = {
easy: Infinity,
medium: 5,
hard: 0,
};
export const DIFFICULTY_SCORES = {
easy: 20,
medium: 50,
hard: 90,
};
export function isBoardComplete(grid, solution) {
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[r].length; c++)
if (grid[r][c] !== solution[r][c]) return false;
return true;
}
// Returns a random empty cell that still needs filling, or null.
export function getHintCell(grid) {
const empties = [];
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[r].length; c++)
if (grid[r][c] === 0) empties.push({ r, c });
if (empties.length === 0) return null;
return empties[Math.floor(Math.random() * empties.length)];
}

View File

@ -52,6 +52,7 @@ import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
import SplendorGame from './games/splendor/SplendorGame.js';
import TectonicGame from './games/tectonic/TectonicGame.js';
const config = {
type: Phaser.AUTO,
@ -117,6 +118,7 @@ const config = {
ForbiddenIslandGame,
SolitaireTourGame,
SplendorGame,
TectonicGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

File diff suppressed because it is too large Load Diff

View File

@ -2,286 +2,209 @@
{
"id": "easy-001",
"difficulty": "easy",
"grid": ["APART", "POWER", "AWARE", "RERUN", "TREND"],
"grid": ["#BLAH", "SLAVE", "CANAL", "AMEND", "REST#"],
"across": [
"Separated, as two people",
"Electrical energy",
"Conscious of; in the know",
"TV episode shown again",
"Current fashion or direction"
"Dull and uninspiring",
"One forced to work for no pay",
"Panama waterway",
"Change, as a law",
"Take a break"
],
"down": [
"Not together",
"Strength or might",
"Mindful",
"Summer TV staple",
"What goes viral, perhaps"
"Point the finger at",
"Bowling alley divisions",
"___-garde (cutting-edge)",
"Gripped tightly",
"Mark left by a healed wound"
]
},
{
"id": "easy-002",
"id": "easy-008",
"difficulty": "easy",
"grid": ["SMART", "POLAR", "OLIVE", "RAVEN", "TREND"],
"grid": ["WATCH", "AWARE", "SABER", "PILED", "STEPS"],
"across": [
"Clever",
"Like a bear at the North Pole",
"Green martini garnish",
"Edgar Allan Poe's black bird",
"Hot new fashion"
"Wrist timepiece",
"In the know",
"Fencing sword",
"Stacked up",
"Stairs, individually"
],
"down": [
"Baseball or soccer, e.g.",
"Back tooth",
"Living and breathing",
"Glossy black bird",
"Topic that's taking off online"
"Stinging insects",
"Wait for",
"Dining room furniture",
"Move slowly and stealthily",
"Cattle groups"
]
},
{
"id": "easy-003",
"id": "easy-009",
"difficulty": "easy",
"grid": ["START", "THROW", "ARGUE", "ROUTE", "TWEED"],
"grid": ["#TONE", "PERIL", "URGES", "BRACE", "SANE#"],
"across": [
"Begin",
"Toss a ball",
"Quarrel",
"Path a bus takes",
"Rough wool fabric for jackets"
"Muscle firmness, or vocal quality",
"Grave danger",
"Strongly encourages",
"Support for a sprained joint",
"Of sound mind"
],
"down": [
"A race's beginning",
"Pitch, as a baseball",
"Bicker",
"Mail carrier's territory",
"Tweedy jacket cloth"
"___ firma (solid ground)",
"Church keyboard instrument",
"Your sibling's daughter",
"Or ___ (otherwise)",
"Spots for a pint"
]
},
{
"id": "easy-004",
"id": "easy-018",
"difficulty": "easy",
"grid": ["CLOSE", "LLAMA", "OASIS", "SMILE", "EASEL"],
"grid": ["#FIRE", "COCOA", "ALIAS", "LINDY", "LOGS#"],
"across": [
"Shut, as a door",
"Andean pack animal",
"Green spot in the desert",
"Happy expression",
"Painter's stand"
"Campsite blaze",
"Hot ___ (cozy winter drink)",
"Assumed name",
"___ Hop (jazz-era swing dance)",
"Firewood pieces"
],
"down": [
"Nearby",
"Spitting Andean beast",
"Desert refuge",
"What a camera asks you to do",
"Stand for a canvas"
"Large book-page size",
"Sugary cake topping",
"Routes for cars",
"Not difficult",
"Ring up on the phone"
]
},
{
"id": "easy-005",
"id": "easy-020",
"difficulty": "easy",
"grid": ["MAPLE", "AGAIN", "PAINT", "LINER", "ENTRY"],
"grid": ["#PILE", "GENOA", "RADAR", "ACIDS", "BEES#"],
"across": [
"Syrup-producing tree",
"Once more",
"What an artist applies",
"Big cruise ship",
"Doorway, or a diary post"
"Big heap",
"Italian port city",
"Speed-gun technology",
"Bases' chemical opposites",
"Hive dwellers"
],
"down": [
"Tree on Canada's flag",
"Time and time ___",
"Wall color in a can",
"Ocean-crossing vessel",
"Way in"
"Opposite of war",
"Like non-mainstream music",
"Tons; a whole lot",
"Corn units, or hearing organs",
"Snatch quickly"
]
},
{
"id": "medium-001",
"id": "medium-020",
"difficulty": "medium",
"grid": ["DROVE", "RAVEN", "OVERT", "VERVE", "ENTER"],
"grid": ["#BLEW#", "PRISON", "RATTLE", "OCTAVE", "MELTED", "#SEES#"],
"across": [
"Operated the car; also, a herd",
"Bird that quoth 'Nevermore'",
"Out in the open, not hidden",
"Energy and enthusiasm",
"Key pressed to confirm"
"Past tense of \"blow\"",
"Place for inmates",
"Baby's shaking toy",
"Span of eight musical notes",
"Turned to liquid, as ice",
"Lays eyes on"
],
"down": [
"Chauffeured",
"Corvid in a Poe poem",
"Undisguised",
"Pep and flair",
"Go in"
"Teeth straighteners",
"Not much; small",
"Large property with a mansion",
"Pack animals that howl",
"High school spring dance",
"Require"
]
},
{
"id": "medium-002",
"id": "medium-008",
"difficulty": "medium",
"grid": ["GRASP", "RUMOR", "AMPLE", "SOLVE", "PREEN"],
"grid": ["#SEAL#", "MIRROR", "ARRIVE", "LEASED", "ENTERS", "#SANS#"],
"across": [
"Grip firmly; comprehend",
"Unverified bit of gossip",
"More than enough",
"Crack, as a puzzle",
"Groom feathers, as a bird does"
"Whiskered arctic swimmer",
"Bathroom reflector",
"Reach a destination",
"Rented out",
"Goes inside",
"Without, in French"
],
"down": [
"Get a handle on",
"Word on the street",
"Plentiful",
"Figure out",
"Primp"
]
},
{
"id": "medium-003",
"difficulty": "medium",
"grid": ["SHADE", "HELIX", "ALIVE", "RIVER", "EXERT"],
"across": [
"Shadow cast by a tree",
"DNA's spiral shape",
"Not dead",
"The Nile or the Amazon",
"Put forth, as effort"
],
"down": [
"Portion; or post online",
"Double ___ (DNA structure)",
"Kicking, so to speak",
"One leaping off a board",
"Apply, as force"
"Ambulance wailers",
"List of printed errors",
"Gotten up out of bed",
"Romantic partners",
"Not female",
"Crimson shades"
]
},
{
"id": "medium-004",
"difficulty": "medium",
"grid": ["CRANE", "RESIN", "ASSET", "NIECE", "ENTER"],
"grid": ["#WEST#", "MISHAP", "ATTIRE", "CHARGE", "SETTER", "#REST#"],
"across": [
"Construction lifting machine; or a tall bird",
"Sticky secretion that becomes amber",
"Valuable item on a balance sheet",
"Your sibling's daughter",
"Type in, as data"
"Sunset direction",
"Minor accident",
"Clothing; an outfit",
"Put on a credit card",
"Irish ___ (long-haired dog)",
"The remainder"
],
"down": [
"Stretch the neck to see",
"Pine-tree ooze",
"A plus on the books",
"Nephew's sister",
"Walk into a room"
]
},
{
"id": "medium-005",
"difficulty": "medium",
"grid": ["EVADE", "VEGAN", "AGENT", "DANCE", "ENTER"],
"across": [
"Dodge, as a question",
"One who eats no animal products",
"Spy, or an actor's rep",
"Waltz or tango",
"Sign up for, as a contest"
],
"down": [
"Slip away from",
"Plant-based eater",
"007, for one",
"Boogie",
"Join, as a race"
]
},
{
"id": "hard-001",
"difficulty": "hard",
"grid": ["CRONE", "RIVAL", "OVOID", "NAIVE", "ELDER"],
"across": [
"Witchy old woman of folklore",
"Competitor to beat",
"Egg-shaped",
"Innocently unworldly",
"Respected senior; also a berry bush"
],
"down": [
"Hag of fairy tales",
"Archnemesis",
"Like an egg's outline",
"Wet behind the ears",
"Tribal sage"
"Shrivel and dry up",
"Inherited property",
"Closet hang-ups",
"Bullseye holder",
"Apple computers",
"Look closely"
]
},
{
"id": "hard-002",
"difficulty": "hard",
"grid": ["GRUFF", "RIVER", "UVULA", "FELON", "FRANK"],
"grid": ["##TWO##", "#POEMS#", "TIDBITS", "AND#TEA", "BELATED", "#SEVER#", "##RED##"],
"across": [
"Brusque and surly",
"The Mississippi, for one",
"Dangly bit at the back of the throat",
"Convicted criminal",
"Candidly blunt; or a hot dog"
"Number after one",
"Verses by Emily Dickinson",
"Small, tasty morsels",
"Plus",
"Afternoon British drink",
"Late, as a birthday card",
"Cut off completely",
"Stop sign's color"
],
"down": [
"Hoarse and curt",
"Flowing waterway",
"Throat's little hanging punching bag",
"One with a rap sheet",
"Straight-talking; ballpark sausage"
"Child just learning to walk",
"Spider's silken trap",
"Left out",
"Evergreen trees",
"Guide a car",
"Bar bill",
"Feeling blue",
"Street: Abbr."
]
},
{
"id": "hard-003",
"id": "hard-014",
"difficulty": "hard",
"grid": ["PLUMB", "LUNAR", "UNTIE", "MAIZE", "BREED"],
"grid": ["##PIN##", "#PACED#", "FORESEE", "ART#TAG", "TEASING", "#SKINS#", "##ENG##"],
"across": [
"Measure the depth of; dead vertical",
"Of the moon",
"Loosen, as shoelaces",
"Corn, by another name",
"Raise animals; a dog variety"
"Bowling target",
"Walked back and forth nervously",
"Predict in advance",
"Museum display",
"Playground chasing game",
"Good-natured ribbing",
"Potato jackets",
"English: Abbr."
],
"down": [
"Perfectly upright",
"___ eclipse",
"Undo a knot",
"Native American staple grain",
"Labrador or poodle, e.g."
]
},
{
"id": "hard-004",
"difficulty": "hard",
"grid": ["SARGE", "ACORN", "ROBOT", "GROVE", "ENTER"],
"across": [
"Nickname for a drill instructor",
"Oak tree's nut",
"Automaton like R2-D2",
"Small cluster of trees",
"Make an entrance"
],
"down": [
"Boot-camp boss, informally",
"Squirrel's buried snack",
"Mechanical worker",
"Orange ___ in Florida",
"Step inside"
]
},
{
"id": "hard-005",
"difficulty": "hard",
"grid": ["SHALE", "HELIX", "ALIVE", "LIVER", "EXERT"],
"across": [
"Rock that yields oil and gas",
"Spiral, as of DNA",
"Full of life",
"Organ that filters toxins",
"Bring to bear, as influence"
],
"down": [
"Sedimentary fracking rock",
"Corkscrew shape",
"Among the living",
"Onions' classic skillet partner",
"Wield, as pressure"
"Join in; participate",
"Frozen water",
"What birds do in spring",
"Tiny skin openings",
"College administrators",
"Not lean",
"Breakfast protein source",
"Commandment breaker"
]
}
]

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@ registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'ca
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 42 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });

View File

@ -0,0 +1,54 @@
// Builds server/data/wordlists/common.txt — the clue-able word subset the Mini
// Crossword generator fills from. It intersects the ENABLE dictionary with the
// Norvig word-frequency list, applying a *tighter rank cutoff for short words*:
// short obscure words (ANI, CESS, SEG) are the worst crossword fill, and the
// truly common short words rank very high, so a low cutoff filters the junk
// while keeping enough vocabulary to fill.
//
// Usage: node server/scripts/buildCommonWords.js
// Caches the 5 MB frequency list at /tmp/count_1w.txt to avoid re-downloading.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENABLE_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
const OUT_PATH = path.join(__dirname, '../data/wordlists/common.txt');
const FREQ_URL = 'https://norvig.com/ngrams/count_1w.txt';
const FREQ_CACHE = '/tmp/count_1w.txt';
// Frequency-rank cutoff per word length. Short words get a much stricter bar.
const CUTOFF = { 3: 6000, 4: 18000, 5: 45000, 6: 70000, 7: 70000 };
async function loadFreq() {
if (fs.existsSync(FREQ_CACHE)) return fs.readFileSync(FREQ_CACHE, 'utf8');
console.log('Downloading frequency list...');
const text = await (await fetch(FREQ_URL)).text();
fs.writeFileSync(FREQ_CACHE, text);
return text;
}
async function main() {
const enable = new Set(
fs.readFileSync(ENABLE_PATH, 'utf8').split('\n').map((w) => w.trim().toUpperCase()),
);
const ranked = (await loadFreq())
.split('\n').map((l) => l.split('\t')[0]).filter(Boolean).map((w) => w.toUpperCase());
const out = [];
const byLen = {};
ranked.forEach((w, rank) => {
const len = w.length;
if (!CUTOFF[len] || rank >= CUTOFF[len]) return;
if (!/^[A-Z]+$/.test(w) || !enable.has(w)) return;
out.push(w);
byLen[len] = (byLen[len] || 0) + 1;
});
fs.writeFileSync(OUT_PATH, out.join('\n') + '\n');
console.log(`Wrote ${out.length} common words to ${OUT_PATH}`);
console.log('Per length:', JSON.stringify(byLen));
}
main();

View File

@ -0,0 +1,339 @@
// Offline generator for Mini Crossword grids.
//
// Produces *non-symmetric* crossword grids (across words differ from down words)
// at three sizes — easy 5x5, medium 6x6, hard 7x7 — by filling block templates
// against the ENABLE word list with a backtracking solver (MRV slot ordering +
// forward checking). It emits a candidate JSON whose `across`/`down` clue arrays
// are blank placeholders, plus the answers, so clues can be hand-authored after.
//
// Usage:
// node server/scripts/genMiniCrossword.js [perTier] [outFile]
// node server/scripts/genMiniCrossword.js 20 server/data/crosswords/_generated.json
//
// All runs are length >= 3. Templates are rotationally symmetric. Each puzzle
// uses every word at most once.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Common-word subset (top frequency ∩ ENABLE) so fills stay clue-able rather
// than picking obscure Scrabble words. Built once via buildCommonWords.js.
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/common.txt');
const BLOCK = '#';
const EMPTY = '.';
// ── Block templates ────────────────────────────────────────────────────────────
// '.' = fillable cell, '#' = black square. Each is rotationally symmetric with no
// run shorter than 3. Blocks make the fill *easier* (shorter, more-flexible runs)
// while giving a real-crossword shape. More than one template per tier yields
// more grid variety.
const TEMPLATES = {
easy: [
// Open 5x5: a double word square — 5 across + 5 down, all distinct words.
[
'.....',
'.....',
'.....',
'.....',
'.....',
],
// 5x5 with symmetric corner nicks (len-4 corners + len-5 spines).
[
'#....',
'.....',
'.....',
'.....',
'....#',
],
],
medium: [
// 6x6 corner-cut: len-4 corners, len-6 spines.
[
'#....#',
'......',
'......',
'......',
'......',
'#....#',
],
],
hard: [
// 7x7 with symmetric corner blocks + center split. Runs of length 3, 5, 7.
[
'##...##',
'#.....#',
'.......',
'...#...',
'.......',
'#.....#',
'##...##',
],
],
};
const TIER_SIZE = { easy: 5, medium: 6, hard: 7 };
// ── Word list ───────────────────────────────────────────────────────────────────
// Returns { byLen: Map<len, string[]>, index: Map<len, Array<Map<letter, Set<idx>>>> }.
// The index lets us find words matching a partial pattern by intersecting the
// candidate sets of the already-fixed letter positions.
function loadWords() {
const raw = fs.readFileSync(WORDLIST_PATH, 'utf8');
const byLen = new Map();
for (const line of raw.split('\n')) {
const w = line.trim().toUpperCase();
if (w.length < 3 || w.length > 7 || !/^[A-Z]+$/.test(w)) continue;
if (!byLen.has(w.length)) byLen.set(w.length, []);
byLen.get(w.length).push(w);
}
const index = new Map();
const sets = new Map();
for (const [len, words] of byLen) {
sets.set(len, new Set(words));
const positions = Array.from({ length: len }, () => new Map());
words.forEach((w, i) => {
for (let p = 0; p < len; p++) {
const m = positions[p];
const ch = w[p];
if (!m.has(ch)) m.set(ch, new Set());
m.get(ch).add(i);
}
});
index.set(len, positions);
}
return { byLen, index, sets };
}
// Words of `len` whose letters match `pattern` (array of letter|null), excluding
// any already in `used`. Intersects the smallest fixed-position sets first.
function candidates({ byLen, index }, len, pattern, used) {
const fixed = [];
for (let p = 0; p < len; p++) {
if (pattern[p]) fixed.push([p, pattern[p]]);
}
const words = byLen.get(len) || [];
if (!fixed.length) {
return words.filter((w) => !used.has(w));
}
const positions = index.get(len);
const sets = fixed.map(([p, ch]) => positions[p].get(ch) || new Set());
if (sets.some((s) => s.size === 0)) return [];
sets.sort((a, b) => a.size - b.size);
const out = [];
for (const i of sets[0]) {
let ok = true;
for (let k = 1; k < sets.length; k++) {
if (!sets[k].has(i)) { ok = false; break; }
}
if (!ok) continue;
const w = words[i];
if (!used.has(w)) out.push(w);
}
return out;
}
// ── Slot extraction (matches the engine's numbering convention) ─────────────────
function buildGrid(template) {
return template.map((row) => row.split(''));
}
function isCell(grid, r, c) {
return r >= 0 && r < grid.length && c >= 0 && c < grid[0].length && grid[r][c] !== BLOCK;
}
// Returns numbered across/down slots; each slot lists its cells in order.
function deriveSlots(grid) {
const across = [];
const down = [];
let number = 0;
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[0].length; c++) {
if (!isCell(grid, r, c)) continue;
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
const startsDown = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c);
if (!startsAcross && !startsDown) continue;
number += 1;
if (startsAcross) {
const cells = [];
let cc = c;
while (isCell(grid, r, cc)) { cells.push([r, cc]); cc += 1; }
across.push({ number, row: r, col: c, len: cells.length, cells });
}
if (startsDown) {
const cells = [];
let rr = r;
while (isCell(grid, rr, c)) { cells.push([rr, c]); rr += 1; }
down.push({ number, row: r, col: c, len: cells.length, cells });
}
}
}
return [...across, ...down]; // combined slot list for the solver
}
// ── Backtracking solver ─────────────────────────────────────────────────────────
function patternFor(grid, slot) {
return slot.cells.map(([r, c]) => (grid[r][c] === EMPTY ? null : grid[r][c]));
}
function isFilled(grid, slot) {
return slot.cells.every(([r, c]) => grid[r][c] !== EMPTY);
}
function wordAt(grid, slot) {
return slot.cells.map(([r, c]) => grid[r][c]).join('');
}
function placeWord(grid, slot, word) {
const prev = slot.cells.map(([r, c]) => grid[r][c]);
slot.cells.forEach(([r, c], i) => { grid[r][c] = word[i]; });
return prev;
}
function unplace(grid, slot, prev) {
slot.cells.forEach(([r, c], i) => { grid[r][c] = prev[i]; });
}
// Fills `grid` so every slot is a distinct valid word. Every slot is explicitly
// assigned a candidate word — a slot already fully determined by crossing words
// simply has one candidate (if that spelling is a real word) or zero (if not), so
// invalid crossings are pruned automatically. Mutates grid in place; returns true
// on success. `assigned` tracks resolved slots; `deadline` is an epoch-ms budget.
function solve(grid, slots, words, used, assigned, deadline) {
if (Date.now() > deadline) return false;
// MRV: assign the unresolved slot with the fewest candidates first. Slots fixed
// by crossings collapse to <=1 candidate and resolve immediately.
let target = null;
let targetCands = null;
for (const slot of slots) {
if (assigned.has(slot)) continue;
const cands = candidates(words, slot.len, patternFor(grid, slot), used);
if (cands.length === 0) return false;
if (!targetCands || cands.length < targetCands.length) {
target = slot;
targetCands = cands;
if (cands.length === 1) break;
}
}
if (!target) return true; // every slot assigned
shuffle(targetCands);
for (const word of targetCands) {
const prev = placeWord(grid, target, word);
used.add(word);
assigned.add(target);
if (solve(grid, slots, words, used, assigned, deadline)) return true;
assigned.delete(target);
used.delete(word);
unplace(grid, target, prev);
}
return false;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ── Puzzle assembly ─────────────────────────────────────────────────────────────
// Re-derive ordered across/down answer lists from a solved grid (numbering order).
function answersFromGrid(grid) {
const across = [];
const down = [];
let number = 0;
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[0].length; c++) {
if (!isCell(grid, r, c)) continue;
const sa = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
const sd = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c);
if (!sa && !sd) continue;
number += 1;
if (sa) { let s = '', cc = c; while (isCell(grid, r, cc)) { s += grid[r][cc]; cc++; } across.push({ number, answer: s }); }
if (sd) { let s = '', rr = r; while (isCell(grid, rr, c)) { s += grid[rr][c]; rr++; } down.push({ number, answer: s }); }
}
}
return { across, down };
}
function generateOne(template, words, timeoutMs = 800) {
const grid = buildGrid(template);
const slots = deriveSlots(grid);
const used = new Set();
const ok = solve(grid, slots, words, used, new Set(), Date.now() + timeoutMs);
if (!ok) return null;
// Safety net: every across/down answer must be a real common word and unique.
const seen = new Set();
for (const slot of slots) {
const w = wordAt(grid, slot);
if (!words.sets.get(slot.len)?.has(w) || seen.has(w)) return null;
seen.add(w);
}
return grid.map((row) => row.join(''));
}
function main() {
const perTier = parseInt(process.argv[2], 10) || 20;
const outFile = process.argv[3] || path.join(__dirname, '../data/crosswords/_generated.json');
console.log('Loading ENABLE word list...');
const words = loadWords();
console.log(` ${[...words.byLen].map(([l, a]) => `${l}:${a.length}`).join(' ')}`);
const bank = [];
for (const tier of ['easy', 'medium', 'hard']) {
const templates = TEMPLATES[tier];
const seen = new Set();
let made = 0;
let attempts = 0;
const maxAttempts = perTier * 80;
const t0 = Date.now();
while (made < perTier && attempts < maxAttempts) {
attempts += 1;
const template = templates[attempts % templates.length];
const rows = generateOne(template, words);
if (process.env.CW_DEBUG && attempts % 5 === 0) {
console.log(` [${tier}] attempt ${attempts}, made ${made}, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
if (!rows) continue;
const key = rows.join('|');
if (seen.has(key)) continue;
seen.add(key);
const { across, down } = answersFromGrid(buildGrid(rows));
bank.push({
id: `${tier}-${String(made + 1).padStart(3, '0')}`,
difficulty: tier,
grid: rows,
// Reference answers (NOT consumed by the engine) to author clues against:
_answersAcross: across.map((a) => `${a.number}. ${a.answer}`),
_answersDown: down.map((a) => `${a.number}. ${a.answer}`),
across: across.map(() => ''),
down: down.map(() => ''),
});
made += 1;
}
console.log(`${tier} (${TIER_SIZE[tier]}x${TIER_SIZE[tier]}): ${made}/${perTier} grids in ${attempts} attempts, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
fs.writeFileSync(outFile, JSON.stringify(bank, null, 2));
console.log(`\nWrote ${bank.length} candidate puzzles to ${outFile}`);
console.log('Fill the empty across/down clue arrays; _answers* fields are references.');
}
main();

View File

@ -1,10 +1,11 @@
// Mini Crossword engine: serves curated 5x5 puzzles from a hand-authored bank.
// Mini Crossword engine: serves curated puzzles from a hand-authored bank.
// Pure logic — no Express. Loaded once at server start.
//
// A puzzle is authored as { id, difficulty, grid:[5 row strings], across:[5],
// down:[5] }. Grids are fixed 5x5; a '#' marks a black square. Across/Down clue
// arrays are ordered by row index / column index respectively. This engine
// derives the standard crossword numbering and pairs each clue with its slot.
// A puzzle is authored as { id, difficulty, grid:[row strings], across:[...],
// down:[...] }. Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7.
// A '#' marks a black square. Across/Down clue arrays are ordered by the derived
// crossword numbering (reading order). This engine derives that numbering and
// pairs each clue with its slot.
import fs from 'node:fs';
import path from 'node:path';
@ -13,18 +14,19 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json');
const SIZE = 5;
const BLOCK = '#';
const DIFFICULTIES = ['easy', 'medium', 'hard'];
// Difficulty no longer means clue obscurity — it sets the grid size.
const TIER_SIZE = { easy: 5, medium: 6, hard: 7 };
const DIFFICULTIES = Object.keys(TIER_SIZE);
let byDifficulty = { easy: [], medium: [], hard: [] };
let allPuzzles = [];
// ── Slot extraction & numbering ───────────────────────────────────────────────
// Returns true when (r,c) is a letter cell (not a black square).
// Returns true when (r,c) is a letter cell (not a black square) within the grid.
function isCell(grid, r, c) {
return r >= 0 && r < SIZE && c >= 0 && c < SIZE && grid[r][c] !== BLOCK;
return r >= 0 && r < grid.length && c >= 0 && c < grid[r].length && grid[r][c] !== BLOCK;
}
// Walks the grid in reading order and builds the numbered across/down slots.
@ -36,8 +38,8 @@ function deriveSlots(grid) {
const down = [];
let number = 0;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
if (!isCell(grid, r, c)) continue;
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
@ -66,12 +68,17 @@ function deriveSlots(grid) {
// ── Validation ────────────────────────────────────────────────────────────────
function validatePuzzle(p) {
if (!Array.isArray(p.grid) || p.grid.length !== SIZE) {
throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`);
const size = TIER_SIZE[p.difficulty];
if (!size) {
throw new Error(`puzzle ${p.id}: unknown difficulty '${p.difficulty}'`);
}
if (!Array.isArray(p.grid) || p.grid.length !== size) {
throw new Error(`puzzle ${p.id}: ${p.difficulty} grid must have ${size} rows`);
}
const rowRe = new RegExp(`^[A-Z#]{${size}}$`);
for (const row of p.grid) {
if (typeof row !== 'string' || row.length !== SIZE || !/^[A-Z#]{5}$/.test(row)) {
throw new Error(`puzzle ${p.id}: each row must be ${SIZE} chars of A-Z or '#'`);
if (typeof row !== 'string' || !rowRe.test(row)) {
throw new Error(`puzzle ${p.id}: each row must be ${size} chars of A-Z or '#'`);
}
}
const { across, down } = deriveSlots(p.grid);
@ -101,8 +108,7 @@ export function initMiniCrosswordPuzzles() {
const bank = JSON.parse(raw);
for (const p of bank) {
validatePuzzle(p);
const diff = DIFFICULTIES.includes(p.difficulty) ? p.difficulty : 'medium';
byDifficulty[diff].push(p);
byDifficulty[p.difficulty].push(p);
allPuzzles.push(p);
}
return { puzzles: allPuzzles.length };
@ -112,10 +118,10 @@ export function initMiniCrosswordPuzzles() {
// Returns a random puzzle for the requested difficulty, packaged with derived
// numbering. Each clue entry carries its number, start cell, length and answer.
export function getPuzzle(difficulty = 'medium') {
export function getPuzzle(difficulty = 'easy') {
const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles;
if (!bucket.length) {
return { id: null, difficulty, rows: SIZE, cols: SIZE, grid: [], across: [], down: [] };
return { id: null, difficulty, rows: 0, cols: 0, grid: [], across: [], down: [] };
}
const p = bucket[Math.floor(Math.random() * bucket.length)];
@ -124,8 +130,8 @@ export function getPuzzle(difficulty = 'medium') {
return {
id: p.id,
difficulty: p.difficulty,
rows: SIZE,
cols: SIZE,
rows: p.grid.length,
cols: p.grid[0].length,
grid: p.grid,
across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })),
down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })),

View File

@ -0,0 +1,317 @@
// Tectonic (a.k.a. Suguru) generator.
//
// Rules:
// • The grid is partitioned into irregular regions ("cages") of 15 cells.
// • A region of N cells contains the numbers 1..N (so digits never exceed 5).
// • No two cells that touch orthogonally OR diagonally (king-move / 8-way)
// may hold the same number.
//
// generatePuzzle(difficulty) → { grid, solution, regions, difficulty }
// grid : 8×8, 0 = empty cell the player must fill
// solution : 8×8 fully-solved board
// regions : 8×8 of integer region IDs (which cage each cell belongs to)
//
// Generation:
// 1. Partition the grid into 15-cell regions (randomized greedy growth).
// 2. Fill a random valid solution (backtracking on flat Int8Array boards with
// precomputed peer lists and most-constrained-variable ordering).
// 3. Dig holes: remove a cell only while the remaining givens are still
// uniquely solvable *by logic alone* (naked singles + hidden singles in a
// region). Logical solvability proves a unique solution AND guarantees the
// puzzle is human-solvable without guessing. The propagation solver is
// polynomial, so digging is fast and low-variance (unlike brute-force
// solution-counting, which blows up on sparse Tectonic boards).
const N = 8;
const CELLS = N * N;
const ORTHO = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const KING = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function inBounds(r, c) {
return r >= 0 && r < N && c >= 0 && c < N;
}
function orthoNeighbors(r, c) {
const out = [];
for (const [dr, dc] of ORTHO) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc)) out.push([nr, nc]);
}
return out;
}
function bitToValue(bit) {
return bit === 1 ? 1 : bit === 2 ? 2 : bit === 4 ? 3 : bit === 8 ? 4 : 5;
}
// ── Region partition ────────────────────────────────────────────────────────
// Greedy growth that seeds the most-constrained free cell (fewest free
// neighbours) and grows toward size 5, always extending into the tightest
// pocket. This keeps stranded size-1 regions rare without forbidding them.
function partition() {
const regions = Array.from({ length: N }, () => Array(N).fill(-1));
let unassigned = CELLS;
let nextId = 0;
const freeNeighborCount = (r, c) => {
let cnt = 0;
for (const [nr, nc] of orthoNeighbors(r, c))
if (regions[nr][nc] === -1) cnt++;
return cnt;
};
while (unassigned > 0) {
const free = [];
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (regions[r][c] === -1) free.push([r, c]);
shuffle(free);
let seed = free[0], bestSeed = Infinity;
for (const [r, c] of free) {
const u = freeNeighborCount(r, c);
if (u < bestSeed) { bestSeed = u; seed = [r, c]; }
}
const id = nextId++;
const members = [seed];
regions[seed[0]][seed[1]] = id;
while (members.length < 5) {
const frontier = [];
for (const [mr, mc] of members)
for (const [nr, nc] of orthoNeighbors(mr, mc))
if (regions[nr][nc] === -1) frontier.push([nr, nc]);
if (frontier.length === 0) break;
shuffle(frontier);
let pick = frontier[0], bestPick = Infinity;
for (const [r, c] of frontier) {
const u = freeNeighborCount(r, c);
if (u < bestPick) { bestPick = u; pick = [r, c]; }
}
regions[pick[0]][pick[1]] = id;
members.push(pick);
}
unassigned -= members.length;
}
return regions;
}
// Precompute flat lookup tables from a 2D region grid.
// sizeOf[i] : size of i's region (max value allowed in i)
// peers[i] : Int16Array of region-mates + king-neighbours (excl. self)
// byRegion : Map regionId -> array of cell indices
function buildTables(regions) {
const regionOf = new Int16Array(CELLS);
const byRegion = new Map();
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const i = r * N + c;
const id = regions[r][c];
regionOf[i] = id;
if (!byRegion.has(id)) byRegion.set(id, []);
byRegion.get(id).push(i);
}
const sizeOf = new Int8Array(CELLS);
const peers = new Array(CELLS);
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const i = r * N + c;
const fellows = byRegion.get(regionOf[i]);
sizeOf[i] = fellows.length;
const set = new Set();
for (const j of fellows) if (j !== i) set.add(j);
for (const [dr, dc] of KING) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc)) set.add(nr * N + nc);
}
peers[i] = Int16Array.from(set);
}
return { sizeOf, peers, byRegion };
}
// ── Solution fill (backtracking) ──────────────────────────────────────────────
function valueOk(g, peers, i, v) {
const p = peers[i];
for (let k = 0; k < p.length; k++) if (g[p[k]] === v) return false;
return true;
}
// Most-constrained empty cell. Returns { idx, cands } or null when full;
// short-circuits on the first forced (0- or 1-candidate) cell.
function findBestCell(g, sizeOf, peers) {
let bestIdx = -1, bestCands = null, bestLen = 99;
for (let i = 0; i < CELLS; i++) {
if (g[i] !== 0) continue;
const max = sizeOf[i];
const cands = [];
for (let v = 1; v <= max; v++) if (valueOk(g, peers, i, v)) cands.push(v);
if (cands.length < bestLen) {
bestLen = cands.length; bestIdx = i; bestCands = cands;
if (bestLen <= 1) return { idx: bestIdx, cands: bestCands };
}
}
return bestIdx === -1 ? null : { idx: bestIdx, cands: bestCands };
}
// Fills g in place with one random valid solution. Returns true on success,
// false if unsatisfiable, or null if the node budget was exhausted.
function solveOne(g, sizeOf, peers, budget) {
let nodes = 0;
function rec() {
if (++nodes > budget) return null;
const next = findBestCell(g, sizeOf, peers);
if (!next) return true;
if (next.cands.length === 0) return false;
const { idx } = next;
for (const v of shuffle([...next.cands])) {
g[idx] = v;
const r = rec();
if (r === true || r === null) return r;
g[idx] = 0;
}
return false;
}
return rec();
}
// ── Logic solver (naked + hidden singles) ─────────────────────────────────────
// Returns true iff `given` is fully solvable by propagation alone — which proves
// the solution is unique and the puzzle needs no guessing.
function logicSolves(given, tables) {
const { sizeOf, peers, byRegion } = tables;
const val = new Int8Array(CELLS);
const cand = new Int8Array(CELLS);
for (let i = 0; i < CELLS; i++) cand[i] = (1 << sizeOf[i]) - 1;
const assign = (i, v) => {
val[i] = v;
cand[i] = 1 << (v - 1);
const p = peers[i];
const mask = ~(1 << (v - 1));
for (let k = 0; k < p.length; k++)
if (val[p[k]] === 0) cand[p[k]] &= mask;
};
for (let i = 0; i < CELLS; i++) if (given[i] !== 0) assign(i, given[i]);
let progress = true;
while (progress) {
progress = false;
// Naked singles
for (let i = 0; i < CELLS; i++) {
if (val[i] !== 0) continue;
const c = cand[i];
if (c === 0) return false; // contradiction
if ((c & (c - 1)) === 0) { // exactly one bit
assign(i, bitToValue(c));
progress = true;
}
}
if (progress) continue;
// Hidden singles within each region
for (const cells of byRegion.values()) {
const size = cells.length;
for (let v = 1; v <= size; v++) {
const bit = 1 << (v - 1);
let count = 0, where = -1, present = false;
for (const i of cells) {
if (val[i] === v) { present = true; break; }
if (val[i] === 0 && (cand[i] & bit)) { count++; where = i; }
}
if (present) continue;
if (count === 0) return false; // value has nowhere to go
if (count === 1) { assign(where, v); progress = true; }
}
}
}
for (let i = 0; i < CELLS; i++) if (val[i] === 0) return false;
return true;
}
// ── Hole digging (logic-guarded) ──────────────────────────────────────────────
const GIVENS = { easy: 30, medium: 24, hard: 18 };
function dig(solution, tables, target) {
const given = Int8Array.from(solution);
const order = shuffle([...Array(CELLS).keys()]);
let givens = CELLS;
for (const i of order) {
if (givens <= target) break;
const saved = given[i];
given[i] = 0;
if (logicSolves(given, tables)) givens--;
else given[i] = saved;
}
return { given, givens };
}
// ── Public API ───────────────────────────────────────────────────────────────
function buildSolved(deadline) {
for (let attempt = 0; attempt < 200; attempt++) {
if (Date.now() > deadline) return null;
const regions = partition();
const tables = buildTables(regions);
const sol = new Int8Array(CELLS);
if (solveOne(sol, tables.sizeOf, tables.peers, 30000) === true)
return { regions, tables, sol };
}
return null;
}
function toGrid(flat) {
const out = [];
for (let r = 0; r < N; r++) {
const row = [];
for (let c = 0; c < N; c++) row.push(flat[r * N + c]);
out.push(row);
}
return out;
}
export function generatePuzzle(difficulty) {
const target = GIVENS[difficulty] ?? GIVENS.medium;
const deadline = Date.now() + 3000;
let best = null;
while (Date.now() < deadline) {
const built = buildSolved(deadline);
if (!built) break;
const { regions, tables, sol } = built;
const { given, givens } = dig(sol, tables, target);
if (givens <= target) {
return { grid: toGrid(given), solution: toGrid(sol), regions, difficulty };
}
if (!best || givens < best.givens) {
best = { grid: toGrid(given), solution: toGrid(sol), regions, givens };
}
}
if (!best) throw new Error('tectonic: failed to generate a board');
return { grid: best.grid, solution: best.solution, regions: best.regions, difficulty };
}

View File

@ -17,6 +17,7 @@ import {
listThemes as wordSearchThemes,
} from './wordSearchEngine.js';
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
import { generatePuzzle as tectonicGenerate } from './tectonicEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
@ -156,7 +157,7 @@ function loadWordLists() {
const beeStats = initSpellingBeeDictionary(allWords);
console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`);
// Mini Crossword: curated 5x5 puzzle bank (independent of the ENABLE list).
// Mini Crossword: curated puzzle bank, 5x5/6x6/7x7 by difficulty (independent of the ENABLE list).
const crosswordStats = initMiniCrosswordPuzzles();
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
@ -216,10 +217,11 @@ router.get('/spellingbee/start', (req, res) => {
// ── Mini Crossword ────────────────────────────────────────────────────────────
// GET /api/words/minicrossword/start?difficulty=easy|medium|hard
// Returns a curated 5x5 puzzle (grid + numbered across/down clues with answers).
// Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7. Returns the
// grid + numbered across/down clues (with answers).
router.get('/minicrossword/start', (req, res) => {
const VALID = ['easy', 'medium', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'medium';
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'easy';
res.json(miniCrosswordGet(difficulty));
});
@ -425,4 +427,11 @@ router.get('/sudoku/start', (req, res) => {
res.json(sudokuGenerate(difficulty));
});
// GET /api/words/tectonic/start?difficulty=easy|medium|hard
router.get('/tectonic/start', (req, res) => {
const VALID = ['easy', 'medium', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'medium';
res.json(tectonicGenerate(difficulty));
});
export default router;