Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s

Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).

- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
  StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
  Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
  DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
  game.Service domain methods, engine.Game and the robot keep one letter-based
  play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
  is geometry-only; the mock seeds a fixture table; the UI normalises display to
  upper case (codec + cache), leaving placement/board/checkword unchanged.

Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
This commit is contained in:
Ilia Denisov
2026-06-04 16:26:43 +02:00
parent 6537082397
commit 90eaf4964b
47 changed files with 1812 additions and 272 deletions
+19 -10
View File
@@ -14,7 +14,8 @@
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { centre, premiumGrid } from '../lib/premiums';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import {
@@ -84,7 +85,13 @@
async function load() {
try {
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
// game whose variant the client has not cached yet); steady-state polls omit it.
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
const [st, hist] = await Promise.all([
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
]);
view = st;
moves = hist.moves;
placement = newPlacement(st.rack);
@@ -206,7 +213,7 @@
if (!sub) return;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
} catch {
/* best-effort */
}
@@ -218,7 +225,7 @@
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles);
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
zoomed = false;
await load();
} catch (e) {
@@ -298,7 +305,7 @@
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles);
await gateway.exchange(id, tiles, variant);
await load();
} catch (e) {
handleError(e);
@@ -313,7 +320,7 @@
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
@@ -326,9 +333,11 @@
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal);
checkResult = r;
const r = await gateway.checkWord(id, w, variant);
// Key the cache and the displayed result on the upper-case word the player typed; the
// server echoes the decoded concrete word in the solver's lower case.
checkedWords.set(w, r.legal);
checkResult = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
@@ -535,7 +544,7 @@
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabet(variant) as ch (ch)}
{#each alphabetLetters(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>