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
+186
View File
@@ -0,0 +1,186 @@
// Central app state + actions. Holds the session/profile, client preferences, a
// transient toast, and the latest live event (screens react to it via $effect). All
// gateway calls funnel through here so errors map to one user-facing toast and an
// expired session logs out.
import type { Profile, PushEvent, Session } from './model';
import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
export interface Toast {
kind: 'error' | 'info';
text: string;
}
export const app = $state<{
ready: boolean;
session: Session | null;
profile: Profile | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
localeLocked: boolean;
}>({
ready: false,
session: null,
profile: null,
toast: null,
lastEvent: null,
theme: 'auto',
locale: 'en',
reduceMotion: false,
localeLocked: false,
});
let unsubscribeStream: (() => void) | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
return;
}
showToast(t(errorKey(err.code)), 'error');
return;
}
showToast(t('error.generic'), 'error');
}
function openStream(): void {
closeStream();
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
navigate(`/game/${e.gameId}`);
}
},
() => showToast(t('error.unavailable'), 'error'),
);
}
function closeStream(): void {
unsubscribeStream?.();
unsubscribeStream = null;
}
async function adoptSession(s: Session): Promise<void> {
gateway.setToken(s.token);
app.session = s;
await saveSession(s);
try {
app.profile = await gateway.profileGet();
if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale));
} catch (err) {
handleError(err);
}
openStream();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
app.locale = prefs.locale;
app.localeLocked = true;
setLocale(prefs.locale);
} else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
app.locale = guess;
setLocale(guess);
}
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
navigate('/login');
}
app.ready = true;
}
export async function loginGuest(): Promise<void> {
try {
const s = await gateway.authGuest(app.locale);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email);
return true;
} catch (err) {
handleError(err);
return false;
}
}
export async function loginEmail(email: string, code: string): Promise<void> {
try {
const s = await gateway.authEmailLogin(email, code);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function logout(): Promise<void> {
closeStream();
gateway.setToken(null);
await clearSession();
app.session = null;
app.profile = null;
navigate('/login');
}
function persistPrefs(): void {
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion });
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
persistPrefs();
}
export function setLocalePref(locale: Locale): void {
app.locale = locale;
app.localeLocked = true;
setLocale(locale);
persistPrefs();
}
export function setReduceMotion(on: boolean): void {
app.reduceMotion = on;
applyReduceMotion(on);
persistPrefs();
}
+45
View File
@@ -0,0 +1,45 @@
// Pure board reconstruction. The wire carries no board (StateView is summary + rack
// only), so the live grid is rebuilt by replaying the decoded move journal — exactly
// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's
// placed tiles onto an empty grid.
import type { MoveRecord, Tile } from './model';
import { BOARD_SIZE } from './premiums';
export interface BoardCell {
letter: string;
blank: boolean;
}
export type Board = (BoardCell | null)[][];
export function emptyBoard(): Board {
return Array.from({ length: BOARD_SIZE }, () =>
Array.from({ length: BOARD_SIZE }, () => null as BoardCell | null),
);
}
function inBounds(r: number, c: number): boolean {
return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
}
/** replay folds every play move's tiles onto an empty board (pass/exchange/resign
* change no squares). */
export function replay(moves: MoveRecord[]): Board {
const b = emptyBoard();
for (const m of moves) {
if (m.action !== 'play') continue;
for (const t of m.tiles) {
if (inBounds(t.row, t.col)) b[t.row][t.col] = { letter: t.letter, blank: t.blank };
}
}
return b;
}
/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */
export function lastPlayTiles(moves: MoveRecord[]): Tile[] {
for (let i = moves.length - 1; i >= 0; i--) {
if (moves[i].action === 'play') return moves[i].tiles;
}
return [];
}
+84
View File
@@ -0,0 +1,84 @@
// GatewayClient — the typed facade the screens call. Both the real Connect/
// FlatBuffers transport and the in-memory mock implement it. Domain failures (the
// gateway's result_code) and edge failures (Connect error codes) are normalised
// into a thrown GatewayError carrying a stable `code` the UI maps to an i18n
// message.
import type {
ChatMessage,
EvalResult,
GameList,
GameView,
History,
HintResult,
MatchResult,
MoveResult,
Profile,
PushEvent,
Session,
StateView,
Tile,
Variant,
WordCheckResult,
} from './model';
/** GatewayError carries a stable code (the gateway result_code, or an edge code). */
export class GatewayError extends Error {
readonly code: string;
constructor(code: string, message?: string) {
super(message ?? code);
this.name = 'GatewayError';
this.code = code;
}
}
/** A tile the player is submitting (rack/blank already resolved to a letter). */
export interface PlacedTile {
row: number;
col: number;
letter: string;
blank: boolean;
}
/** Unsubscribe handle for the live stream. */
export type Unsubscribe = () => void;
export interface GatewayClient {
// --- auth (unauthenticated) ---
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
// --- profile / lists ---
profileGet(): Promise<Profile>;
gamesList(): Promise<GameList>;
// --- lobby ---
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
// --- game ---
gameState(gameId: string): Promise<StateView>;
gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[]): 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>;
complaint(gameId: string, word: string, note: string): Promise<void>;
// --- chat ---
chatPost(gameId: string, body: string): Promise<ChatMessage>;
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
/** Set or clear the bearer token used for authenticated calls and the stream. */
setToken(token: string | null): void;
}
export type { GameView, Tile };
+13
View File
@@ -0,0 +1,13 @@
// The single GatewayClient the app uses. In `mock` mode (pnpm start) it is the
// in-memory fake; otherwise it is the real Connect/FlatBuffers transport. MODE is a
// build-time constant, so a production build tree-shakes the mock away.
import type { GatewayClient } from './client';
import { MockGateway } from './mock/client';
import { createTransport } from './transport';
const isMock = import.meta.env.MODE === 'mock';
export const gateway: GatewayClient = isMock
? new MockGateway()
: createTransport(import.meta.env.VITE_GATEWAY_URL ?? '');
+37
View File
@@ -0,0 +1,37 @@
// Pure i18n catalog + lookup (no runes) so any module can import the types and
// translate without depending on the reactive layer.
import { en, type MessageKey } from './en';
import { ru } from './ru';
export type Locale = 'en' | 'ru';
export type { MessageKey };
export const catalogs: Record<Locale, Record<MessageKey, string>> = { en, ru };
export function translate(
locale: Locale,
key: MessageKey,
params?: Record<string, string | number>,
): string {
const dict = catalogs[locale] ?? en;
let s: string = dict[key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replaceAll(`{${k}}`, String(v));
}
}
return s;
}
/** errorKey maps a gateway result/edge code to a message key, falling back to generic. */
export function errorKey(code: string): MessageKey {
const key = `error.${code}` as MessageKey;
return key in en ? key : 'error.generic';
}
/** localeFrom picks a supported locale from a free-form hint (e.g. 'ru-RU' -> 'ru'). */
export function localeFrom(hint: string | undefined | null, fallback: Locale = 'en'): Locale {
const l = (hint ?? '').slice(0, 2).toLowerCase();
return l === 'ru' ? 'ru' : l === 'en' ? 'en' : fallback;
}
+128
View File
@@ -0,0 +1,128 @@
// English message catalog (authoritative). Keys are flat dotted strings; ru.ts must
// provide exactly the same keys (enforced by its type and a Vitest parity test).
// {name} placeholders are filled by t(key, params).
export const en = {
'app.title': 'Scrabble',
'common.back': 'Back',
'common.cancel': 'Cancel',
'common.ok': 'OK',
'common.close': 'Close',
'common.loading': 'Loading…',
'common.retry': 'Retry',
'common.you': 'You',
'common.save': 'Save',
'login.title': 'Sign in',
'login.guest': 'Play as guest',
'login.email': 'Email',
'login.emailPlaceholder': 'you@example.com',
'login.sendCode': 'Send code',
'login.codePlaceholder': '6-digit code',
'login.signIn': 'Sign in',
'login.codeSent': 'We sent a code to {email}.',
'lobby.activeGames': 'Active games',
'lobby.finishedGames': 'Finished games',
'lobby.noActive': 'No active games yet.',
'lobby.noFinished': 'No finished games yet.',
'lobby.new': 'New',
'lobby.stats': 'Stats',
'lobby.tournaments': 'Tourn.',
'lobby.profile': 'Profile',
'lobby.settings': 'Settings',
'lobby.about': 'About',
'lobby.yourTurn': 'Your turn',
'lobby.theirTurn': 'Their turn',
'lobby.vs': 'vs {opponents}',
'lobby.soon': 'Coming soon',
'new.title': 'New game',
'new.subtitle': 'Auto-match with another player',
'new.english': 'English',
'new.russian': 'Russian',
'new.erudit': 'Эрудит',
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
'game.bag': 'Bag {n}',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move',
'game.reset': 'Reset',
'game.draw': 'Draw',
'game.skip': 'Skip',
'game.shuffle': 'Shuffle',
'game.hint': 'Hint',
'game.history': 'History',
'game.chat': 'Chat',
'game.checkWord': 'Check word',
'game.dropGame': 'Drop game',
'game.preview': 'Scores {n}',
'game.previewIllegal': 'Not a legal move',
'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Select tiles to exchange',
'game.exchangeConfirm': 'Exchange {n}',
'game.confirmResign': 'Resign this game?',
'game.hintShown': 'Best move: {word} for {n}',
'game.over': 'Game over',
'game.won': 'You won',
'game.lost': 'You lost',
'game.tied': 'Draw',
'game.checkWordPrompt': 'Enter a word',
'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.',
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
'chat.nudge': 'Nudge',
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
'profile.title': 'Profile',
'profile.language': 'Language',
'profile.timezone': 'Time zone',
'profile.hintBalance': 'Hint balance',
'profile.guest': 'Guest account',
'profile.readonly': 'Editing your profile arrives in a later update.',
'settings.title': 'Settings',
'settings.theme': 'Theme',
'settings.themeAuto': 'Auto',
'settings.themeLight': 'Light',
'settings.themeDark': 'Dark',
'settings.language': 'Interface language',
'settings.reduceMotion': 'Reduce motion',
'about.title': 'About',
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
'lang.en': 'English',
'lang.ru': 'Русский',
'error.not_your_turn': "It is not your turn.",
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
'error.email_taken': 'That email belongs to another account.',
'error.code_invalid': 'Invalid or expired code.',
'error.invalid_email': 'Enter a valid email address.',
'error.invalid_config': 'Invalid game settings.',
'error.not_found': 'Not found.',
'error.session_invalid': 'Your session expired. Please sign in again.',
'error.unauthenticated': 'Please sign in.',
'error.rate_limited': 'Too many requests, slow down.',
'error.unavailable': 'Connection problem. Retrying…',
'error.internal': 'Something went wrong.',
'error.generic': 'Something went wrong.',
} as const;
export type MessageKey = keyof typeof en;
+17
View File
@@ -0,0 +1,17 @@
// Reactive i18n layer. The locale is a rune, so any component that calls t()
// re-renders when the locale changes. The catalog + lookup are pure (see catalog.ts).
import { translate, type Locale, type MessageKey } from './catalog';
export { errorKey, localeFrom } from './catalog';
export type { Locale, MessageKey };
export const i18n = $state<{ locale: Locale }>({ locale: 'en' });
export function setLocale(locale: Locale): void {
i18n.locale = locale;
}
export function t(key: MessageKey, params?: Record<string, string | number>): string {
return translate(i18n.locale, key, params);
}
+127
View File
@@ -0,0 +1,127 @@
// Russian message catalog. Typed as Record<MessageKey, string> so it must cover every
// key the English catalog defines (a Vitest test asserts parity too).
import type { MessageKey } from './en';
export const ru: Record<MessageKey, string> = {
'app.title': 'Scrabble',
'common.back': 'Назад',
'common.cancel': 'Отмена',
'common.ok': 'ОК',
'common.close': 'Закрыть',
'common.loading': 'Загрузка…',
'common.retry': 'Повторить',
'common.you': 'Вы',
'common.save': 'Сохранить',
'login.title': 'Вход',
'login.guest': 'Играть как гость',
'login.email': 'Эл. почта',
'login.emailPlaceholder': 'you@example.com',
'login.sendCode': 'Отправить код',
'login.codePlaceholder': 'Код из 6 цифр',
'login.signIn': 'Войти',
'login.codeSent': 'Мы отправили код на {email}.',
'lobby.activeGames': 'Активные игры',
'lobby.finishedGames': 'Завершённые игры',
'lobby.noActive': 'Пока нет активных игр.',
'lobby.noFinished': 'Пока нет завершённых игр.',
'lobby.new': 'Новая',
'lobby.stats': 'Статы',
'lobby.tournaments': 'Турниры',
'lobby.profile': 'Профиль',
'lobby.settings': 'Настройки',
'lobby.about': 'О программе',
'lobby.yourTurn': 'Ваш ход',
'lobby.theirTurn': 'Ход соперника',
'lobby.vs': 'против {opponents}',
'lobby.soon': 'Скоро',
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
'new.english': 'Английский',
'new.russian': 'Русский',
'new.erudit': 'Эрудит',
'new.find': 'Найти игру',
'new.searching': 'Ищем соперника…',
'game.bag': 'Мешок {n}',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход',
'game.reset': 'Сброс',
'game.draw': 'Обмен',
'game.skip': 'Пас',
'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка',
'game.history': 'История',
'game.chat': 'Чат',
'game.checkWord': 'Проверить слово',
'game.dropGame': 'Покинуть игру',
'game.preview': 'Очков: {n}',
'game.previewIllegal': 'Недопустимый ход',
'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Выберите фишки для обмена',
'game.exchangeConfirm': 'Обменять {n}',
'game.confirmResign': 'Сдаться в этой игре?',
'game.hintShown': 'Лучший ход: {word} на {n}',
'game.over': 'Игра окончена',
'game.won': 'Вы выиграли',
'game.lost': 'Вы проиграли',
'game.tied': 'Ничья',
'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
'chat.nudge': 'Поторопить',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
'profile.title': 'Профиль',
'profile.language': 'Язык',
'profile.timezone': 'Часовой пояс',
'profile.hintBalance': 'Баланс подсказок',
'profile.guest': 'Гостевой аккаунт',
'profile.readonly': 'Редактирование профиля появится в следующем обновлении.',
'settings.title': 'Настройки',
'settings.theme': 'Тема',
'settings.themeAuto': 'Авто',
'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса',
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
'lang.en': 'English',
'lang.ru': 'Русский',
'error.not_your_turn': 'Сейчас не ваш ход.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.',
'error.email_taken': 'Эта почта принадлежит другому аккаунту.',
'error.code_invalid': 'Неверный или истёкший код.',
'error.invalid_email': 'Введите корректный адрес почты.',
'error.invalid_config': 'Неверные настройки игры.',
'error.not_found': 'Не найдено.',
'error.session_invalid': 'Сессия истекла. Войдите снова.',
'error.unauthenticated': 'Пожалуйста, войдите.',
'error.rate_limited': 'Слишком много запросов, помедленнее.',
'error.unavailable': 'Проблема соединения. Повторяем…',
'error.internal': 'Что-то пошло не так.',
'error.generic': 'Что-то пошло не так.',
};
+350
View File
@@ -0,0 +1,350 @@
// In-memory mock implementation of GatewayClient. Drives the playable slice with no
// backend: it serves the seed data, applies plays/passes/exchanges/resigns to local
// state, fabricates plausible scores, and emits live events (a canned opponent reply,
// a match-found after enqueue) so the stream path is exercised too. This same fake is
// reused by the Playwright smoke. It is tree-shaken out of a production (non-mock)
// build.
import type {
GatewayClient,
PlacedTile,
Unsubscribe,
} from '../client';
import { GatewayError } from '../client';
import type {
ChatMessage,
EvalResult,
GameList,
History,
HintResult,
MatchResult,
MoveResult,
Profile,
PushEvent,
Session,
StateView,
Variant,
WordCheckResult,
} from '../model';
import { tileValue } from '../premiums';
import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data';
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
function draw(variant: Variant, n: number): string[] {
const pool = POOL[variant];
const out: string[] = [];
for (let i = 0; i < n; i++) out.push(pool[Math.floor(Math.random() * pool.length)]);
return out;
}
function removeFromRack(rack: string[], tiles: PlacedTile[]): string[] {
const next = [...rack];
for (const t of tiles) {
const want = t.blank ? '?' : t.letter.toUpperCase();
const i = next.indexOf(want);
if (i >= 0) next.splice(i, 1);
}
return next;
}
export class MockGateway implements GatewayClient {
private readonly games = seedGames();
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
setToken(_token: string | null): void {
// The mock needs no auth; the real transport stores the bearer token.
}
private emit(e: PushEvent): void {
for (const cb of this.subs) cb(e);
}
private game(id: string): MockGame {
const g = this.games.get(id);
if (!g) throw new GatewayError('not_found');
return g;
}
private mySeat(g: MockGame): number {
const s = g.view.seats.find((x) => x.accountId === ME);
return s ? s.seat : 0;
}
// --- auth ---
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(): Promise<void> {}
async authEmailLogin(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
// --- profile / lists ---
async profileGet(): Promise<Profile> {
return { ...this.profile };
}
async gamesList(): Promise<GameList> {
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
}
// --- lobby ---
async lobbyEnqueue(variant: Variant): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly.
const id = crypto.randomUUID();
const g: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
moveCount: 0,
endReason: '',
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, g);
this.pendingMatch = id;
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400);
return { matched: false };
}
async lobbyPoll(): Promise<MatchResult> {
if (this.pendingMatch) {
const g = this.games.get(this.pendingMatch);
this.pendingMatch = null;
if (g) return { matched: true, game: structuredClone(g.view) };
}
return { matched: false };
}
// --- game ---
async gameState(gameId: string): Promise<StateView> {
const g = this.game(gameId);
return {
game: structuredClone(g.view),
seat: this.mySeat(g),
rack: [...g.rack],
bagLen: g.bagLen,
hintsRemaining: g.hintsRemaining,
};
}
async gameHistory(gameId: string): Promise<History> {
const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) };
}
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): 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);
if (tiles.length === 7) score += 50;
const total = g.view.seats[seat].score + score;
const move = {
player: seat,
action: 'play' as const,
dir,
mainRow: tiles[0]?.row ?? 7,
mainCol: tiles[0]?.col ?? 7,
tiles: tiles.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })),
words: [tiles.map((t) => t.letter).join('')],
count: 1,
score,
total,
};
g.moves.push(move);
g.view.seats[seat].score = total;
g.view.moveCount += 1;
g.rack = removeFromRack(g.rack, tiles);
const drawn = Math.min(7 - g.rack.length, g.bagLen);
g.rack.push(...draw(variant, drawn));
g.bagLen -= drawn;
g.view.toMove = (seat + 1) % g.view.players;
this.scheduleOpponentReply(gameId);
return { move: structuredClone(move), game: structuredClone(g.view) };
}
private async simpleAction(
gameId: string,
action: 'pass' | 'exchange' | 'resign',
tiles: string[] = [],
): Promise<MoveResult> {
const g = this.game(gameId);
const seat = this.mySeat(g);
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
if (action === 'exchange' && tiles.length > 0) {
g.rack = removeFromRack(
g.rack,
tiles.map((l) => ({ row: 0, col: 0, letter: l, blank: l === '?' })),
);
g.rack.push(...draw(g.view.variant, tiles.length));
}
const move = {
player: seat,
action,
dir: '',
mainRow: 0,
mainCol: 0,
tiles: [],
words: [],
count: 0,
score: 0,
total: g.view.seats[seat].score,
};
g.moves.push(move);
g.view.moveCount += 1;
if (action === 'resign') {
g.view.status = 'finished';
g.view.endReason = 'resignation';
for (const s of g.view.seats) s.isWinner = s.seat !== seat;
} else {
g.view.toMove = (seat + 1) % g.view.players;
this.scheduleOpponentReply(gameId);
}
return { move: structuredClone(move), game: structuredClone(g.view) };
}
pass(gameId: string): Promise<MoveResult> {
return this.simpleAction(gameId, 'pass');
}
exchange(gameId: string, tiles: string[]): Promise<MoveResult> {
return this.simpleAction(gameId, 'exchange', tiles);
}
resign(gameId: string): Promise<MoveResult> {
return this.simpleAction(gameId, 'resign');
}
async hint(gameId: string): Promise<HintResult> {
const g = this.game(gameId);
if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable');
g.hintsRemaining -= 1;
const letter = g.rack.find((l) => l !== '?') ?? 'A';
return {
move: {
player: this.mySeat(g),
action: 'play',
dir: 'H',
mainRow: 7,
mainCol: 7,
tiles: [{ row: 7, col: 7, letter, blank: false }],
words: [letter],
count: 1,
score: tileValue(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
};
}
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): 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);
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> {
return { word, legal: word.trim().length >= 2 };
}
async complaint(): Promise<void> {}
// --- chat ---
async chatPost(gameId: string, body: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
id: crypto.randomUUID(),
gameId,
senderId: ME,
kind: 'message',
body,
createdAtUnix: Math.floor(Date.now() / 1000),
};
g.chat.push(msg);
return msg;
}
async chatList(gameId: string): Promise<ChatMessage[]> {
return [...this.game(gameId).chat];
}
async nudge(gameId: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
id: crypto.randomUUID(),
gameId,
senderId: ME,
kind: 'nudge',
body: '',
createdAtUnix: Math.floor(Date.now() / 1000),
};
g.chat.push(msg);
return msg;
}
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
this.subs.add(onEvent);
return () => this.subs.delete(onEvent);
}
// Fabricate an opponent reply shortly after the human moves, then hand the turn back.
private scheduleOpponentReply(gameId: string): void {
setTimeout(() => {
const g = this.games.get(gameId);
if (!g || g.view.status !== 'active') return;
const opp = (this.mySeat(g) + 1) % g.view.players;
if (g.view.toMove !== opp) return;
const cell = this.firstEmptyPair(g);
const move = {
player: opp,
action: 'play' as const,
dir: 'H' as const,
mainRow: cell.row,
mainCol: cell.col,
tiles: [
{ row: cell.row, col: cell.col, letter: 'O', blank: false },
{ row: cell.row, col: cell.col + 1, letter: 'K', blank: false },
],
words: ['OK'],
count: 1,
score: 6,
total: g.view.seats[opp].score + 6,
};
g.moves.push(move);
g.view.seats[opp].score = move.total;
g.view.moveCount += 1;
g.view.toMove = this.mySeat(g);
this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total });
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 });
}, 1600);
}
private firstEmptyPair(g: MockGame): { row: number; col: number } {
const occupied = new Set(g.moves.flatMap((m) => m.tiles.map((t) => `${t.row},${t.col}`)));
for (let row = 11; row < 15; row++) {
for (let col = 0; col < 14; col++) {
if (!occupied.has(`${row},${col}`) && !occupied.has(`${row},${col + 1}`)) return { row, col };
}
}
return { row: 0, col: 0 };
}
}
+192
View File
@@ -0,0 +1,192 @@
// Seed data for the mock transport. Enough to exercise the playable slice locally
// (pnpm start) with no backend: a profile, one active mid-game whose board already
// has tiles, and two finished games. Coordinates are 0-indexed (centre 7,7). Words do
// not need to be strictly legal here — this is a visual/interaction fixture; real
// legality and scoring come from the backend.
import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model';
export const ME = 'me';
export const SESSION: Session = {
token: 'mock-token',
userId: ME,
isGuest: true,
displayName: 'You',
};
export const PROFILE: Profile = {
userId: ME,
displayName: 'You',
preferredLanguage: 'en',
timeZone: 'UTC',
hintBalance: 3,
blockChat: false,
blockFriendRequests: false,
isGuest: true,
};
function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat {
return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner };
}
function play(
player: number,
dir: 'H' | 'V',
tiles: Array<[number, number, string]>,
words: string[],
score: number,
total: number,
): MoveRecord {
const ts = tiles.map(([row, col, letter]) => ({ row, col, letter, blank: false }));
return {
player,
action: 'play',
dir,
mainRow: ts[0]?.row ?? 7,
mainCol: ts[0]?.col ?? 7,
tiles: ts,
words,
count: words.length,
score,
total,
};
}
export interface MockGame {
view: GameView;
moves: MoveRecord[];
rack: string[];
bagLen: number;
hintsRemaining: number;
chat: ChatMessage[];
}
// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn ---
const G1_MOVES: MoveRecord[] = [
play(0, 'H', [
[7, 5, 'H'],
[7, 6, 'E'],
[7, 7, 'L'],
[7, 8, 'L'],
[7, 9, 'O'],
], ['HELLO'], 16, 16),
play(1, 'V', [
[6, 9, 'W'],
[8, 9, 'R'],
[9, 9, 'L'],
[10, 9, 'D'],
], ['WORLD'], 9, 9),
play(0, 'H', [
[8, 10, 'A'],
[8, 11, 'T'],
], ['RAT'], 3, 19),
play(1, 'V', [
[9, 10, 'N'],
[10, 10, 'D'],
], ['AND'], 4, 13),
];
function activeGame(): MockGame {
return {
view: {
id: 'g1',
variant: 'english',
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
moveCount: G1_MOVES.length,
endReason: '',
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
},
moves: G1_MOVES,
rack: ['R', 'E', 'T', 'I', 'N', 'A', '?'],
bagLen: 58,
hintsRemaining: 1,
chat: [
{
id: 'c1',
gameId: 'g1',
senderId: 'ann',
kind: 'message',
body: 'good luck!',
createdAtUnix: Math.floor(Date.now() / 1000) - 3600,
},
],
};
}
// --- finished games ---
function finishedG2(): MockGame {
return {
view: {
id: 'g2',
variant: 'english',
dictVersion: 'v1',
status: 'finished',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
moveCount: 2,
endReason: 'normal',
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
},
moves: [
play(0, 'H', [
[7, 6, 'Q'],
[7, 7, 'U'],
[7, 8, 'I'],
[7, 9, 'Z'],
], ['QUIZ'], 48, 48),
play(1, 'V', [
[6, 9, 'J'],
[8, 9, 'A'],
[9, 9, 'M'],
], ['JAZM'], 30, 30),
],
rack: [],
bagLen: 0,
hintsRemaining: 0,
chat: [],
};
}
function finishedG3(): MockGame {
return {
view: {
id: 'g3',
variant: 'russian',
dictVersion: 'v1',
status: 'finished',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
moveCount: 1,
endReason: 'resignation',
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
},
moves: [
play(0, 'H', [
[7, 6, 'С'],
[7, 7, 'Л'],
[7, 8, 'О'],
[7, 9, 'В'],
[7, 10, 'О'],
], ['СЛОВО'], 12, 12),
],
rack: [],
bagLen: 0,
hintsRemaining: 0,
chat: [],
};
}
export function seedGames(): Map<string, MockGame> {
const m = new Map<string, MockGame>();
for (const g of [activeGame(), finishedG2(), finishedG3()]) m.set(g.view.id, g);
return m;
}
+137
View File
@@ -0,0 +1,137 @@
// Domain model — plain TypeScript shapes the screens use, deliberately decoupled
// from the FlatBuffers wire types. Both the real transport (which decodes
// FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly.
export type Variant = 'english' | 'russian' | 'erudit';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
/** Play orientation: H is across a row, V is down a column. */
export type Direction = 'H' | 'V';
export interface Tile {
row: number;
col: number;
letter: string;
blank: boolean;
}
export interface Seat {
seat: number;
accountId: string;
displayName: string;
score: number;
hintsUsed: number;
isWinner: boolean;
}
export interface GameView {
id: string;
variant: Variant;
dictVersion: string;
status: GameStatus;
players: number;
toMove: number;
turnTimeoutSecs: number;
moveCount: number;
endReason: string;
seats: Seat[];
}
export interface MoveRecord {
player: number;
action: MoveAction;
dir: string;
mainRow: number;
mainCol: number;
tiles: Tile[];
words: string[];
count: number;
score: number;
total: number;
}
/** A seated player's private view of a game. */
export interface StateView {
game: GameView;
seat: number;
rack: string[];
bagLen: number;
hintsRemaining: number;
}
export interface MoveResult {
move: MoveRecord;
game: GameView;
}
export interface HintResult {
move: MoveRecord;
hintsRemaining: number;
}
export interface EvalResult {
legal: boolean;
score: number;
words: string[];
}
export interface WordCheckResult {
word: string;
legal: boolean;
}
export interface ChatMessage {
id: string;
gameId: string;
senderId: string;
kind: string;
body: string;
createdAtUnix: number;
}
export interface Profile {
userId: string;
displayName: string;
preferredLanguage: string;
timeZone: string;
hintBalance: number;
blockChat: boolean;
blockFriendRequests: boolean;
isGuest: boolean;
}
export interface Session {
token: string;
userId: string;
isGuest: boolean;
displayName: string;
}
export interface MatchResult {
matched: boolean;
game?: GameView;
}
export interface History {
gameId: string;
moves: MoveRecord[];
}
export interface GameList {
games: GameView[];
}
/** A live event delivered over the Subscribe stream. */
export type PushEvent =
| { kind: 'your_turn'; gameId: string; deadlineUnix: number }
| { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number }
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string }
| { kind: 'heartbeat' };
+116
View File
@@ -0,0 +1,116 @@
// Pure placement state machine for composing a play. The UI lifts tiles from the
// rack onto board cells (via drag or tap); this tracks the pending tiles, infers the
// play direction, supports per-tile recall and a full reset, and builds the submit
// payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable.
import type { Direction } from './model';
import type { PlacedTile } from './client';
export interface PendingTile {
/** Index of the rack slot this tile was lifted from. */
rackIndex: number;
row: number;
col: number;
/** Designated concrete letter (for a blank, the letter the player chose). */
letter: string;
/** Whether this tile came from a blank rack slot ("?"). */
blank: boolean;
}
export interface Placement {
/** The player's rack as dealt, e.g. ['A','Q','?','N','I','W','E']. */
rack: string[];
pending: PendingTile[];
}
export interface RackSlot {
index: number;
letter: string;
used: boolean;
}
export const BLANK = '?';
export function newPlacement(rack: string[]): Placement {
return { rack: [...rack], pending: [] };
}
function usedIndexes(p: Placement): Set<number> {
return new Set(p.pending.map((t) => t.rackIndex));
}
/** rackView lists each rack slot with whether it is currently placed on the board. */
export function rackView(p: Placement): RackSlot[] {
const used = usedIndexes(p);
return p.rack.map((letter, index) => ({ index, letter, used: used.has(index) }));
}
export function isBlankSlot(p: Placement, rackIndex: number): boolean {
return p.rack[rackIndex] === BLANK;
}
export function cellOccupied(p: Placement, row: number, col: number): boolean {
return p.pending.some((t) => t.row === row && t.col === col);
}
/**
* place lifts a rack slot onto (row, col). For a blank slot the caller must pass the
* designated letter. Returns the unchanged placement if the move is invalid (slot out
* of range, already used, target occupied, or a blank with no letter).
*/
export function place(
p: Placement,
rackIndex: number,
row: number,
col: number,
blankLetter?: string,
): Placement {
if (rackIndex < 0 || rackIndex >= p.rack.length) return p;
if (usedIndexes(p).has(rackIndex)) return p;
if (cellOccupied(p, row, col)) return p;
const blank = p.rack[rackIndex] === BLANK;
const letter = blank ? (blankLetter ?? '').toUpperCase() : p.rack[rackIndex];
if (blank && !letter) return p;
return { ...p, pending: [...p.pending, { rackIndex, row, col, letter, blank }] };
}
export function recallAt(p: Placement, row: number, col: number): Placement {
return { ...p, pending: p.pending.filter((t) => !(t.row === row && t.col === col)) };
}
export function recallIndex(p: Placement, rackIndex: number): Placement {
return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
}
export function reset(p: Placement): Placement {
return { ...p, pending: [] };
}
/**
* direction infers the play orientation from the pending tiles: H if they share a row,
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
*/
export function direction(p: Placement): Direction | null {
if (p.pending.length < 2) return null;
const rows = new Set(p.pending.map((t) => t.row));
const cols = new Set(p.pending.map((t) => t.col));
if (rows.size === 1 && cols.size === p.pending.length) return 'H';
if (cols.size === 1 && rows.size === p.pending.length) return 'V';
return null;
}
/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where
* the orientation cannot be inferred; otherwise the inferred direction is used. */
export function toSubmit(
p: Placement,
dirOverride?: Direction,
): { dir: Direction; tiles: PlacedTile[] } | null {
if (p.pending.length === 0) return null;
const dir = dirOverride ?? direction(p) ?? 'H';
const tiles: PlacedTile[] = p.pending
.slice()
.sort((a, b) => a.row - b.row || a.col - b.col)
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
return { dir, tiles };
}
+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()];
}
+60
View File
@@ -0,0 +1,60 @@
// Minimal dependency-free hash router. Hash routing survives a reload and works on
// a file:// origin (Capacitor native packaging), where there is no server to honour
// deep paths. The route is a reactive rune so screens re-render on navigation.
export type RouteName =
| 'login'
| 'lobby'
| 'new'
| 'game'
| 'profile'
| 'settings'
| 'about'
| 'notfound';
export interface Route {
name: RouteName;
params: Record<string, string>;
}
function parse(hash: string): Route {
const path = (hash.replace(/^#/, '') || '/').split('?')[0];
const seg = path.split('/').filter(Boolean);
if (seg.length === 0) return { name: 'lobby', params: {} };
switch (seg[0]) {
case 'login':
return { name: 'login', params: {} };
case 'new':
return { name: 'new', params: {} };
case 'game':
return seg[1] ? { name: 'game', params: { id: seg[1] } } : { name: 'notfound', params: {} };
case 'profile':
return { name: 'profile', params: {} };
case 'settings':
return { name: 'settings', params: {} };
case 'about':
return { name: 'about', params: {} };
default:
return { name: 'notfound', params: {} };
}
}
export const router = $state<{ route: Route }>({
route: parse(typeof location !== 'undefined' ? location.hash : ''),
});
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', () => {
router.route = parse(location.hash);
});
}
/** navigate switches the hash route (and forces a re-parse if it is unchanged). */
export function navigate(path: string): void {
const target = '#' + path;
if (location.hash === target) {
router.route = parse(target);
} else {
location.hash = path;
}
}
+133
View File
@@ -0,0 +1,133 @@
// Session + preferences persistence. The session token lives in memory for the app
// session and is mirrored to IndexedDB when available (so a reload does not force a
// re-login), with a localStorage fallback. Losing the store just means re-login —
// acceptable, and for a guest it simply mints a fresh guest.
import type { Session } from './model';
import type { ThemePref } from './theme';
import type { Locale } from './i18n/catalog';
const DB_NAME = 'scrabble';
const STORE = 'kv';
const LS_PREFIX = 'scrabble.';
let dbPromise: Promise<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}).catch(() => {
dbPromise = null;
throw new Error('indexedDB unavailable');
});
return dbPromise;
}
function lsGet<T>(key: string): T | null {
try {
const v = localStorage.getItem(LS_PREFIX + key);
return v ? (JSON.parse(v) as T) : null;
} catch {
return null;
}
}
function lsSet(key: string, value: unknown): void {
try {
localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
} catch {
/* storage unavailable — stay in-memory only */
}
}
function lsDel(key: string): void {
try {
localStorage.removeItem(LS_PREFIX + key);
} catch {
/* ignore */
}
}
async function kvGet<T>(key: string): Promise<T | null> {
const db = openDb();
if (!db) return lsGet<T>(key);
try {
const d = await db;
return await new Promise<T | null>((resolve, reject) => {
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key);
r.onsuccess = () => resolve((r.result ?? null) as T | null);
r.onerror = () => reject(r.error);
});
} catch {
return lsGet<T>(key);
}
}
async function kvSet(key: string, value: unknown): Promise<void> {
const db = openDb();
if (!db) return lsSet(key, value);
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
lsSet(key, value);
}
}
async function kvDel(key: string): Promise<void> {
const db = openDb();
if (!db) return lsDel(key);
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
lsDel(key);
}
}
const SESSION_KEY = 'session';
const PREFS_KEY = 'prefs';
export function loadSession(): Promise<Session | null> {
return kvGet<Session>(SESSION_KEY);
}
export function saveSession(s: Session): Promise<void> {
return kvSet(SESSION_KEY, s);
}
export function clearSession(): Promise<void> {
return kvDel(SESSION_KEY);
}
export interface Prefs {
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
return (await kvGet<Prefs>(PREFS_KEY)) ?? {};
}
export function savePrefs(p: Prefs): Promise<void> {
return kvSet(PREFS_KEY, p);
}
+47
View File
@@ -0,0 +1,47 @@
// Theme application. The design tokens are CSS custom properties (app.css); here we
// only flip how they resolve: 'auto' follows the OS, 'light'/'dark' force a value via
// [data-theme]. A Telegram Mini App can additionally override the token values from
// WebApp.themeParams — the mapping lives here so the token system is Telegram-ready,
// while the SDK is wired in the Telegram stage.
export type ThemePref = 'auto' | 'light' | 'dark';
export function applyTheme(pref: ThemePref): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (pref === 'auto') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', pref);
}
export function applyReduceMotion(on: boolean): void {
if (typeof document === 'undefined') return;
document.body.classList.toggle('reduce-motion', on);
}
/** Subset of Telegram WebApp.themeParams we map onto our tokens. */
export interface TelegramThemeParams {
bg_color?: string;
text_color?: string;
hint_color?: string;
link_color?: string;
button_color?: string;
button_text_color?: string;
secondary_bg_color?: string;
}
/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */
export function applyTelegramTheme(p: TelegramThemeParams): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const set = (value: string | undefined, name: string) => {
if (value) root.style.setProperty(name, value);
};
set(p.bg_color, '--bg');
set(p.bg_color, '--surface');
set(p.secondary_bg_color, '--surface-2');
set(p.text_color, '--text');
set(p.hint_color, '--text-muted');
set(p.button_color, '--accent');
set(p.button_text_color, '--accent-text');
set(p.link_color, '--accent');
}
+36
View File
@@ -0,0 +1,36 @@
// Placeholder for the real Connect-web + FlatBuffers transport, wired in the edge
// codegen task. Until then, selecting a non-mock mode surfaces a clear error instead
// of failing silently. The mock (lib/mock) backs `pnpm start`.
import type { GatewayClient } from './client';
import { GatewayError } from './client';
export function createTransport(_baseUrl: string): GatewayClient {
const ni = (): never => {
throw new GatewayError('unavailable', 'real transport not wired yet');
};
return {
setToken: () => {},
authGuest: ni,
authEmailRequest: ni,
authEmailLogin: ni,
profileGet: ni,
gamesList: ni,
lobbyEnqueue: ni,
lobbyPoll: ni,
gameState: ni,
gameHistory: ni,
submitPlay: ni,
pass: ni,
exchange: ni,
resign: ni,
hint: ni,
evaluate: ni,
checkWord: ni,
complaint: ni,
chatPost: ni,
chatList: ni,
nudge: ni,
subscribe: ni,
};
}