Wordle module, cross-language tests, and the deploy path

The browser engine is a port of the Python one and CI proves it: all 21.2M
(guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests,
including the duplicate-letter table and the twelve pinned seed vectors that
keep ?seed= permalinks pointing at the same word the recording used.

Word lists are split by how they are used. answers.json is inlined because the
board needs it before first paint to turn a seed into a word, and a fetch there
means a visibly empty board on a cold cache. guesses.json is fetched, because it
is three times larger and only needed the first time somebody presses Enter;
until it lands, validation falls back to the answer list, which accepts strictly
fewer words. The failure mode is 'your real word was briefly rejected', not 'a
non-word was accepted' — the right way round.

The solver runs in a worker constructed from a same-origin module URL, never
Vite's ?worker&inline: that yields a blob:, and production CSP has no
worker-src, so it falls back to default-src 'self' and the worker is blocked
with no console error. It would fail in production only.

deploy.sh smoke-tests the real public hostname from the deploying machine and
fails on a body under 1 kB, because the bind bug's signature is a valid
certificate over an empty 200 and a local --resolve check passes anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:53:25 -07:00
parent 2c2dcad9fd
commit b601511e7f
38 changed files with 3419 additions and 646 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* The game, in the browser.
*
* This is a port of `envs/wordle_five/wordle_five/engine.py`, and "port" is
* meant strictly: CI scores every (guess, answer) pair in the answer list
* through both implementations and compares a SHA-256 of the result. If these
* two files ever disagree by one tile, the build fails. That gate is what lets
* the page claim it verified a recorded run rather than merely replayed it.
*
* Keep this module pure and dependency-free. It runs on the main thread, in a
* Web Worker, and under `node --test`.
*/
export const WORD_LENGTH = 5;
export const MAX_GUESSES = 6;
/** A tile: correct position, present elsewhere, or absent. */
export type Tile = 'G' | 'Y' | 'X';
/** Five tiles, as a string. `'GYXXY'`. */
export type Pattern = string;
export const ALL_GREEN: Pattern = 'G'.repeat(WORD_LENGTH);
/**
* Green/yellow/grey feedback, as two passes.
*
* The two passes are not stylistic. A letter may be marked non-grey at most as
* many times as it occurs in the answer, and greens have first claim on that
* allocation — so every green in the word must be resolved before any yellow
* is assigned. A single pass marks the first S of SASSY yellow when BASIS has
* already spent both its S's on the greens that come later.
*
* This is the single most common bug in implementations of this game. It is
* also the bug that put a correction video on the most-watched explanation of
* it ever made, so it is worth the extra loop.
*/
export function scoreGuess(guess: string, answer: string): Pattern {
const g = guess.toLowerCase();
const a = answer.toLowerCase();
if (g.length !== a.length) {
throw new Error(`length mismatch: ${guess} vs ${answer}`);
}
const n = a.length;
const pattern: Tile[] = new Array(n).fill('X');
// Counts of each answer letter still available to yellows, keyed by char
// code so this stays allocation-free in the hot loop the solver runs.
const remaining = new Map<string, number>();
// Pass 1 — greens claim their letters out of the pool.
for (let i = 0; i < n; i += 1) {
if (g[i] === a[i]) {
pattern[i] = 'G';
} else {
const c = a[i]!;
remaining.set(c, (remaining.get(c) ?? 0) + 1);
}
}
// Pass 2 — yellows take only what pass 1 left, left to right.
for (let i = 0; i < n; i += 1) {
if (pattern[i] === 'G') continue;
const c = g[i]!;
const left = remaining.get(c) ?? 0;
if (left > 0) {
pattern[i] = 'Y';
remaining.set(c, left - 1);
}
}
return pattern.join('');
}
/**
* Would `candidate` have produced `pattern` for `guess`?
*
* This is the whole of constraint filtering, and it is also how `consistency`
* decides whether a guess contradicted what the player had already been told:
* a guess is consistent exactly when it was still a possible answer.
*/
export function isConsistent(candidate: string, guess: string, pattern: Pattern): boolean {
return scoreGuess(guess, candidate) === pattern;
}
/**
* Hard-mode legality, or null if the guess is legal.
*
* Three details are routinely got wrong and are deliberate here: greens are
* positional and locked; yellows are COUNTED, not merely present, so a guess
* must carry at least as many copies as were revealed; and grey letters are
* not banned at all — hard mode places no constraint on known-absent letters.
*/
export function hardModeViolation(
guess: string,
prevGuess: string,
prevPattern: Pattern,
): string | null {
const g = guess.toLowerCase();
const p = prevGuess.toLowerCase();
for (let i = 0; i < prevPattern.length; i += 1) {
if (prevPattern[i] === 'G' && g[i] !== p[i]) {
return `${p[i]!.toUpperCase()} must stay in position ${i + 1}`;
}
}
const need = new Map<string, number>();
for (let i = 0; i < prevPattern.length; i += 1) {
const tile = prevPattern[i];
if (tile === 'G' || tile === 'Y') {
const c = p[i]!;
need.set(c, (need.get(c) ?? 0) + 1);
}
}
const have = new Map<string, number>();
for (const c of g) have.set(c, (have.get(c) ?? 0) + 1);
for (const [letter, count] of need) {
if ((have.get(letter) ?? 0) < count) {
const copies = count === 1 ? '' : ` ${count} copies of`;
return `guess must contain${copies} ${letter.toUpperCase()}`;
}
}
return null;
}
/**
* FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
*
* A language's built-in RNG is not portable: Python's `random.Random(7)` is a
* Mersenne Twister with no honest one-line equivalent here, so seed 7 would
* pick one word in the environment and a different one in this tab. Every
* permalink would then disagree with the recorded run it claims to show. A
* hash sidesteps it — both sides compute the same integer from the same
* string, and there is nothing to keep in step.
*
* `Math.imul` is what makes the 32-bit multiply exact; a plain `*` overflows
* into a double and silently diverges from Python after the first few bytes.
*/
export function fnv1a32(text: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/** The hidden word for a seed. Identical in engine.py — see fnv1a32. */
export function answerForSeed(seed: number, pool: readonly string[]): string {
return pool[fnv1a32(String(seed)) % pool.length]!;
}
/** Why a guess would be refused, or null if it is playable. */
export function rejectionReason(
word: string,
history: readonly [string, Pattern][],
allowed: ReadonlySet<string>,
hardMode = false,
): string | null {
const w = word.toLowerCase().trim();
if (w.length !== WORD_LENGTH) return `'${w}' is not ${WORD_LENGTH} letters`;
if (!allowed.has(w)) return `'${w}' is not in the word list`;
if (history.some(([prev]) => prev === w)) return `'${w}' has already been guessed`;
if (hardMode && history.length > 0) {
const last = history[history.length - 1]!;
return hardModeViolation(w, last[0], last[1]);
}
return null;
}
/** The board, as the surface renders it. Snapshots, never deltas. */
export interface BoardState {
seed: number;
answer: string;
/** Completed rows. */
rows: { guess: string; pattern: Pattern }[];
/** What is being typed into the next row, if the board is interactive. */
draft: string;
/** Replies the game refused. They cost a turn of patience, not a row. */
rejected: number;
status: 'playing' | 'won' | 'lost';
/** Set for one render after an illegal guess, to drive the shake. */
invalid: string | null;
hardMode: boolean;
}
export function emptyBoard(seed: number, answer: string, hardMode = false): BoardState {
return {
seed,
answer,
rows: [],
draft: '',
rejected: 0,
status: 'playing',
invalid: null,
hardMode,
};
}
export function isOver(board: BoardState): boolean {
return board.status !== 'playing';
}
/** Play a guess, returning the next board. Pure — never mutates its input. */
export function play(board: BoardState, word: string, allowed: ReadonlySet<string>): BoardState {
if (isOver(board)) return board;
const history = board.rows.map((r) => [r.guess, r.pattern] as [string, Pattern]);
const reason = rejectionReason(word, history, allowed, board.hardMode);
if (reason) {
return { ...board, rejected: board.rejected + 1, invalid: reason, draft: board.draft };
}
const guess = word.toLowerCase().trim();
const pattern = scoreGuess(guess, board.answer);
const rows = [...board.rows, { guess, pattern }];
const status: BoardState['status'] =
pattern === ALL_GREEN ? 'won' : rows.length >= MAX_GUESSES ? 'lost' : 'playing';
return { ...board, rows, draft: '', invalid: null, status };
}
/**
* What a screen reader hears when a row lands.
*
* Not optional decoration: `prefers-reduced-motion` clamps the tile flip to
* nothing, and colour alone is not a result. This sentence IS the feedback for
* anyone who is not looking at the tiles.
*/
export function announceRow(guess: string, pattern: Pattern, remaining?: number): string {
const parts = [...guess].map((letter, i) => {
const tile = pattern[i];
const state = tile === 'G' ? 'placed' : tile === 'Y' ? 'present' : 'absent';
return `${letter.toUpperCase()} ${state}`;
});
const tail = remaining === undefined ? '' : ` ${remaining} words remain.`;
return `${guess.toUpperCase()}: ${parts.join(', ')}.${tail}`;
}