Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke

- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session
- pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru
- in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend
- board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat
- Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 00:32:50 +02:00
parent 19ae8f04a2
commit 453ddc5e94
48 changed files with 5696 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
// 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.
import type { Variant } from './model';
export const BOARD_SIZE = 15;
export type Premium = '' | 'TW' | 'DW' | 'TL' | 'DL';
// Legend (rules.go): T=triple word, D=double word, t=triple letter, d=double
// letter, .=plain, *=centre (a double word), +=centre with no premium.
const standardBoard = [
'T..d...T...d..T',
'.D...t...t...D.',
'..D...d.d...D..',
'd..D...d...D..d',
'....D.....D....',
'.t...t...t...t.',
'..d...d.d...d..',
'T..d...*...d..T',
'..d...d.d...d..',
'.t...t...t...t.',
'....D.....D....',
'd..D...d...D..d',
'..D...d.d...D..',
'.D...t...t...D.',
'T..d...T...d..T',
];
// Эрудит: the standard layout but a non-doubling centre ('+').
const eruditBoard = [
'T..d...T...d..T',
'.D...t...t...D.',
'..D...d.d...D..',
'd..D...d...D..d',
'....D.....D....',
'.t...t...t...t.',
'..d...d.d...d..',
'T..d...+...d..T',
'..d...d.d...d..',
'.t...t...t...t.',
'....D.....D....',
'd..D...d...D..d',
'..D...d.d...D..',
'.D...t...t...D.',
'T..d...T...d..T',
];
function template(variant: Variant): string[] {
return variant === 'erudit' ? eruditBoard : standardBoard;
}
function premiumOf(ch: string): Premium {
switch (ch) {
case 'T':
return 'TW';
case 'D':
case '*':
return 'DW';
case 't':
return 'TL';
case 'd':
return 'DL';
default:
return '';
}
}
/** premiumGrid returns the 15x15 premium layout for a variant (row-major). */
export function premiumGrid(variant: Variant): Premium[][] {
return template(variant).map((line) => Array.from(line, premiumOf));
}
/** centre returns the first-move anchor square (row, col). */
export function centre(variant: Variant): { row: number; col: number } {
const lines = template(variant);
for (let r = 0; r < lines.length; r++) {
const c = lines[r].search(/[*+]/);
if (c >= 0) return { row: r, col: c };
}
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()];
}