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:
@@ -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();
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 ?? '');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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': 'Что-то пошло не так.',
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user