R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
This commit is contained in:
Ilia Denisov
2026-06-09 12:09:50 +02:00
parent 70e3fab512
commit 26aa154547
54 changed files with 688 additions and 675 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
// complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props();
let variant = $state<Variant>('english');
let variant = $state<Variant>('scrabble_en');
let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null);
let cooling = $state(false);
+1 -1
View File
@@ -57,7 +57,7 @@
let resignOpen = $state(false);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
const variant = $derived(view?.game.variant ?? 'english');
const variant = $derived(view?.game.variant ?? 'scrabble_en');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant));
+20 -20
View File
@@ -12,37 +12,37 @@ import {
// 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', [
setAlphabet('scrabble_en', [
{ 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);
expect(hasAlphabet('scrabble_en')).toBe(true);
expect(letterForIndex('scrabble_en', 0)).toBe('A');
expect(letterForIndex('scrabble_en', 16)).toBe('Q');
expect(indexForLetter('scrabble_en', 'a')).toBe(0);
expect(indexForLetter('scrabble_en', 'Q')).toBe(16);
expect(valueForLetter('scrabble_en', 'a')).toBe(1);
expect(valueForLetter('scrabble_en', '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();
setAlphabet('scrabble_en', [{ index: 0, letter: 'a', value: 1 }]);
expect(letterForIndex('scrabble_en', BLANK_INDEX)).toBe('?');
expect(indexForLetter('scrabble_en', '?')).toBe(BLANK_INDEX);
expect(valueForLetter('scrabble_en', '?')).toBe(0);
expect(letterForIndex('scrabble_en', 99)).toBe(''); // out of range
expect(valueForLetter('scrabble_en', 'Z')).toBe(0); // not in this alphabet
expect(() => indexForLetter('scrabble_en', 'Z')).toThrow();
});
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
setAlphabet('english', [
setAlphabet('scrabble_en', [
{ 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);
expect(alphabetLetters('scrabble_en')).toEqual(['A', 'B']);
expect(hasAlphabet('erudit_ru')).toBe(false);
expect(alphabetLetters('erudit_ru')).toEqual([]);
expect(valueForLetter('erudit_ru', 'A')).toBe(0);
});
});
+10 -10
View File
@@ -36,7 +36,7 @@ describe('codec', () => {
});
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('english', [
setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
@@ -48,7 +48,7 @@ describe('codec', () => {
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
],
'english',
'scrabble_en',
);
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1');
@@ -95,7 +95,7 @@ describe('codec', () => {
const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1');
const variant = b.createString('english');
const variant = b.createString('scrabble_en');
const dv = b.createString('v1');
const status = b.createString('active');
const er = b.createString('');
@@ -242,7 +242,7 @@ describe('codec', () => {
const invitees = fb.Invitation.createInviteesVector(b, [invitee]);
const id = b.createString('i-1');
const variant = b.createString('english');
const variant = b.createString('scrabble_en');
const dropout = b.createString('remove');
const status = b.createString('pending');
const gid = b.createString('');
@@ -264,7 +264,7 @@ describe('codec', () => {
expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
expect(inv.invitees).toHaveLength(1);
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('english');
expect(inv.variant).toBe('scrabble_en');
});
});
@@ -273,12 +273,12 @@ describe('codec', () => {
// 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', [
setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'scrabble_en')),
);
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)).toBe(0);
@@ -286,13 +286,13 @@ describe('codec — alphabet on the wire (Stage 13)', () => {
});
it('encodes a CheckWordRequest as alphabet indices', () => {
setAlphabet('english', [
setAlphabet('scrabble_en', [
{ 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')),
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'scrabble_en')),
);
expect(r.wordLength()).toBe(3);
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
@@ -330,7 +330,7 @@ describe('codec — alphabet on the wire (Stage 13)', () => {
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';
// No GameView on the buffer, so decode falls back to the default variant 'scrabble_en';
// 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', '?']);
+2 -2
View File
@@ -325,7 +325,7 @@ 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;
const variant = (g ? s(g.variant()) : 'scrabble_en') 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) {
@@ -681,7 +681,7 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
function emptyGame(): GameView {
return {
id: '',
variant: 'english',
variant: 'scrabble_en',
dictVersion: '',
status: '',
players: 0,
+1 -1
View File
@@ -15,7 +15,7 @@ const seat = (s: number, accountId: string): Seat => ({
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
return {
id,
variant: 'english',
variant: 'scrabble_en',
dictVersion: 'v1',
status,
players: 2,
+3 -3
View File
@@ -10,11 +10,11 @@ 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:
scrabble_en:
'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_scrabble:
scrabble_ru:
'а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:
erudit_ru:
'а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',
};
+3 -3
View File
@@ -61,9 +61,9 @@ function emptyLinked(): LinkResult {
}
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
function draw(variant: Variant, n: number): string[] {
+5 -5
View File
@@ -57,7 +57,7 @@ export function mockInvitations(): Invitation[] {
id: 'inv1',
inviter: { accountId: 'kaya', displayName: 'Kaya' },
invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }],
variant: 'english',
variant: 'scrabble_en',
turnTimeoutSecs: 86400,
hintsAllowed: true,
hintsPerPlayer: 1,
@@ -105,7 +105,7 @@ export interface MockGame {
chat: ChatMessage[];
}
// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn ---
// --- active game G1: scrabble_en, You (seat 0) vs Ann (seat 1), your turn ---
const G1_MOVES: MoveRecord[] = [
play(0, 'H', [
@@ -135,7 +135,7 @@ function activeGame(): MockGame {
return {
view: {
id: 'g1',
variant: 'english',
variant: 'scrabble_en',
dictVersion: 'v1',
status: 'active',
players: 2,
@@ -169,7 +169,7 @@ function finishedG2(): MockGame {
return {
view: {
id: 'g2',
variant: 'english',
variant: 'scrabble_en',
dictVersion: 'v1',
status: 'finished',
players: 2,
@@ -204,7 +204,7 @@ function finishedG3(): MockGame {
return {
view: {
id: 'g3',
variant: 'russian_scrabble',
variant: 'scrabble_ru',
dictVersion: 'v1',
status: 'finished',
players: 2,
+1 -1
View File
@@ -3,7 +3,7 @@
// FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly.
export type Variant = 'english' | 'russian_scrabble' | 'erudit';
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
+10 -10
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest';
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
// 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
// Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru
// share standardBoard (centre is a double word); erudit_ru 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');
const g = premiumGrid('scrabble_en');
expect(g.length).toBe(BOARD_SIZE);
expect(g[0].length).toBe(BOARD_SIZE);
for (const [r, c] of [
@@ -20,16 +20,16 @@ describe('premium layout', () => {
}
});
it('doubles the centre for standard variants but not for erudit', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW');
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe('');
it('doubles the centre for standard variants but not for erudit_ru', () => {
expect(centre('scrabble_en')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('scrabble_en')[7][7]).toBe('DW');
expect(premiumGrid('scrabble_ru')[7][7]).toBe('DW');
expect(centre('erudit_ru')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit_ru')[7][7]).toBe('');
});
it('keeps the standard premium counts', () => {
const flat = premiumGrid('english').flat();
const flat = premiumGrid('scrabble_en').flat();
const count = (p: string) => flat.filter((x) => x === p).length;
expect(count('TW')).toBe(8);
expect(count('TL')).toBe(12);
+1 -1
View File
@@ -51,7 +51,7 @@ const eruditBoard = [
];
function template(variant: Variant): string[] {
return variant === 'erudit' ? eruditBoard : standardBoard;
return variant === 'erudit_ru' ? eruditBoard : standardBoard;
}
function premiumOf(ch: string): Premium {
+1 -1
View File
@@ -14,7 +14,7 @@ const seat = (s: number, accountId: string, score: number, isWinner = false): Se
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return {
id: 'g',
variant: 'english',
variant: 'scrabble_en',
dictVersion: 'v1',
status,
players: seats.length,
+3 -3
View File
@@ -9,14 +9,14 @@ describe('availableVariants', () => {
});
it('offers only English for an en-only service', () => {
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['english']);
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']);
});
it('offers Russian and Эрудит for a ru-only service', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']);
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']);
});
it('offers every variant for a bilingual service', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']);
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
});
});
+10 -10
View File
@@ -17,9 +17,9 @@ export interface VariantOption {
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
// (Stage 17).
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian_scrabble', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' },
{ id: 'scrabble_en', label: 'new.english' },
{ id: 'scrabble_ru', label: 'new.russian' },
{ id: 'erudit_ru', label: 'new.erudit' },
];
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
@@ -31,22 +31,22 @@ export function variantNameKey(v: Variant): MessageKey {
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = {
english: 'new.rulesEnglish',
russian_scrabble: 'new.rulesRussian',
erudit: 'new.rulesErudit',
scrabble_en: 'new.rulesEnglish',
scrabble_ru: 'new.rulesRussian',
erudit_ru: 'new.rulesErudit',
};
// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants;
// Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty.
export const VARIANT_FLAG: Record<Variant, string> = {
english: '🇺🇸',
russian_scrabble: '🇷🇺',
erudit: '',
scrabble_en: '🇺🇸',
scrabble_ru: '🇷🇺',
erudit_ru: '',
};
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
// or absent set is ungated (a web/legacy session without a declared set), returning
+3 -3
View File
@@ -144,9 +144,9 @@
}
const variantKey: Record<string, MessageKey> = {
english: 'new.english',
russian_scrabble: 'new.russian',
erudit: 'new.erudit',
scrabble_en: 'new.english',
scrabble_ru: 'new.russian',
erudit_ru: 'new.erudit',
};
</script>