feat: add Mini Crossword game (5x5 curated puzzles, single-player)
- Frontend: Phaser scene for grid/clue rendering, keyboard navigation, win detection, and difficulty selection. - Logic: Pure helpers for slot mapping, cell validation, and answer checking. - Backend: Puzzle engine, JSON bank of 15 puzzles across 3 difficulties, API route, and game registry. - Integrates into existing game menu and routing.
This commit is contained in:
parent
3e975521fb
commit
9dda7f4487
|
|
@ -0,0 +1,453 @@
|
|||
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 {
|
||||
numberMap, cellsForSlot, slotAt, isSolved, wrongCells,
|
||||
} from './MiniCrosswordLogic.js';
|
||||
|
||||
// ── Palette / styling ──────────────────────────────────────────────────────────
|
||||
const FELT = 0x12100a; // deep background
|
||||
const PAPER = 0x1e1a12; // panels
|
||||
const GOLD = 0xd4a017;
|
||||
const GOLD_DARK = 0xb8860b;
|
||||
const CELL = 0xf2ead8; // empty letter cell
|
||||
const CELL_WORD = 0xefdca6; // cells of the active word
|
||||
const CELL_SEL = 0xf3c75a; // the selected cell
|
||||
const BLOCK_FILL = 0x0c0a07; // black square
|
||||
const EDGE = 0x4a4030;
|
||||
const INK_DARK = '#1a1208'; // letters on light cells
|
||||
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;
|
||||
|
||||
export default class MiniCrosswordGame extends Phaser.Scene {
|
||||
constructor() { super('MiniCrosswordGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
|
||||
this.puzzle = null;
|
||||
this.grid = []; // solution rows (strings)
|
||||
this.across = [];
|
||||
this.down = [];
|
||||
this.entries = {}; // "r,c" -> letter
|
||||
this.cells = {}; // "r,c" -> { rect, txt }
|
||||
this.numbers = {}; // "r,c" -> number
|
||||
|
||||
this.sel = { row: 0, col: 0 };
|
||||
this.dir = 'across';
|
||||
this.ended = false;
|
||||
|
||||
this.startObjs = [];
|
||||
}
|
||||
|
||||
create() {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
|
||||
.setDepth(DEPTH.bg);
|
||||
|
||||
this.add.text(40, 36, 'MINI CROSSWORD', {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
this.events.once('shutdown', () => this.cleanup());
|
||||
this.showStartPanel();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.keyHandler) {
|
||||
this.input.keyboard.off('keydown', this.keyHandler);
|
||||
this.keyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
showStartPanel() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
|
||||
const sheet = this.add.graphics().setDepth(DEPTH.panel);
|
||||
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
|
||||
sheet.fillStyle(PAPER, 1);
|
||||
sheet.fillRoundedRect(cx - 480, cy - 280, 960, 560, 20);
|
||||
sheet.lineStyle(3, GOLD_DARK, 1);
|
||||
sheet.strokeRoundedRect(cx - 480, cy - 280, 960, 560, 20);
|
||||
this.startObjs.push(sheet);
|
||||
|
||||
this.startObjs.push(this.add.text(cx, cy - 190, 'Mini Crossword', {
|
||||
fontFamily: 'Righteous', fontSize: '88px', color: TITLE_GOLD,
|
||||
}).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.', {
|
||||
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', {
|
||||
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,
|
||||
() => this.startPuzzle(id),
|
||||
{ width: 230, height: 68, fontSize: 28, bgHover: GOLD });
|
||||
b.setDepth(DEPTH.ui);
|
||||
this.startObjs.push(b);
|
||||
});
|
||||
}
|
||||
|
||||
async startPuzzle(difficulty) {
|
||||
let data;
|
||||
try {
|
||||
data = await api.get(`/words/minicrossword/start?difficulty=${difficulty}`);
|
||||
} catch (err) {
|
||||
console.error('[minicrossword] failed to fetch puzzle:', err);
|
||||
return;
|
||||
}
|
||||
if (!data?.grid?.length) {
|
||||
console.error('[minicrossword] empty puzzle payload');
|
||||
return;
|
||||
}
|
||||
|
||||
this.startObjs.forEach((o) => o.destroy());
|
||||
this.startObjs = [];
|
||||
|
||||
this.puzzle = data;
|
||||
this.grid = data.grid;
|
||||
this.across = data.across ?? [];
|
||||
this.down = data.down ?? [];
|
||||
this.numbers = numberMap(this.across, this.down);
|
||||
|
||||
this.buildBoard();
|
||||
}
|
||||
|
||||
// ── Board ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
buildBoard() {
|
||||
this.buildClueBanner();
|
||||
this.buildGrid();
|
||||
this.buildCluePanel();
|
||||
this.buildControls();
|
||||
this.bindKeyboard();
|
||||
|
||||
// Start on the first across answer.
|
||||
const first = this.across[0] ?? this.down[0];
|
||||
if (first) {
|
||||
this.dir = this.across[0] ? 'across' : 'down';
|
||||
this.setSelection(first.row, first.col, false);
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
cellCenter(r, c) {
|
||||
return {
|
||||
x: GRID_LEFT + c * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: GRID_TOP + r * CELL_SIZE + CELL_SIZE / 2,
|
||||
};
|
||||
}
|
||||
|
||||
buildGrid() {
|
||||
for (let r = 0; r < this.grid.length; r++) {
|
||||
for (let c = 0; c < this.grid[r].length; c++) {
|
||||
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,
|
||||
block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell);
|
||||
if (block) continue;
|
||||
|
||||
rect.setInteractive({ useHandCursor: true });
|
||||
rect.on('pointerdown', () => this.onCellClick(r, c));
|
||||
|
||||
const txt = this.add.text(x, y + 6, '', {
|
||||
fontFamily: 'Righteous', fontSize: '58px', 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,
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
|
||||
}
|
||||
|
||||
this.cells[`${r},${c}`] = { rect, txt };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildClueBanner() {
|
||||
const gridW = this.grid[0].length * CELL_SIZE;
|
||||
this.banner = this.add.text(GRID_LEFT + gridW / 2, GRID_TOP - 70, '', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
|
||||
align: 'center', wordWrap: { width: gridW + 40 },
|
||||
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildCluePanel() {
|
||||
const PX = 1010, PY = 150, PW = 850, PH = 760;
|
||||
const g = this.add.graphics().setDepth(DEPTH.panel);
|
||||
g.fillStyle(PAPER, 1);
|
||||
g.fillRoundedRect(PX, PY, PW, PH, 18);
|
||||
g.lineStyle(3, EDGE, 1);
|
||||
g.strokeRoundedRect(PX, PY, PW, PH, 18);
|
||||
|
||||
this.clueTexts = {}; // "across:idx" / "down:idx" -> text object
|
||||
|
||||
const columns = [
|
||||
{ title: 'ACROSS', dir: 'across', slots: this.across, x: PX + 40 },
|
||||
{ title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 },
|
||||
];
|
||||
|
||||
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 t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
wordWrap: { width: 360 },
|
||||
}).setDepth(DEPTH.cellTxt);
|
||||
t.setInteractive({ useHandCursor: true });
|
||||
t.on('pointerdown', () => this.selectClue(col.dir, slot));
|
||||
this.clueTexts[`${col.dir}:${i}`] = { t, slot };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
buildControls() {
|
||||
const y = GRID_TOP + this.grid.length * CELL_SIZE + 70;
|
||||
this.checkBtn = new Button(this, GRID_LEFT + 120, y, 'Check',
|
||||
() => this.checkGrid(), { width: 200, height: 60, fontSize: 24 });
|
||||
this.revealBtn = new Button(this, GRID_LEFT + 350, y, 'Reveal Word',
|
||||
() => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD });
|
||||
[this.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui));
|
||||
|
||||
const leaveBtn = new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 44, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 140, height: 52, fontSize: 20 });
|
||||
leaveBtn.setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Selection & active word ─────────────────────────────────────────────────────
|
||||
|
||||
activeSlot() {
|
||||
const slots = this.dir === 'across' ? this.across : this.down;
|
||||
let slot = slotAt(slots, this.dir, this.sel.row, this.sel.col);
|
||||
if (!slot) {
|
||||
// No word in this direction through the cell — try the other direction.
|
||||
const other = this.dir === 'across' ? 'down' : 'across';
|
||||
const otherSlots = other === 'across' ? this.across : this.down;
|
||||
slot = slotAt(otherSlots, other, this.sel.row, this.sel.col);
|
||||
if (slot) this.dir = other;
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
setSelection(r, c, toggleIfSame = true) {
|
||||
if (this.grid[r][c] === '#') return;
|
||||
if (toggleIfSame && this.sel.row === r && this.sel.col === c) {
|
||||
this.dir = this.dir === 'across' ? 'down' : 'across';
|
||||
}
|
||||
this.sel = { row: r, col: c };
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onCellClick(r, c) {
|
||||
if (this.ended) return;
|
||||
this.setSelection(r, c, true);
|
||||
}
|
||||
|
||||
selectClue(dir, slot) {
|
||||
if (this.ended) return;
|
||||
this.dir = dir;
|
||||
this.sel = { row: slot.row, col: slot.col };
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// ── Rendering refresh ────────────────────────────────────────────────────────────
|
||||
|
||||
refresh() {
|
||||
const slot = this.activeSlot();
|
||||
const wordKeys = new Set(
|
||||
slot ? cellsForSlot(slot, this.dir).map((p) => `${p.row},${p.col}`) : [],
|
||||
);
|
||||
const selKey = `${this.sel.row},${this.sel.col}`;
|
||||
|
||||
for (const [key, cell] of Object.entries(this.cells)) {
|
||||
let fill = CELL;
|
||||
if (key === selKey) fill = CELL_SEL;
|
||||
else if (wordKeys.has(key)) fill = CELL_WORD;
|
||||
cell.rect.setFillStyle(fill);
|
||||
cell.txt.setText(this.entries[key] ?? '');
|
||||
}
|
||||
|
||||
// Highlight the active clue in the side panel.
|
||||
if (this.clueTexts) {
|
||||
for (const { t } of Object.values(this.clueTexts)) t.setColor(COLORS.textHex);
|
||||
if (slot) {
|
||||
const slots = this.dir === 'across' ? this.across : this.down;
|
||||
const idx = slots.indexOf(slot);
|
||||
const entry = this.clueTexts[`${this.dir}:${idx}`];
|
||||
if (entry) entry.t.setColor(TITLE_GOLD);
|
||||
this.banner.setText(`${slot.number} ${this.dir === 'across' ? 'Across' : 'Down'}: ${slot.clue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard input ───────────────────────────────────────────────────────────────
|
||||
|
||||
bindKeyboard() {
|
||||
this.keyHandler = (event) => {
|
||||
if (this.ended) return;
|
||||
const key = event.key;
|
||||
if (/^[a-zA-Z]$/.test(key)) {
|
||||
this.typeLetter(key.toUpperCase());
|
||||
} else if (key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
this.backspace();
|
||||
} else if (key === ' ') {
|
||||
event.preventDefault();
|
||||
this.dir = this.dir === 'across' ? 'down' : 'across';
|
||||
this.refresh();
|
||||
} else if (key.startsWith('Arrow')) {
|
||||
event.preventDefault();
|
||||
this.moveArrow(key);
|
||||
}
|
||||
};
|
||||
this.input.keyboard.on('keydown', this.keyHandler);
|
||||
}
|
||||
|
||||
typeLetter(letter) {
|
||||
const key = `${this.sel.row},${this.sel.col}`;
|
||||
this.entries[key] = letter;
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.advance();
|
||||
this.refresh();
|
||||
if (isSolved(this.grid, this.entries)) this.handleWin();
|
||||
}
|
||||
|
||||
backspace() {
|
||||
const key = `${this.sel.row},${this.sel.col}`;
|
||||
if (this.entries[key]) {
|
||||
delete this.entries[key];
|
||||
} else {
|
||||
this.advance(-1);
|
||||
delete this.entries[`${this.sel.row},${this.sel.col}`];
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// Step to the next (dir=1) or previous (dir=-1) cell of the active word.
|
||||
advance(step = 1) {
|
||||
const slot = this.activeSlot();
|
||||
if (!slot) return;
|
||||
const cells = cellsForSlot(slot, this.dir);
|
||||
const idx = cells.findIndex((p) => p.row === this.sel.row && p.col === this.sel.col);
|
||||
const next = cells[idx + step];
|
||||
if (next) this.sel = { row: next.row, col: next.col };
|
||||
}
|
||||
|
||||
moveArrow(arrow) {
|
||||
let { row, col } = this.sel;
|
||||
const deltas = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
|
||||
const [dr, dc] = deltas[arrow];
|
||||
// Prefer the direction the arrow implies.
|
||||
this.dir = (dr !== 0) ? 'down' : 'across';
|
||||
let nr = row + dr, nc = col + dc;
|
||||
while (nr >= 0 && nr < this.grid.length && nc >= 0 && nc < this.grid[0].length) {
|
||||
if (this.grid[nr][nc] !== '#') { this.sel = { row: nr, col: nc }; break; }
|
||||
nr += dr; nc += dc;
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// ── Helpers (Check / Reveal) ─────────────────────────────────────────────────────
|
||||
|
||||
checkGrid() {
|
||||
if (this.ended) return;
|
||||
const wrong = wrongCells(this.grid, this.entries);
|
||||
if (!wrong.length) { playSound(this, SFX.PIECE_CLICK); return; }
|
||||
wrong.forEach((key) => {
|
||||
const cell = this.cells[key];
|
||||
if (!cell) return;
|
||||
cell.txt.setColor('#e06c75');
|
||||
this.tweens.add({
|
||||
targets: cell.txt, alpha: { from: 1, to: 0.2 }, yoyo: true, repeat: 2,
|
||||
duration: 160, onComplete: () => cell.txt.setColor(INK_DARK).setAlpha(1),
|
||||
});
|
||||
});
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
}
|
||||
|
||||
revealWord() {
|
||||
if (this.ended) return;
|
||||
const slot = this.activeSlot();
|
||||
if (!slot) return;
|
||||
cellsForSlot(slot, this.dir).forEach((p) => {
|
||||
this.entries[`${p.row},${p.col}`] = this.grid[p.row][p.col];
|
||||
});
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
this.refresh();
|
||||
if (isSolved(this.grid, this.entries)) this.handleWin();
|
||||
}
|
||||
|
||||
// ── Win ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
handleWin() {
|
||||
this.ended = true;
|
||||
this.cleanup();
|
||||
playSound(this, SFX.CASINO_WIN);
|
||||
this.recordResult('win');
|
||||
this.time.delayedCall(400, () => this.showWin());
|
||||
}
|
||||
|
||||
showWin() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(DEPTH.overlay);
|
||||
|
||||
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
|
||||
panel.fillStyle(PAPER, 1);
|
||||
panel.fillRoundedRect(cx - 400, cy - 200, 800, 400, 20);
|
||||
panel.lineStyle(3, GOLD_DARK, 1);
|
||||
panel.strokeRoundedRect(cx - 400, cy - 200, 800, 400, 20);
|
||||
|
||||
this.add.text(cx, cy - 90, 'Solved!', {
|
||||
fontFamily: 'Righteous', fontSize: '96px', color: TITLE_GOLD,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
|
||||
|
||||
this.add.text(cx, cy + 10, 'You completed the mini crossword.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '32px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
|
||||
|
||||
new Button(this, cx - 160, cy + 120, 'New Puzzle',
|
||||
() => this.scene.restart(this._initData),
|
||||
{ width: 280, height: 64, fontSize: 26, bgHover: GOLD }).setDepth(DEPTH.overlayUI);
|
||||
new Button(this, cx + 160, cy + 120, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 280, height: 64, fontSize: 26 }).setDepth(DEPTH.overlayUI);
|
||||
}
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'minicrossword', score: this.grid.length * this.grid[0].length, opponentScores: [], result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// Pure helpers for Mini Crossword — no Phaser. The server returns the solution
|
||||
// grid plus numbered across/down slots; these helpers operate on that data and
|
||||
// the player's per-cell entries (keyed "row,col").
|
||||
|
||||
export const BLOCK = '#';
|
||||
|
||||
export function isBlock(grid, r, c) {
|
||||
const row = grid[r];
|
||||
return !row || c < 0 || c >= row.length || row[c] === BLOCK;
|
||||
}
|
||||
|
||||
// Maps a starting cell "row,col" to its clue number for on-grid labels.
|
||||
export function numberMap(across, down) {
|
||||
const map = {};
|
||||
for (const s of [...across, ...down]) map[`${s.row},${s.col}`] = s.number;
|
||||
return map;
|
||||
}
|
||||
|
||||
// The ordered cells a slot covers, given its direction.
|
||||
export function cellsForSlot(slot, dir) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < slot.len; i++) {
|
||||
cells.push(dir === 'across'
|
||||
? { row: slot.row, col: slot.col + i }
|
||||
: { row: slot.row + i, col: slot.col });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// The slot in a given direction that contains (r,c), or null.
|
||||
export function slotAt(slots, dir, r, c) {
|
||||
return slots.find((s) =>
|
||||
cellsForSlot(s, dir).some((p) => p.row === r && p.col === c)) || null;
|
||||
}
|
||||
|
||||
// All non-block cells have a letter?
|
||||
export function isComplete(grid, entries) {
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] !== BLOCK && !entries[`${r},${c}`]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Every filled cell matches the solution grid?
|
||||
export function isSolved(grid, entries) {
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === BLOCK) continue;
|
||||
if (entries[`${r},${c}`] !== grid[r][c]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// "row,col" keys whose current entry is present but wrong (for Check).
|
||||
export function wrongCells(grid, entries) {
|
||||
const wrong = [];
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === BLOCK) continue;
|
||||
const e = entries[`${r},${c}`];
|
||||
if (e && e !== grid[r][c]) wrong.push(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import BoggleGame from './games/boggle/BoggleGame.js';
|
|||
import OldMaidGame from './games/oldmaid/OldMaidGame.js';
|
||||
import BlokusGame from './games/blokus/BlokusGame.js';
|
||||
import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
|
||||
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -109,6 +110,7 @@ const config = {
|
|||
OldMaidGame,
|
||||
BlokusGame,
|
||||
SpellingBeeGame,
|
||||
MiniCrosswordGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,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' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
[
|
||||
{
|
||||
"id": "easy-001",
|
||||
"difficulty": "easy",
|
||||
"grid": ["APART", "POWER", "AWARE", "RERUN", "TREND"],
|
||||
"across": [
|
||||
"Separated, as two people",
|
||||
"Electrical energy",
|
||||
"Conscious of; in the know",
|
||||
"TV episode shown again",
|
||||
"Current fashion or direction"
|
||||
],
|
||||
"down": [
|
||||
"Not together",
|
||||
"Strength or might",
|
||||
"Mindful",
|
||||
"Summer TV staple",
|
||||
"What goes viral, perhaps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-002",
|
||||
"difficulty": "easy",
|
||||
"grid": ["SMART", "POLAR", "OLIVE", "RAVEN", "TREND"],
|
||||
"across": [
|
||||
"Clever",
|
||||
"Like a bear at the North Pole",
|
||||
"Green martini garnish",
|
||||
"Edgar Allan Poe's black bird",
|
||||
"Hot new fashion"
|
||||
],
|
||||
"down": [
|
||||
"Baseball or soccer, e.g.",
|
||||
"Back tooth",
|
||||
"Living and breathing",
|
||||
"Glossy black bird",
|
||||
"Topic that's taking off online"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-003",
|
||||
"difficulty": "easy",
|
||||
"grid": ["START", "THROW", "ARGUE", "ROUTE", "TWEED"],
|
||||
"across": [
|
||||
"Begin",
|
||||
"Toss a ball",
|
||||
"Quarrel",
|
||||
"Path a bus takes",
|
||||
"Rough wool fabric for jackets"
|
||||
],
|
||||
"down": [
|
||||
"A race's beginning",
|
||||
"Pitch, as a baseball",
|
||||
"Bicker",
|
||||
"Mail carrier's territory",
|
||||
"Tweedy jacket cloth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-004",
|
||||
"difficulty": "easy",
|
||||
"grid": ["CLOSE", "LLAMA", "OASIS", "SMILE", "EASEL"],
|
||||
"across": [
|
||||
"Shut, as a door",
|
||||
"Andean pack animal",
|
||||
"Green spot in the desert",
|
||||
"Happy expression",
|
||||
"Painter's stand"
|
||||
],
|
||||
"down": [
|
||||
"Nearby",
|
||||
"Spitting Andean beast",
|
||||
"Desert refuge",
|
||||
"What a camera asks you to do",
|
||||
"Stand for a canvas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-005",
|
||||
"difficulty": "easy",
|
||||
"grid": ["MAPLE", "AGAIN", "PAINT", "LINER", "ENTRY"],
|
||||
"across": [
|
||||
"Syrup-producing tree",
|
||||
"Once more",
|
||||
"What an artist applies",
|
||||
"Big cruise ship",
|
||||
"Doorway, or a diary post"
|
||||
],
|
||||
"down": [
|
||||
"Tree on Canada's flag",
|
||||
"Time and time ___",
|
||||
"Wall color in a can",
|
||||
"Ocean-crossing vessel",
|
||||
"Way in"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-001",
|
||||
"difficulty": "medium",
|
||||
"grid": ["DROVE", "RAVEN", "OVERT", "VERVE", "ENTER"],
|
||||
"across": [
|
||||
"Operated the car; also, a herd",
|
||||
"Bird that quoth 'Nevermore'",
|
||||
"Out in the open, not hidden",
|
||||
"Energy and enthusiasm",
|
||||
"Key pressed to confirm"
|
||||
],
|
||||
"down": [
|
||||
"Chauffeured",
|
||||
"Corvid in a Poe poem",
|
||||
"Undisguised",
|
||||
"Pep and flair",
|
||||
"Go in"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-002",
|
||||
"difficulty": "medium",
|
||||
"grid": ["GRASP", "RUMOR", "AMPLE", "SOLVE", "PREEN"],
|
||||
"across": [
|
||||
"Grip firmly; comprehend",
|
||||
"Unverified bit of gossip",
|
||||
"More than enough",
|
||||
"Crack, as a puzzle",
|
||||
"Groom feathers, as a bird does"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-004",
|
||||
"difficulty": "medium",
|
||||
"grid": ["CRANE", "RESIN", "ASSET", "NIECE", "ENTER"],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hard-002",
|
||||
"difficulty": "hard",
|
||||
"grid": ["GRUFF", "RIVER", "UVULA", "FELON", "FRANK"],
|
||||
"across": [
|
||||
"Brusque and surly",
|
||||
"The Mississippi, for one",
|
||||
"Dangly bit at the back of the throat",
|
||||
"Convicted criminal",
|
||||
"Candidly blunt; or a hot dog"
|
||||
],
|
||||
"down": [
|
||||
"Hoarse and curt",
|
||||
"Flowing waterway",
|
||||
"Throat's little hanging punching bag",
|
||||
"One with a rap sheet",
|
||||
"Straight-talking; ballpark sausage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hard-003",
|
||||
"difficulty": "hard",
|
||||
"grid": ["PLUMB", "LUNAR", "UNTIE", "MAIZE", "BREED"],
|
||||
"across": [
|
||||
"Measure the depth of; dead vertical",
|
||||
"Of the moon",
|
||||
"Loosen, as shoelaces",
|
||||
"Corn, by another name",
|
||||
"Raise animals; a dog variety"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -63,3 +63,4 @@ registerGame({ slug: 'boggle', name: 'Boggle', category: 'wo
|
|||
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
// Mini Crossword engine: serves curated 5x5 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.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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'];
|
||||
|
||||
let byDifficulty = { easy: [], medium: [], hard: [] };
|
||||
let allPuzzles = [];
|
||||
|
||||
// ── Slot extraction & numbering ───────────────────────────────────────────────
|
||||
|
||||
// Returns true when (r,c) is a letter cell (not a black square).
|
||||
function isCell(grid, r, c) {
|
||||
return r >= 0 && r < SIZE && c >= 0 && c < SIZE && grid[r][c] !== BLOCK;
|
||||
}
|
||||
|
||||
// Walks the grid in reading order and builds the numbered across/down slots.
|
||||
// A cell starts an across word when it has no playable neighbour to its left and
|
||||
// at least one to its right; likewise a down word vertically. Both kinds of
|
||||
// starting cell share a single incrementing clue number (standard convention).
|
||||
function deriveSlots(grid) {
|
||||
const across = [];
|
||||
const down = [];
|
||||
let number = 0;
|
||||
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; 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) {
|
||||
let answer = '';
|
||||
let cc = c;
|
||||
while (isCell(grid, r, cc)) { answer += grid[r][cc]; cc += 1; }
|
||||
across.push({ number, row: r, col: c, len: answer.length, answer });
|
||||
}
|
||||
if (startsDown) {
|
||||
let answer = '';
|
||||
let rr = r;
|
||||
while (isCell(grid, rr, c)) { answer += grid[rr][c]; rr += 1; }
|
||||
down.push({ number, row: r, col: c, len: answer.length, answer });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { across, down };
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
function validatePuzzle(p) {
|
||||
if (!Array.isArray(p.grid) || p.grid.length !== SIZE) {
|
||||
throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`);
|
||||
}
|
||||
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 '#'`);
|
||||
}
|
||||
}
|
||||
const { across, down } = deriveSlots(p.grid);
|
||||
if (!Array.isArray(p.across) || p.across.length !== across.length) {
|
||||
throw new Error(`puzzle ${p.id}: expected ${across.length} across clues, got ${p.across?.length}`);
|
||||
}
|
||||
if (!Array.isArray(p.down) || p.down.length !== down.length) {
|
||||
throw new Error(`puzzle ${p.id}: expected ${down.length} down clues, got ${p.down?.length}`);
|
||||
}
|
||||
return { across, down };
|
||||
}
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────────
|
||||
|
||||
export function initMiniCrosswordPuzzles() {
|
||||
byDifficulty = { easy: [], medium: [], hard: [] };
|
||||
allPuzzles = [];
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(PUZZLE_PATH, 'utf8');
|
||||
} catch {
|
||||
console.warn('[words] Mini Crossword puzzle bank not found.');
|
||||
return { puzzles: 0 };
|
||||
}
|
||||
|
||||
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);
|
||||
allPuzzles.push(p);
|
||||
}
|
||||
return { puzzles: allPuzzles.length };
|
||||
}
|
||||
|
||||
// ── Puzzle selection ──────────────────────────────────────────────────────────
|
||||
|
||||
// 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') {
|
||||
const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles;
|
||||
if (!bucket.length) {
|
||||
return { id: null, difficulty, rows: SIZE, cols: SIZE, grid: [], across: [], down: [] };
|
||||
}
|
||||
|
||||
const p = bucket[Math.floor(Math.random() * bucket.length)];
|
||||
const { across, down } = deriveSlots(p.grid);
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
difficulty: p.difficulty,
|
||||
rows: SIZE,
|
||||
cols: SIZE,
|
||||
grid: p.grid,
|
||||
across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })),
|
||||
down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })),
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
|
||||
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
|
||||
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
|
||||
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
|
@ -155,6 +156,10 @@ 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).
|
||||
const crosswordStats = initMiniCrosswordPuzzles();
|
||||
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
|
||||
|
||||
// Answer pool: prefer curated common words that are also in ENABLE;
|
||||
// supplement with additional ENABLE words up to a healthy pool size.
|
||||
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
|
||||
|
|
@ -208,6 +213,16 @@ router.get('/spellingbee/start', (req, res) => {
|
|||
res.json(spellingBeeGenerate(difficulty));
|
||||
});
|
||||
|
||||
// ── Mini Crossword ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/words/minicrossword/start?difficulty=easy|medium|hard
|
||||
// Returns a curated 5x5 puzzle (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';
|
||||
res.json(miniCrosswordGet(difficulty));
|
||||
});
|
||||
|
||||
// ── Scrabble ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/scrabble/validate { words: string[] }
|
||||
|
|
|
|||
Loading…
Reference in New Issue