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
+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': 'Что-то пошло не так.',
};