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