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
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
alphabetLetters,
BLANK_INDEX,
hasAlphabet,
indexForLetter,
letterForIndex,
setAlphabet,
valueForLetter,
} from './alphabet';
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
describe('alphabet cache (Stage 13)', () => {
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 16, letter: 'q', value: 10 },
]);
expect(hasAlphabet('english')).toBe(true);
expect(letterForIndex('english', 0)).toBe('A');
expect(letterForIndex('english', 16)).toBe('Q');
expect(indexForLetter('english', 'a')).toBe(0);
expect(indexForLetter('english', 'Q')).toBe(16);
expect(valueForLetter('english', 'a')).toBe(1);
expect(valueForLetter('english', 'Q')).toBe(10);
});
it('handles the blank sentinel and unknown letters/indices', () => {
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]);
expect(letterForIndex('english', BLANK_INDEX)).toBe('?');
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX);
expect(valueForLetter('english', '?')).toBe(0);
expect(letterForIndex('english', 99)).toBe(''); // out of range
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet
expect(() => indexForLetter('english', 'Z')).toThrow();
});
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
expect(alphabetLetters('english')).toEqual(['A', 'B']);
expect(hasAlphabet('erudit')).toBe(false);
expect(alphabetLetters('erudit')).toEqual([]);
expect(valueForLetter('erudit', 'A')).toBe(0);
});
});
+84
View File
@@ -0,0 +1,84 @@
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
// stored upper-cased for display (the rest of the UI works in upper case) and index lookups
// are case-insensitive. A blank rides as the sentinel index 255 in a rack/exchange list; a
// placed blank instead travels as its designated letter's index with a separate blank flag.
import type { Variant } from './model';
/** BLANK_INDEX is the wire sentinel for a blank tile in a rack/exchange index list. */
export const BLANK_INDEX = 255;
/** BLANK_LETTER is the glyph a blank rack tile decodes to (matches placement.BLANK). */
const BLANK_LETTER = '?';
/** AlphabetEntryWire is one raw alphabet row as received from the wire or a mock fixture. */
export interface AlphabetEntryWire {
index: number;
letter: string;
value: number;
}
interface Table {
letters: string[]; // by index, upper-cased
values: number[]; // by index
indexByLetter: Map<string, number>; // upper-cased letter -> index
}
const cache = new Map<Variant, Table>();
/** setAlphabet caches a variant's table, upper-casing letters for display. */
export function setAlphabet(variant: Variant, entries: AlphabetEntryWire[]): void {
let size = 0;
for (const e of entries) size = Math.max(size, e.index + 1);
const letters = new Array<string>(size).fill('');
const values = new Array<number>(size).fill(0);
const indexByLetter = new Map<string, number>();
for (const e of entries) {
const up = e.letter.toUpperCase();
letters[e.index] = up;
values[e.index] = e.value;
indexByLetter.set(up, e.index);
}
cache.set(variant, { letters, values, indexByLetter });
}
/** hasAlphabet reports whether a variant's table is cached (so the client can skip asking
* the server to resend it). */
export function hasAlphabet(variant: Variant): boolean {
return cache.has(variant);
}
/** alphabetLetters lists a variant's letters (upper-cased) for the blank chooser and the
* word-check input filter; empty when the table is not yet cached. */
export function alphabetLetters(variant: Variant): string[] {
return cache.get(variant)?.letters.slice() ?? [];
}
/** letterForIndex maps a wire rack index to its display letter: the blank sentinel maps to
* "?", an unknown index to "". */
export function letterForIndex(variant: Variant, index: number): string {
if (index === BLANK_INDEX) return BLANK_LETTER;
return cache.get(variant)?.letters[index] ?? '';
}
/** valueForLetter returns a tile's point value; a blank ("?") and an unknown letter score 0. */
export function valueForLetter(variant: Variant, letter: string): number {
if (!letter || letter === BLANK_LETTER) return 0;
const t = cache.get(variant);
if (!t) return 0;
const i = t.indexByLetter.get(letter.toUpperCase());
return i === undefined ? 0 : t.values[i];
}
/** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank
* sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not
* user input (the UI constrains every entry point to the variant's alphabet). */
export function indexForLetter(variant: Variant, letter: string): number {
if (letter === BLANK_LETTER) return BLANK_INDEX;
const i = cache.get(variant)?.indexByLetter.get(letter.toUpperCase());
if (i === undefined) throw new Error(`alphabet: no index for "${letter}" in ${variant}`);
return i;
}
+8 -5
View File
@@ -67,15 +67,18 @@ export interface GatewayClient {
lobbyPoll(): Promise<MatchResult>;
// --- game ---
gameState(gameId: string): Promise<StateView>;
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
// check-word take the game's variant (to map letters<->indices via the cached alphabet
// table), and gameState's includeAlphabet asks the server to embed that table.
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[]): Promise<MoveResult>;
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
// --- chat ---
+90 -5
View File
@@ -1,28 +1,44 @@
import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeSession,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
} from './codec';
describe('codec', () => {
it('encodes a SubmitPlayRequest the gateway can read', () => {
const buf = encodeSubmitPlay('g1', 'H', [
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
// A placed blank carries its designated letter's index with the blank flag set.
const buf = encodeSubmitPlay(
'g1',
'H',
[
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
],
'english',
);
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1');
expect(r.dir()).toBe('H');
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)?.letter()).toBe('A');
expect(r.tiles(0)?.letter()).toBe(0);
expect(r.tiles(1)?.letter()).toBe(1);
expect(r.tiles(1)?.blank()).toBe(true);
});
@@ -214,3 +230,72 @@ describe('codec', () => {
expect(inv.variant).toBe('english');
});
});
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
// the whole table), so they are independent of order.
describe('codec — alphabet on the wire (Stage 13)', () => {
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
);
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)).toBe(0);
expect(r.tiles(1)).toBe(BLANK_INDEX);
});
it('encodes a CheckWordRequest as alphabet indices', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 2, letter: 'c', value: 3 },
{ index: 19, letter: 't', value: 1 },
]);
const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')),
);
expect(r.wordLength()).toBe(3);
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
});
it('carries the include_alphabet flag on a StateRequest', () => {
const on = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', true)));
expect(on.gameId()).toBe('g1');
expect(on.includeAlphabet()).toBe(true);
const off = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', false)));
expect(off.includeAlphabet()).toBe(false);
});
it('caches the alphabet table from a StateView and decodes the index rack to letters', () => {
const b = new Builder(128);
const la = b.createString('a');
fb.AlphabetEntry.startAlphabetEntry(b);
fb.AlphabetEntry.addIndex(b, 0);
fb.AlphabetEntry.addLetter(b, la);
fb.AlphabetEntry.addValue(b, 1);
const ea = fb.AlphabetEntry.endAlphabetEntry(b);
const lb = b.createString('b');
fb.AlphabetEntry.startAlphabetEntry(b);
fb.AlphabetEntry.addIndex(b, 1);
fb.AlphabetEntry.addLetter(b, lb);
fb.AlphabetEntry.addValue(b, 3);
const eb = fb.AlphabetEntry.endAlphabetEntry(b);
const alpha = fb.StateView.createAlphabetVector(b, [ea, eb]);
const rack = fb.StateView.createRackVector(b, [0, BLANK_INDEX]);
fb.StateView.startStateView(b);
fb.StateView.addSeat(b, 0);
fb.StateView.addRack(b, rack);
fb.StateView.addBagLen(b, 10);
fb.StateView.addHintsRemaining(b, 0);
fb.StateView.addAlphabet(b, alpha);
b.finish(fb.StateView.endStateView(b));
// No GameView on the buffer, so decode falls back to the default variant 'english';
// the embedded table is cached under it and the rack [0, blank] decodes to letters.
const sv = decodeStateView(b.asUint8Array());
expect(sv.rack).toEqual(['A', '?']);
});
});
+52 -22
View File
@@ -5,6 +5,7 @@
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
import type { PlacedTile } from './client';
import type {
AccountRef,
@@ -37,14 +38,15 @@ import type {
// --- request encoders ---
function buildTile(b: Builder, t: PlacedTile): Offset {
const letter = b.createString(t.letter);
fb.TileRecord.startTileRecord(b);
fb.TileRecord.addRow(b, t.row);
fb.TileRecord.addCol(b, t.col);
fb.TileRecord.addLetter(b, letter);
fb.TileRecord.addBlank(b, t.blank);
return fb.TileRecord.endTileRecord(b);
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
// carries its designated letter's index with blank set.
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
fb.PlayTile.startPlayTile(b);
fb.PlayTile.addRow(b, t.row);
fb.PlayTile.addCol(b, t.col);
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
fb.PlayTile.addBlank(b, t.blank);
return fb.PlayTile.endPlayTile(b);
}
function finish(b: Builder, root: Offset): Uint8Array {
@@ -62,17 +64,23 @@ export function encodeGameAction(gameId: string): Uint8Array {
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}
export function encodeStateRequest(gameId: string): Uint8Array {
export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.StateRequest.startStateRequest(b);
fb.StateRequest.addGameId(b, gid);
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
return finish(b, fb.StateRequest.endStateRequest(b));
}
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
export function encodeSubmitPlay(
gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
@@ -83,9 +91,14 @@ export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTi
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
export function encodeEval(
gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
@@ -96,10 +109,12 @@ export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]):
return finish(b, fb.EvalRequest.endEvalRequest(b));
}
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
const b = new Builder(128);
const offs = tiles.map((s) => b.createString(s));
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
const vec = fb.ExchangeRequest.createTilesVector(
b,
tiles.map((l) => indexForLetter(variant, l)),
);
const gid = b.createString(gameId);
fb.ExchangeRequest.startExchangeRequest(b);
fb.ExchangeRequest.addGameId(b, gid);
@@ -107,13 +122,16 @@ export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
const b = new Builder(128);
const vec = fb.CheckWordRequest.createWordVector(
b,
Array.from(word).map((ch) => indexForLetter(variant, ch)),
);
const gid = b.createString(gameId);
const w = b.createString(word);
fb.CheckWordRequest.startCheckWordRequest(b);
fb.CheckWordRequest.addGameId(b, gid);
fb.CheckWordRequest.addWord(b, w);
fb.CheckWordRequest.addWord(b, vec);
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}
@@ -188,7 +206,8 @@ function s(v: string | null): string {
}
function decodeTile(t: fb.TileRecord): Tile {
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
// The wire keeps the move journal in the solver's lower case; the UI renders upper case.
return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
}
function decodeSeat(v: fb.SeatView): Seat {
@@ -229,7 +248,7 @@ function decodeMove(m: fb.MoveRecord): MoveRecord {
if (t) tiles.push(decodeTile(t));
}
const words: string[] = [];
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
return {
player: m.player(),
action: s(m.action()),
@@ -280,8 +299,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
const g = v.game();
const variant = (g ? s(g.variant()) : 'english') as Variant;
// Cache the alphabet table when the server included it (a per-variant cache miss), then
// decode the index rack to display letters with it (Stage 13).
if (v.alphabetLength() > 0) {
const entries: AlphabetEntryWire[] = [];
for (let i = 0; i < v.alphabetLength(); i++) {
const e = v.alphabet(i);
if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
}
setAlphabet(variant, entries);
}
const rack: string[] = [];
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
return {
game: g ? decodeGameView(g) : emptyGame(),
seat: v.seat(),
+41
View File
@@ -0,0 +1,41 @@
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
// table comes from the server; the mock seeds the same client cache from a local copy so
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
// token's position is its index — the same shape the wire delivers.
import { setAlphabet, type AlphabetEntryWire } from '../alphabet';
import type { Variant } from '../model';
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
// Эрудит а..я incl. ё=0.
const SPECS: Record<Variant, string> = {
english:
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
russian:
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
erudit:
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
};
function parse(spec: string): AlphabetEntryWire[] {
return spec
.trim()
.split(/\s+/)
.map((tok, index) => {
const m = tok.match(/^(.+?)(\d+)$/);
return { index, letter: m ? m[1] : tok, value: m ? Number(m[2]) : 0 };
});
}
let seeded = false;
/** seedMockAlphabets populates the alphabet cache for every variant once, mirroring the
* server-sent tables so the mock-driven UI is alphabet-agnostic too. */
export function seedMockAlphabets(): void {
if (seeded) return;
for (const variant of Object.keys(SPECS) as Variant[]) {
setAlphabet(variant, parse(SPECS[variant]));
}
seeded = true;
}
+16 -9
View File
@@ -34,7 +34,8 @@ import type {
Variant,
WordCheckResult,
} from '../model';
import { tileValue } from '../premiums';
import { valueForLetter } from '../alphabet';
import { seedMockAlphabets } from './alphabet';
import {
ME,
MOCK_FRIENDS,
@@ -93,6 +94,12 @@ export class MockGateway implements GatewayClient {
private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS };
constructor() {
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
seedMockAlphabets();
}
setToken(_token: string | null): void {
// The mock needs no auth; the real transport stores the bearer token.
}
@@ -174,7 +181,7 @@ export class MockGateway implements GatewayClient {
}
// --- game ---
async gameState(gameId: string): Promise<StateView> {
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
return {
game: structuredClone(g.view),
@@ -190,12 +197,12 @@ export class MockGateway implements GatewayClient {
return { gameId, moves: structuredClone(g.moves) };
}
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult> {
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
const g = this.game(gameId);
const seat = this.mySeat(g);
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
const variant = g.view.variant;
let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0);
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
const total = g.view.seats[seat].score + score;
const move = {
@@ -265,7 +272,7 @@ export class MockGateway implements GatewayClient {
pass(gameId: string): Promise<MoveResult> {
return this.simpleAction(gameId, 'pass');
}
exchange(gameId: string, tiles: string[]): Promise<MoveResult> {
exchange(gameId: string, tiles: string[], _variant: Variant): Promise<MoveResult> {
return this.simpleAction(gameId, 'exchange', tiles);
}
resign(gameId: string): Promise<MoveResult> {
@@ -287,22 +294,22 @@ export class MockGateway implements GatewayClient {
tiles: [{ row: 7, col: 7, letter, blank: false }],
words: [letter],
count: 1,
score: tileValue(g.view.variant, letter),
score: valueForLetter(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
};
}
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult> {
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0);
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
}
async checkWord(_gameId: string, word: string): Promise<WordCheckResult> {
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
return { word, legal: word.trim().length >= 2 };
}
async complaint(): Promise<void> {}
+5 -19
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums';
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
// share standardBoard (centre is a double word); erudit shares the geometry but a
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
describe('premium layout', () => {
it('is a 15x15 grid with TW corners', () => {
const g = premiumGrid('english');
@@ -35,19 +37,3 @@ describe('premium layout', () => {
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
});
});
describe('tile values', () => {
it('scores letters per variant and zero for a blank', () => {
expect(tileValue('english', 'A')).toBe(1);
expect(tileValue('english', 'Q')).toBe(10);
expect(tileValue('english', '?')).toBe(0);
expect(tileValue('russian', 'Ф')).toBe(10);
expect(tileValue('erudit', 'Ё')).toBe(0);
});
it('exposes the full alphabet for the blank chooser', () => {
expect(alphabet('english')).toHaveLength(26);
expect(alphabet('russian')).toHaveLength(33);
expect(alphabet('erudit')).toHaveLength(33);
});
});
+8 -45
View File
@@ -1,8 +1,9 @@
// Board premium layout and tile values — ported verbatim from the engine source of
// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the
// per-variant value tables). These are NOT transmitted on the wire (StateView has
// no board), so the client renders them locally. A Vitest parity test pins the
// layout against the known geometry. Keep this in lockstep with the solver.
// Board premium layout — the 15x15 premium-square geometry, ported from the engine source
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
// transmitted on the wire (StateView has no board), so the client renders the premiums
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
// lib/alphabet.ts), so this file is geometry only.
import type { Variant } from './model';
@@ -84,43 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
return { row: 7, col: 7 };
}
// --- tile values (points shown on the tile face); blank scores 0 ---
// English Latin a..z (rules.go English()).
const enValues =
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10';
// Russian а..я incl. ё (rules.go RussianScrabble()).
const ruValues =
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3';
// Эрудит а..я incl. ё=0 (rules.go Erudit()).
const eruditValues =
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3';
// Split each "letter+value" token into its letter (all but trailing digits) and its
// integer value (the trailing digits).
function valueTable(spec: string): Map<string, number> {
const m = new Map<string, number>();
for (const pair of spec.trim().split(/\s+/)) {
const match = pair.match(/^(.+?)(\d+)$/);
if (!match) continue;
m.set(match[1].toUpperCase(), Number(match[2]));
}
return m;
}
const VALUES: Record<Variant, Map<string, number>> = {
english: valueTable(enValues),
russian: valueTable(ruValues),
erudit: valueTable(eruditValues),
};
/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */
export function tileValue(variant: Variant, letter: string): number {
if (!letter || letter === '?') return 0;
return VALUES[variant]?.get(letter.toUpperCase()) ?? 0;
}
/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */
export function alphabet(variant: Variant): string[] {
return [...VALUES[variant].keys()];
}
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
// Stage 13); the board geometry above is all this module owns.
+10 -10
View File
@@ -81,20 +81,20 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
async gameState(id) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
async gameState(id, includeAlphabet) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
},
async gameHistory(id) {
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
},
async submitPlay(id, dir, tiles) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
async submitPlay(id, dir, tiles, variant) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
},
async pass(id) {
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
},
async exchange(id, tiles) {
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
async exchange(id, tiles, variant) {
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles, variant)));
},
async resign(id) {
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
@@ -102,11 +102,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async hint(id) {
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
},
async evaluate(id, dir, tiles) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
async evaluate(id, dir, tiles, variant) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
},
async checkWord(id, word) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
async checkWord(id, word, variant) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
},
async complaint(id, word, note) {
await exec('game.complaint', codec.encodeComplaint(id, word, note));