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
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app, bootstrap } from './lib/app.svelte';
import { router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import Toast from './components/Toast.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte';
import Profile from './screens/Profile.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Game from './game/Game.svelte';
onMount(() => {
void bootstrap();
});
</script>
{#if !app.ready}
<div class="splash">{t('common.loading')}</div>
{:else if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else}
<Lobby />
{/if}
<Toast />
<style>
.splash {
height: 100%;
display: grid;
place-items: center;
color: var(--text-muted);
}
</style>
+144
View File
@@ -0,0 +1,144 @@
/*
* Design tokens — pure CSS custom properties, no framework, no image/font/SVG
* assets. Light is the default; dark is applied either by the OS
* (prefers-color-scheme) or by an explicit [data-theme] set from Settings. A
* Telegram Mini App can override these same variables at runtime from
* WebApp.themeParams (see lib/theme — SDK wiring lands in the Telegram stage), so
* the whole UI re-themes without touching components.
*/
:root {
--bg: #f3f4f6;
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef0f3;
--text: #14181f;
--text-muted: #6b7280;
--border: #d8dce2;
--accent: #2f6df6;
--accent-text: #ffffff;
--danger: #d6453d;
--ok: #1f9d57;
--warn: #c9881b;
/* board + tiles (all drawn with CSS primitives) */
--board-bg: #cdd6cf;
--cell-bg: #e7ece8;
--cell-line: #b6c0b8;
--tile-bg: #f4e2b8;
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #ffe7a3;
--tile-recent: #fff6d8;
--prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */
--prem-dl: #a8cdec; /* double letter */
--prem-text: #2a2113;
/* shape + type */
--radius: 10px;
--radius-sm: 6px;
--gap: 8px;
--pad: 12px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
--accent: #5b8cff;
--accent-text: #0b0e13;
--danger: #f0635a;
--ok: #44c87f;
--warn: #e0a93a;
--board-bg: #2a3330;
--cell-bg: #222a27;
--cell-line: #38433d;
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
--prem-dl: #3b5a72;
--prem-text: #e7eaf0;
}
}
/* Explicit dark chosen in Settings (overrides OS preference). */
:root[data-theme="dark"] {
--bg: #0f1115;
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
--accent: #5b8cff;
--accent-text: #0b0e13;
--danger: #f0635a;
--ok: #44c87f;
--warn: #e0a93a;
--board-bg: #2a3330;
--cell-bg: #222a27;
--cell-line: #38433d;
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
--prem-dl: #3b5a72;
--prem-text: #e7eaf0;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 16px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* never let the page scroll/zoom out from under the board */
overscroll-behavior: none;
touch-action: manipulation;
}
#app {
height: 100%;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
.reduce-motion * {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
</script>
<header class="topbar">
{#if back}
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back"></button>
{:else}
<span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
</header>
<style>
.topbar {
display: flex;
align-items: center;
gap: var(--gap);
padding: 10px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
}
h1 {
font-size: 1.05rem;
margin: 0;
flex: 1;
text-align: center;
font-weight: 600;
}
.icon,
.spacer,
.end {
width: 40px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.end {
width: auto;
min-width: 40px;
}
.icon {
background: none;
border: none;
font-size: 1.1rem;
color: var(--text);
border-radius: var(--radius-sm);
}
.icon:hover {
background: var(--surface-2);
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '',
onclose,
children,
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => onclose?.()}>
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if title}<h2>{title}</h2>{/if}
{@render children?.()}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 40;
}
.sheet {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--pad);
width: min(94vw, 420px);
max-height: 86vh;
overflow: auto;
}
h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { app } from '../lib/app.svelte';
</script>
{#if app.toast}
<div class="toast {app.toast.kind}" role="status" aria-live="polite">{app.toast.text}</div>
{/if}
<style>
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
max-width: min(92vw, 420px);
padding: 10px 16px;
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--shadow);
z-index: 50;
text-align: center;
}
.error {
border-color: var(--danger);
color: var(--danger);
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
let {
board,
premium,
pending,
recent,
centre,
zoomed,
variant,
oncell,
ontogglezoom,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
} = $props();
const premClass: Record<Premium, string> = {
'': '',
TW: 'tw',
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
// Double-tap toggles zoom.
let lastTap = 0;
function onTap(row: number, col: number) {
const now = Date.now();
if (now - lastTap < 300) {
ontogglezoom();
lastTap = 0;
return;
}
lastTap = now;
oncell(row, col);
}
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
if (p.length < 2) return 0;
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) startDist = dist();
}
function onPointerMove(e: PointerEvent) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && startDist > 0) {
const d = dist();
if (!zoomed && d > startDist * 1.25) {
ontogglezoom();
startDist = 0;
} else if (zoomed && d < startDist * 0.8) {
ontogglezoom();
startDist = 0;
}
}
}
function onPointerUp(e: PointerEvent) {
pts.delete(e.pointerId);
if (pts.size < 2) startDist = 0;
}
function key(r: number, c: number): string {
return `${r},${c}`;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="grid" class:zoomed>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if premLabel[premium[r][c]]}
<span class="plabel">{premLabel[premium[r][c]]}</span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<style>
.viewport {
width: 100%;
background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm);
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
max-height: 70vh;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 2px;
width: 100%;
}
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 2px;
background: var(--cell-bg);
color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0;
overflow: hidden;
}
.cell.tw {
background: var(--prem-tw);
}
.cell.dw {
background: var(--prem-dw);
}
.cell.tl {
background: var(--prem-tl);
}
.cell.dl {
background: var(--prem-dl);
}
.cell.filled,
.cell.pending {
background: var(--tile-bg);
color: var(--tile-text);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.cell.pending {
background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell.recent {
box-shadow:
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
}
.letter {
font-size: 1.05em;
font-weight: 700;
line-height: 1;
}
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val {
position: absolute;
right: 1px;
bottom: 0;
font-size: 0.55em;
font-weight: 600;
}
.plabel {
opacity: 0.85;
font-weight: 600;
}
.star {
font-size: 1.1em;
opacity: 0.7;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { ChatMessage } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
let {
messages,
myId,
busy,
onsend,
onnudge,
}: {
messages: ChatMessage[];
myId: string;
busy: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
let text = $state('');
function send() {
const v = text.trim();
if (!v) return;
onsend(v);
text = '';
}
</script>
<div class="chat">
<div class="list">
{#if messages.length === 0}
<p class="empty">{t('chat.empty')}</p>
{/if}
{#each messages as m (m.id)}
{#if m.kind === 'nudge'}
<div class="note">{t('chat.nudge')}</div>
{:else}
<div class="msg" class:mine={m.senderId === myId}>{m.body}</div>
{/if}
{/each}
</div>
<div class="input">
<input
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button onclick={send} disabled={busy}>{t('chat.send')}</button>
<button class="nudge" onclick={onnudge} disabled={busy}>{t('chat.nudge')}</button>
</div>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
gap: 10px;
height: 56vh;
}
.list {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
}
.empty {
color: var(--text-muted);
text-align: center;
margin: auto;
}
.msg {
align-self: flex-start;
max-width: 80%;
padding: 7px 11px;
border-radius: 12px;
background: var(--surface-2);
}
.msg.mine {
align-self: flex-end;
background: var(--accent);
color: var(--accent-text);
}
.note {
align-self: center;
font-size: 0.82rem;
color: var(--text-muted);
font-style: italic;
}
.input {
display: flex;
gap: 6px;
}
.input input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}
.input button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import type { EvalResult } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
let {
preview,
hints,
busy,
ambiguous,
dir,
ondraw,
onskip,
onshuffle,
onhint,
ondir,
}: {
preview: EvalResult | null;
hints: number;
busy: boolean;
ambiguous: boolean;
dir: 'H' | 'V';
ondraw: () => void;
onskip: () => void;
onshuffle: () => void;
onhint: () => void;
ondir: () => void;
} = $props();
</script>
<div class="controls">
<div class="preview">
{#if preview}
{#if preview.legal}
<span class="ok">{t('game.preview', { n: preview.score })}</span>
{:else}
<span class="bad">{t('game.previewIllegal')}</span>
{/if}
{/if}
{#if ambiguous}
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
</div>
<div class="row">
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
</button>
</div>
</div>
<style>
.controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview {
min-height: 22px;
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.dir {
margin-left: auto;
width: 34px;
height: 28px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 1rem;
}
.row {
display: flex;
gap: 6px;
}
.row button {
flex: 1;
padding: 11px 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.row button:disabled {
opacity: 0.45;
}
.hint {
color: var(--accent);
}
</style>
+741
View File
@@ -0,0 +1,741 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Header from '../components/Header.svelte';
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import MakeMove from './MakeMove.svelte';
import Controls from './Controls.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import {
BLANK,
direction,
newPlacement,
place,
rackView,
recallAt,
reset,
toSubmit,
type Placement,
} from '../lib/placement';
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let dirOverride = $state<Direction | undefined>(undefined);
let busy = $state(false);
let zoomed = $state(false);
let selected = $state<number | null>(null);
let panel = $state<'none' | 'chat' | 'history'>('none');
let menuOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let checkOpen = $state(false);
let checkWord = $state('');
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant));
const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
);
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
const slots = $derived(rackView(placement));
const isMyTurn = $derived(
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
);
const gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
const ambiguous = $derived(placement.pending.length === 1);
async function load() {
try {
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
view = st;
moves = hist.moves;
placement = newPlacement(st.rack);
preview = null;
selected = null;
dirOverride = undefined;
} catch (e) {
handleError(e);
}
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
} catch (e) {
handleError(e);
}
}
onMount(load);
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
// --- tile placement: pointer drag + tap, both feeding the placement model ---
let downInfo: { index: number; x0: number; y0: number } | null = null;
let dragMoved = false;
let swallowClick = false;
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
downInfo = { index, x0: e.clientX, y0: e.clientY };
dragMoved = false;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement
}
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
'[data-cell]',
) as HTMLElement | null;
drag = null;
if (el?.dataset.row && el.dataset.col) {
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
} else if (di) {
selected = selected === di.index ? null : di.index;
drag = null;
}
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
if (pendingMap.has(`${row},${col}`)) {
placement = recallAt(placement, row, col);
recompute();
return;
}
if (selected != null) {
attemptPlace(selected, row, col);
selected = null;
}
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
}
placement = place(placement, index, row, col);
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
recompute();
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
} catch {
/* preview is best-effort */
}
}, 250);
}
async function commit() {
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function resetPlacement() {
placement = reset(placement);
preview = null;
selected = null;
dirOverride = undefined;
}
async function doPass() {
busy = true;
try {
await gateway.pass(id);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doResign() {
resignOpen = false;
busy = true;
try {
await gateway.resign(id);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doHint() {
try {
const h = await gateway.hint(id);
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join('');
showToast(t('game.hintShown', { word, n: h.move.score }));
if (view) view = { ...view, hintsRemaining: h.hintsRemaining };
} catch (e) {
handleError(e);
}
}
function shuffle() {
if (placement.pending.length > 0) return;
const r = [...placement.rack];
for (let i = r.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[r[i], r[j]] = [r[j], r[i]];
}
placement = newPlacement(r);
}
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() {
menuOpen = false;
resetPlacement();
exchangeSel = [];
exchangeOpen = true;
}
function toggleExch(i: number) {
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
}
async function doExchange() {
if (!view || exchangeSel.length === 0) return;
const tiles = exchangeSel.map((i) => view!.rack[i]);
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function openCheck() {
menuOpen = false;
checkWord = '';
checkResult = null;
checkOpen = true;
}
async function runCheck() {
const w = checkWord.trim();
if (!w) return;
try {
checkResult = await gateway.checkWord(id, w);
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!checkResult) return;
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
} catch (e) {
handleError(e);
}
}
function openChat() {
menuOpen = false;
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
const m = await gateway.chatPost(id, text);
messages = [...messages, m];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
const m = await gateway.nudge(id);
messages = [...messages, m];
} catch (e) {
handleError(e);
}
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
</script>
<Header title={t('app.title')} back="/">
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
<button onclick={openChat}>{t('game.chat')}</button>
<button onclick={openCheck}>{t('game.checkWord')}</button>
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
</div>
{/if}
{/snippet}
</Header>
{#if view}
<div class="scoreboard">
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
</div>
{/each}
</div>
<div class="boardwrap">
<Board
{board}
{premium}
pending={pendingMap}
{recent}
centre={ctr}
{zoomed}
{variant}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
/>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
{/if}
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<MakeMove
label={t('game.makeMove')}
resetLabel={t('game.reset')}
onmake={commit}
onreset={resetPlacement}
/>
{/if}
</div>
<Controls
{preview}
hints={view.hintsRemaining}
busy={busy || !isMyTurn}
{ambiguous}
{dir}
ondraw={openExchange}
onskip={doPass}
onshuffle={shuffle}
onhint={doHint}
ondir={toggleDir}
/>
{/if}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
</div>
{/if}
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabet(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>
</Modal>
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
{letter === BLANK ? '?' : letter}
</button>
{/each}
</div>
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
{t('game.exchangeConfirm', { n: exchangeSel.length })}
</button>
</Modal>
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<div class="check">
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} />
<button onclick={runCheck}>{t('game.checkWord')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
{checkResult.legal
? t('game.wordLegal', { word: checkResult.word })
: t('game.wordIllegal', { word: checkResult.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
{#if panel === 'history' && view}
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
<ol class="history">
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
</li>
{/each}
</ol>
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.seat {
flex: 1;
text-align: center;
padding: 4px;
border-radius: var(--radius-sm);
}
.seat.turn {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.seat.win .sc {
color: var(--ok);
}
.nm {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.boardwrap {
padding: 8px;
}
.status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--pad) 8px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.rack-row {
display: flex;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad);
}
.rack-wrap {
flex: 1;
min-width: 0;
}
:global(.rack-row .wrap) {
display: flex;
}
.loading {
text-align: center;
color: var(--text-muted);
padding: 40px;
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
.ghost {
position: fixed;
width: 40px;
height: 40px;
transform: translate(-50%, -50%);
background: var(--tile-pending);
color: var(--tile-text);
border-radius: 5px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 1.3rem;
box-shadow: var(--shadow);
pointer-events: none;
z-index: 60;
}
.alpha {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.alpha button {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 700;
}
.exch {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.etile {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 5px;
font-weight: 700;
}
.etile.sel {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.confirm {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-sm);
font-weight: 700;
}
.confirm:disabled {
opacity: 0.5;
}
.check {
display: flex;
gap: 6px;
}
.check input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}
.check button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.complain {
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
.history {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
// The contextual commit control: appears when tiles are pending. A short tap opens a
// minimalist popup (Make move / Reset); a press-and-hold (~1s) commits immediately.
let {
label,
resetLabel,
onmake,
onreset,
}: { label: string; resetLabel: string; onmake: () => void; onreset: () => void } = $props();
let popup = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let held = false;
function clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function down() {
held = false;
clear();
timer = setTimeout(() => {
held = true;
popup = false;
onmake();
}, 1000);
}
function up() {
clear();
if (!held) popup = true;
}
function leave() {
clear();
}
</script>
<div class="wrap">
<button
class="make"
onpointerdown={down}
onpointerup={up}
onpointerleave={leave}
onpointercancel={leave}
>
{label}
</button>
{#if popup}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (popup = false)}></div>
<div class="popup">
<button
class="go"
onclick={() => {
popup = false;
onmake();
}}>{label}</button
>
<button
class="rs"
onclick={() => {
popup = false;
onreset();
}}>{resetLabel}</button
>
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
}
.make {
height: 100%;
min-width: 64px;
padding: 0 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-weight: 700;
touch-action: none;
user-select: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 18;
}
.popup {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 19;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px;
min-width: 140px;
}
.popup button {
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.popup .go {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import type { RackSlot } from '../lib/placement';
import { BLANK } from '../lib/placement';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
let {
slots,
variant,
selected,
ondown,
}: {
slots: RackSlot[];
variant: Variant;
selected: number | null;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
</script>
<div class="rack">
{#each slots as slot (slot.index)}
{#if slot.used}
<span class="slot empty"></span>
{:else}
<button
class="slot tile"
class:selected={selected === slot.index}
data-rack-index={slot.index}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
</button>
{/if}
{/each}
</div>
<style>
.rack {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
}
.slot {
aspect-ratio: 1;
border-radius: 5px;
}
.empty {
background: var(--surface-2);
border: 1px dashed var(--border);
}
.tile {
position: relative;
background: var(--tile-bg);
color: var(--tile-text);
border: none;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
touch-action: none;
user-select: none;
}
.tile.selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.val {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 0.7rem;
font-weight: 600;
}
</style>
+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,
};
}
+5
View File
@@ -0,0 +1,5 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
export default mount(App, { target: document.getElementById('app')! });
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import { t } from '../lib/i18n/index.svelte';
const version = '0.7.0';
</script>
<Header title={t('about.title')} back="/" />
<main class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p>
</main>
<style>
.page {
padding: var(--pad);
}
.muted {
color: var(--text-muted);
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount } from 'svelte';
import Header from '../components/Header.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() {
try {
games = (await gateway.gamesList()).games;
} catch (e) {
handleError(e);
}
}
onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => {
if (app.lastEvent) void load();
});
const myId = $derived(app.session?.userId ?? '');
const active = $derived(games.filter((g) => g.status === 'active'));
const finished = $derived(games.filter((g) => g.status !== 'active'));
function mySeat(g: GameView) {
return g.seats.find((s) => s.accountId === myId);
}
function opponents(g: GameView): string {
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
.join(', ');
}
function subtitle(g: GameView): string {
const me = mySeat(g);
if (g.status === 'active') {
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
}
if (me?.isWinner) return t('game.won');
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
function scoreline(g: GameView): string {
const me = mySeat(g);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
function go(path: string) {
menuOpen = false;
navigate(path);
}
</script>
<Header title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
</div>
{/if}
{/snippet}
</Header>
<main class="lobby">
<section>
<h2>{t('lobby.activeGames')}</h2>
{#if active.length === 0}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
{#each active as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
<section>
<h2>{t('lobby.finishedGames')}</h2>
{#if finished.length === 0}
<p class="empty">{t('lobby.noFinished')}</p>
{/if}
{#each finished as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub">{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
</main>
<nav class="tabs">
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
</nav>
<style>
.lobby {
padding: var(--pad);
padding-bottom: 84px;
display: flex;
flex-direction: column;
gap: 18px;
}
h2 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px;
}
.empty {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
}
.who {
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
.sub.turn {
color: var(--accent);
font-weight: 600;
}
.score {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85rem;
}
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
padding: 10px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.tab.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
let email = $state('');
let code = $state('');
let stage = $state<'choose' | 'code'>('choose');
let busy = $state(false);
async function sendCode() {
if (!email.trim()) return;
busy = true;
const ok = await requestEmailCode(email.trim());
busy = false;
if (ok) stage = 'code';
}
async function signIn() {
busy = true;
await loginEmail(email.trim(), code.trim());
busy = false;
}
</script>
<main class="login">
<div class="card">
<h1>{t('app.title')}</h1>
<button class="primary" disabled={busy} onclick={() => loginGuest()}>
{t('login.guest')}
</button>
<div class="divider"><span>{t('login.email')}</span></div>
{#if stage === 'choose'}
<input
type="email"
autocomplete="email"
placeholder={t('login.emailPlaceholder')}
bind:value={email}
/>
<button class="secondary" disabled={busy || !email.trim()} onclick={sendCode}>
{t('login.sendCode')}
</button>
{:else}
<p class="muted">{t('login.codeSent', { email })}</p>
<input
inputmode="numeric"
autocomplete="one-time-code"
placeholder={t('login.codePlaceholder')}
bind:value={code}
/>
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
{t('login.signIn')}
</button>
{/if}
</div>
</main>
<style>
.login {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
}
.card {
width: min(94vw, 360px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
h1 {
margin: 0 0 8px;
text-align: center;
}
input {
padding: 11px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font-size: 1rem;
}
button {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
font-weight: 600;
}
.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.secondary {
background: var(--surface);
color: var(--text);
}
button:disabled {
opacity: 0.5;
}
.divider {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Header from '../components/Header.svelte';
import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { Variant } from '../lib/model';
const variants: { id: Variant; label: MessageKey }[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' },
];
let searching = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
async function find(v: Variant) {
searching = true;
try {
const r = await gateway.lobbyEnqueue(v);
if (r.matched && r.game) {
navigate(`/game/${r.game.id}`);
return;
}
// The match also arrives via the live stream (handled in app), but poll as a
// fallback for a client that is not currently streaming.
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
} catch (e) {
searching = false;
handleError(e);
}
}
onDestroy(stop);
</script>
<Header title={t('new.title')} back="/" />
<main class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{/if}
</main>
<style>
.page {
padding: var(--pad);
}
.subtitle {
color: var(--text-muted);
}
.variants {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.variant {
padding: 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
font-size: 1.05rem;
font-weight: 600;
}
.searching {
display: grid;
place-items: center;
gap: 14px;
padding: 48px 0;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import { app, logout } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
</script>
<Header title={t('profile.title')} back="/" />
<main class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</main>
<style>
.page {
padding: var(--pad);
}
.name {
font-size: 1.4rem;
font-weight: 700;
}
.badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.8rem;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 20px 0;
}
dt {
color: var(--text-muted);
}
dd {
margin: 0;
text-align: right;
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.logout {
margin-top: 16px;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto',
light: 'settings.themeLight',
dark: 'settings.themeDark',
};
const locales: Locale[] = ['en', 'ru'];
</script>
<Header title={t('settings.title')} back="/" />
<main class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
<section>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</main>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 20px;
}
h3 {
margin: 0 0 8px;
font-size: 0.95rem;
color: var(--text-muted);
}
.seg {
display: flex;
gap: 8px;
}
.opt {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */
readonly VITE_GATEWAY_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}