import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { Dawg } from './dawg'; import { validatePlay, type Board, type Ruleset, type Placement, type Cell, type Direction } from './validate'; import { playDirection } from './direction'; import { premiumGrid, centre, BINGO, RACK_SIZE, BOARD_SIZE } from '../premiums'; import type { Variant } from '../model'; // Conformance gate for the ported validator: for every fixture produced by // `go run ./backend/cmd/validategen`, validatePlay must agree with the Go engine // on legality, and — when legal — on the score, the bonus and the exact words // formed (main + cross), across all variants and both cross-word rules. Point it // at the fixtures + dictionaries with: // DICT_DAWG_DIR=/dawg DICT_VALID_DIR= // It skips when those are unset (kept out of the default unit run until a // committed sample fixture lands). const dawgDir = process.env.DICT_DAWG_DIR; const validDir = process.env.DICT_VALID_DIR; const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); const dawgFile: Record = { scrabble_en: 'en_sowpods.dawg', scrabble_ru: 'ru_scrabble.dawg', erudit_ru: 'ru_erudit.dawg', }; interface FxCell { R: number; C: number; Letter: number; Blank: boolean; } interface FxWord { Letters: number[]; Score: number; } interface Fx { board: number; dir: number; ignoreCrossWords: boolean; tiles: FxCell[]; legal: boolean; score: number; bonus: number; main?: FxWord; cross?: FxWord[]; } interface VF { rows: number; cols: number; center: number; rackSize: number; bingo: number; values: number[]; premiums: number[]; boards: (FxCell[] | null)[]; fixtures: Fx[]; } // Mirror rules.Premium.LetterMult / WordMult over the premium codes // (0 none, 1 DL, 2 TL, 3 DW, 4 TW) emitted by validategen. const letterMultOf = (code: number): number => (code === 1 ? 2 : code === 2 ? 3 : 1); const wordMultOf = (code: number): number => (code === 3 ? 2 : code === 4 ? 3 : 1); function makeBoard(rows: number, cols: number, cells: FxCell[]): Board { const grid: (Cell | null)[] = new Array(rows * cols).fill(null); for (const cl of cells) grid[cl.R * cols + cl.C] = { letter: cl.Letter, blank: cl.Blank }; const inBounds = (r: number, c: number) => r >= 0 && r < rows && c >= 0 && c < cols; return { inBounds, filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, cellAt: (r, c) => grid[r * cols + c] as Cell, isEmpty: () => cells.length === 0, }; } function arrEq(a: number[], b: number[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } describe.skipIf(!ready)('validator parity vs Go engine', () => { for (const variant of Object.keys(dawgFile)) { it( variant, () => { const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); const v = variant as Variant; // Pin the client's static per-variant rule constants to the engine's ruleset. expect(RACK_SIZE, 'rack size').toBe(vf.rackSize); expect(BINGO[v], 'bingo').toBe(vf.bingo); const ctr = centre(v); expect(ctr.row * BOARD_SIZE + ctr.col, 'centre').toBe(vf.center); const clientPrem = premiumGrid(v); const premCode = (p: string): number => (p === 'DL' ? 1 : p === 'TL' ? 2 : p === 'DW' ? 3 : p === 'TW' ? 4 : 0); let premOk = true; for (let r = 0; r < vf.rows; r++) for (let c = 0; c < vf.cols; c++) if (premCode(clientPrem[r][c]) !== vf.premiums[r * vf.cols + c]) premOk = false; expect(premOk, 'premium grid matches engine').toBe(true); let legalCount = 0; let illegalCount = 0; let mismatches = 0; let dirMismatches = 0; const first: string[] = []; const firstDir: string[] = []; for (let fi = 0; fi < vf.fixtures.length; fi++) { const fx = vf.fixtures[fi]; const board = makeBoard(vf.rows, vf.cols, vf.boards[fx.board] ?? []); const rs: Ruleset = { cols: vf.cols, center: vf.center, rackSize: vf.rackSize, bingo: vf.bingo, values: vf.values, letterMult: (r, c) => letterMultOf(vf.premiums[r * vf.cols + c]), wordMult: (r, c) => wordMultOf(vf.premiums[r * vf.cols + c]), ignoreCrossWords: fx.ignoreCrossWords, }; const tiles: Placement[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: t.Letter, blank: t.Blank })); const res = validatePlay(board, rs, dawg, fx.dir as Direction, tiles); const gotDir = playDirection(board, rs, dawg, tiles); if (gotDir !== fx.dir) { dirMismatches++; if (firstDir.length < 8) firstDir.push(`#${fi} dir exp ${fx.dir} got ${gotDir}`); } if (fx.legal) legalCount++; else illegalCount++; let ok = res.legal === fx.legal; if (ok && fx.legal) { const m = res.move!; ok = m.score === fx.score && m.bonus === fx.bonus && arrEq(m.main.letters, fx.main!.Letters) && m.cross.length === (fx.cross?.length ?? 0) && m.cross.every((w, i) => arrEq(w.letters, fx.cross![i].Letters)); } if (!ok) { mismatches++; if (first.length < 8) { first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.move?.score ?? '-'}`); } } } expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); expect(illegalCount, 'fixtures should include illegal plays').toBeGreaterThan(0); expect(mismatches, first.join(' | ')).toBe(0); expect(dirMismatches, firstDir.join(' | ')).toBe(0); }, 120_000, ); } });