R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
+7
-7
@@ -4,10 +4,10 @@ Pure-HTML5 game client — **plain Svelte 5 (runes) + TypeScript + Vite**, no
|
||||
SvelteKit. Talks to the `gateway` over **Connect-RPC + FlatBuffers**; embeddable in
|
||||
platform webviews and packageable to native via Capacitor.
|
||||
|
||||
Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby,
|
||||
The **playable slice**: sign in (guest / email), the "my games" lobby,
|
||||
auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint,
|
||||
word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru),
|
||||
theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes),
|
||||
theme, and the profile. **Social** surfaces add friends/blocks (with one-time friend codes),
|
||||
friend-game invitations, profile editing + email binding, the statistics screen, the
|
||||
lobby notification badge, and the in-game history + GCG export (share or download,
|
||||
finished games only).
|
||||
@@ -26,11 +26,11 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
||||
```
|
||||
|
||||
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
|
||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
|
||||
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
|
||||
share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
|
||||
are the per-language "Play in Telegram" links shown on the landing page.
|
||||
|
||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||
@@ -40,7 +40,7 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
A single Connect `Execute(message_type, payload)` carries every unary op; the request
|
||||
and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`.
|
||||
The session token rides in `Authorization: Bearer`; a domain failure comes back in
|
||||
`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta**
|
||||
`result_code`. `Subscribe` is the live event stream; its game events carry a state **delta**
|
||||
that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without
|
||||
a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real
|
||||
client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken
|
||||
@@ -48,7 +48,7 @@ out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`
|
||||
|
||||
**No board on the wire:** `StateView` is a summary + rack only, so the client
|
||||
reconstructs the 15×15 board by replaying the decoded move journal (`game.history`).
|
||||
**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange /
|
||||
**The play loop is alphabet-agnostic:** the rack and the play / exchange /
|
||||
word-check requests carry **alphabet indices**, and the client caches each variant's
|
||||
`(index, letter, value)` table — sent once behind `StateRequest.include_alphabet` — in
|
||||
`lib/alphabet.ts`, rendering the rack and blank chooser from it. **Premium squares**
|
||||
|
||||
+5
-5
@@ -16,7 +16,7 @@ async function openGame(page: Page): Promise<void> {
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
}
|
||||
|
||||
test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => {
|
||||
test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => {
|
||||
await openGame(page);
|
||||
// The exchange/draw tab is a server action; on my turn with tiles in the bag it is live.
|
||||
const draw = page.locator('.tab').first();
|
||||
@@ -56,7 +56,7 @@ test('a placed tile is saved as a draft and restored on reopening the game', asy
|
||||
await page.waitForTimeout(600); // let the debounced draft save flush to the mock store
|
||||
|
||||
// Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is
|
||||
// restored without re-placing it (Stage 17 #4/#6).
|
||||
// restored without re-placing it.
|
||||
await page.evaluate(() => (location.hash = '/'));
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
@@ -77,7 +77,7 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page
|
||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
|
||||
// A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger).
|
||||
// A single tap must NOT recall it (recall was too easy to trigger).
|
||||
await page.waitForTimeout(350); // clear the double-tap window from the placing tap
|
||||
await page.locator('[data-cell].pending').first().click();
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
@@ -158,7 +158,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => {
|
||||
test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.rack .tile').first().click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||
@@ -181,7 +181,7 @@ test('a placed tile drags from one board cell to another (Stage 17 relocation)',
|
||||
expect(to).not.toBe(from);
|
||||
});
|
||||
|
||||
test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => {
|
||||
test('chat and word-check open as their own screens and back to the game', async ({ page }) => {
|
||||
await openGame(page);
|
||||
|
||||
await page.locator('.burger').click();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
||||
// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
||||
// the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html.
|
||||
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
||||
await page.goto('/landing.html');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// Stage 8 social / account / history surfaces against the mock transport (no backend).
|
||||
// Social / account / history surfaces against the mock transport (no backend).
|
||||
// The mock profile is a durable account, so friends, invitations, stats and the GCG
|
||||
// export are reachable from the seeded fixture.
|
||||
|
||||
@@ -154,7 +154,7 @@ test('game: an opponent who is already a friend shows a disabled "in friends"',
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend
|
||||
await page.locator('.burger').first().click();
|
||||
// The in-game friend item is derived from the server's friend list (Stage 17): a friend reads
|
||||
// The in-game friend item is derived from the server's friend list: a friend reads
|
||||
// a disabled "✓ in friends", not the addable "Add to friends".
|
||||
const inFriends = page.getByRole('button', { name: /in friends/i });
|
||||
await expect(inFriends).toBeVisible();
|
||||
@@ -210,7 +210,7 @@ test('chat: the message field shows on your turn, the nudge replaces it otherwis
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: 'Chat' }).click();
|
||||
// On your turn the message field + Send are shown and the nudge is hidden (Stage 17);
|
||||
// On your turn the message field + Send are shown and the nudge is hidden;
|
||||
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
|
||||
// through the aria-label.
|
||||
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
// - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump.
|
||||
// - landing own: the landing's own code; kept minimal.
|
||||
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
|
||||
// ~2 KB. Lazy-loading was analysed and rejected for R5 (no total-size win — every chunk still
|
||||
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
|
||||
// ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers
|
||||
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope. See
|
||||
// PRERELEASE.md R5 for the full rationale.
|
||||
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope.
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { aboutContent } from './lib/aboutContent';
|
||||
import { telegramChannelLink } from './lib/landing';
|
||||
|
||||
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
|
||||
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
|
||||
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
||||
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
|
||||
@@ -192,7 +192,7 @@
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link
|
||||
keeps an aria-label for assistive tech (Stage 17). */
|
||||
keeps an aria-label for assistive tech. */
|
||||
.tg {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
|
||||
+2
-2
@@ -42,10 +42,10 @@
|
||||
--gap: 8px;
|
||||
--pad: 12px;
|
||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||
content-safe-area inset (Stage 17), 0 elsewhere. */
|
||||
content-safe-area inset, 0 elsewhere. */
|
||||
--tg-content-top: 0px;
|
||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
||||
--tg-content-top, so the in-app header aligns to that band (Stage 17), 0 elsewhere. */
|
||||
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
||||
--tg-safe-top: 0px;
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", "Liberation Sans", sans-serif;
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
|
||||
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
|
||||
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
|
||||
which sit in the band's corners (Stage 17). */
|
||||
which sit in the band's corners. */
|
||||
:global(html.tg-fullscreen) .bar {
|
||||
min-height: var(--tg-content-top);
|
||||
box-sizing: border-box;
|
||||
@@ -119,7 +119,7 @@
|
||||
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
|
||||
notch) so Telegram's native controls aren't flush against our header. Applied as
|
||||
padding because the bar is sized by its content here, not by min-height (owner review
|
||||
tweaks, Stage 17). */
|
||||
tweaks). */
|
||||
padding-top: calc(var(--tg-safe-top) + 6px);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
// bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the
|
||||
// keyboard with a transform (kb), driven by the visual viewport — a compositor-only
|
||||
// move, so neither the page behind nor the sheet relayouts as the keyboard animates
|
||||
// (Stage 17). The backdrop is not resized in this mode (no per-event reflow).
|
||||
// The backdrop is not resized in this mode (no per-event reflow).
|
||||
let vh = $state(0);
|
||||
let top = $state(0);
|
||||
let kb = $state(0);
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
/* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the
|
||||
soft keyboard via a transform (--kb) — compositor-only, so the page behind and the
|
||||
sheet itself do not relayout as the keyboard animates (Stage 17). */
|
||||
sheet itself do not relayout as the keyboard animates. */
|
||||
.backdrop.bottom {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
|
||||
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
|
||||
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
|
||||
// banner.ts logic — is dead-code-eliminated from the production bundle (Stage 17). Flip to
|
||||
// banner.ts logic — is dead-code-eliminated from the production bundle. Flip to
|
||||
// true to bring it back.
|
||||
const SHOW_AD_BANNER = false;
|
||||
|
||||
// Edge-swipe back (Stage 17): a left-edge rightward drag returns to `back`, the standard
|
||||
// Edge-swipe back: a left-edge rightward drag returns to `back`, the standard
|
||||
// mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer
|
||||
// handlers (which capture/stop the event) can never swallow it; armed only from the very
|
||||
// left edge (<=24px), touch/pen only, so it never competes with the board's gestures.
|
||||
@@ -72,7 +72,7 @@
|
||||
flex-direction: column;
|
||||
/* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a
|
||||
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
||||
scrolling; falls back to the full height where the var is unset (Stage 17). */
|
||||
scrolling; falls back to the full height where the var is unset. */
|
||||
height: var(--vvh, 100%);
|
||||
}
|
||||
.content {
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch
|
||||
// Pinch zoom: a two-finger spread zooms in toward the pinch midpoint, a pinch
|
||||
// close zooms out. preventDefault fires only for two touches, so the one-finger native
|
||||
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
|
||||
// double-tap, toggling toward the midpoint cell.
|
||||
@@ -278,7 +278,7 @@
|
||||
.cell.pending {
|
||||
background: var(--tile-pending);
|
||||
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
|
||||
board) instead of the touch starting a board pan (Stage 17). */
|
||||
board) instead of the touch starting a board pan. */
|
||||
touch-action: none;
|
||||
}
|
||||
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
messages: ChatMessage[];
|
||||
myId: string;
|
||||
busy: boolean;
|
||||
// Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the
|
||||
// Chat and nudge are mutually exclusive by turn: on the player's own turn the
|
||||
// message field + send are shown (and nudging makes no sense — there is no one to
|
||||
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
|
||||
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
|
||||
@@ -72,7 +72,7 @@
|
||||
gap: 10px;
|
||||
/* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen
|
||||
fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input
|
||||
stays visible — no modal relayout, no page jump (Stage 17). */
|
||||
stays visible — no modal relayout, no page jump. */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px var(--pad);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, StateView } from '../lib/model';
|
||||
|
||||
// The chat is its own screen (Stage 17), so the soft keyboard simply resizes the viewport with
|
||||
// The chat is its own screen, so the soft keyboard simply resizes the viewport with
|
||||
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
|
||||
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
// Word-check on its own screen (Stage 17): unlimited dictionary lookups, each with a
|
||||
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
||||
// complaint, off the board so the soft keyboard never relayouts the play area.
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
|
||||
+14
-14
@@ -112,7 +112,7 @@
|
||||
}
|
||||
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
|
||||
// debounce; best-effort, so a failed save never interrupts play (Stage 17).
|
||||
// debounce; best-effort, so a failed save never interrupts play.
|
||||
function scheduleDraftSave() {
|
||||
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
||||
draftSaveTimer = setTimeout(() => {
|
||||
@@ -178,7 +178,7 @@
|
||||
if (!e) return;
|
||||
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||
// the move as a delta with no fetch (R4).
|
||||
// the move as a delta with no fetch.
|
||||
if (placement.pending.length > 0) void load();
|
||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||
@@ -208,15 +208,15 @@
|
||||
let hoverKey = '';
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
|
||||
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
|
||||
// target while carrying a tile over the board. Null over an occupied cell.
|
||||
let dropTarget = $state<{ row: number; col: number } | null>(null);
|
||||
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
|
||||
// Rack reordering: while a rack tile is dragged, reorderDragId is its stable id
|
||||
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
|
||||
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
|
||||
let reorderDragId = $state<number | null>(null);
|
||||
let reorderTo = $state<number | null>(null);
|
||||
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
||||
// hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17).
|
||||
// hidden from the board (the ghost stands in) like a lifted rack tile.
|
||||
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
@@ -245,7 +245,7 @@
|
||||
drag = null;
|
||||
}
|
||||
function onRackDown(e: PointerEvent, index: number) {
|
||||
// Tiles may be arranged on the opponent's turn too (Stage 17 #5): only placement is
|
||||
// Tiles may be arranged on the opponent's turn too: only placement is
|
||||
// relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is
|
||||
// position-only (never scored or submitted).
|
||||
if (busy || gameOver) return;
|
||||
@@ -390,7 +390,7 @@
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
clearHover();
|
||||
clearReorder();
|
||||
// Flush a pending draft save so leaving mid-composition still persists it (Stage 17).
|
||||
// Flush a pending draft save so leaving mid-composition still persists it.
|
||||
if (draftSaveTimer) {
|
||||
clearTimeout(draftSaveTimer);
|
||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||
@@ -401,7 +401,7 @@
|
||||
function onCell(row: number, col: number) {
|
||||
if (swallowClick) return;
|
||||
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
|
||||
// by a single tap (which recalled too easily — Stage 17).
|
||||
// by a single tap (which recalled too easily).
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
if (selected != null) {
|
||||
// A committed tile already sits here: keep the rack selection so a stray tap
|
||||
@@ -418,7 +418,7 @@
|
||||
scheduleDraftSave();
|
||||
}
|
||||
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
|
||||
// (a board→board drag), keeping its rack slot and any blank letter (Stage 17).
|
||||
// (a board→board drag), keeping its rack slot and any blank letter.
|
||||
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
|
||||
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
|
||||
if (!pt) return;
|
||||
@@ -459,7 +459,7 @@
|
||||
function recompute() {
|
||||
preview = null;
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
// Off-turn the composition is position-only: no score preview or evaluate (Stage 17 #5).
|
||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||
if (!isMyTurn) return;
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
if (!sub) return;
|
||||
@@ -473,7 +473,7 @@
|
||||
}
|
||||
|
||||
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
||||
// post-move game and the refilled rack — without a follow-up game.state + game.history (R4).
|
||||
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
||||
function applyMoveResult(r: MoveResult) {
|
||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||
moves = [...moves, r.move];
|
||||
@@ -613,7 +613,7 @@
|
||||
}
|
||||
|
||||
// Friend state for the in-game "add to friends" item, derived from the server so it is
|
||||
// correct across reloads and live-updates when a request is answered (Stage 17):
|
||||
// correct across reloads and live-updates when a request is answered:
|
||||
// `friends` are the caller's accepted friends; `requested` are the addressees already
|
||||
// requested (pending or declined — both block a re-send and read as "request sent").
|
||||
let friends = $state(new Set<string>());
|
||||
@@ -985,7 +985,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
||||
while the pending word is known to be illegal (Stage 17). */
|
||||
while the pending word is known to be illegal. */
|
||||
.make {
|
||||
min-width: 56px;
|
||||
background: none;
|
||||
@@ -1045,7 +1045,7 @@
|
||||
pointer-events: none;
|
||||
z-index: 60;
|
||||
}
|
||||
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */
|
||||
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */
|
||||
.ghost.touch {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
selected: number | null;
|
||||
shuffling?: boolean;
|
||||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17).
|
||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||
draggingId?: number | null;
|
||||
dropIndex?: number | null;
|
||||
ondown: (e: PointerEvent, index: number) => void;
|
||||
@@ -93,7 +93,7 @@
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
|
||||
dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */
|
||||
dragged tile's slot; suppress it so only our own styles mark a tile. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.tile.selected {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Localised "About" / landing copy, shared by the About screen and the public landing
|
||||
// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading,
|
||||
// page. Kept out of the flat i18n catalog because it is structured (a heading,
|
||||
// a rules link, two bulleted sections) and only used in these two long-form places.
|
||||
|
||||
import type { Locale } from './i18n/index.svelte';
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from './alphabet';
|
||||
|
||||
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
|
||||
describe('alphabet cache (Stage 13)', () => {
|
||||
describe('alphabet cache', () => {
|
||||
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
|
||||
// Per-variant alphabet table cache. The client is alphabet-agnostic: it caches
|
||||
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
|
||||
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
|
||||
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface Toast {
|
||||
|
||||
export const app = $state<{
|
||||
ready: boolean;
|
||||
/** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */
|
||||
/** Whether the live-event stream is connected; drives the matchmaking poll fallback. */
|
||||
streamAlive: boolean;
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
@@ -154,12 +154,12 @@ function openStream(): void {
|
||||
showToast(t('game.yourTurn'), 'info');
|
||||
} else if (e.kind === 'match_found') {
|
||||
// Seed the cache from the event's initial state so the game renders instantly on arrival,
|
||||
// then navigate (R4).
|
||||
// then navigate.
|
||||
if (e.state) setCachedGame(e.state.game.id, e.state, []);
|
||||
navigate(`/game/${e.gameId}`);
|
||||
} else if (e.kind === 'notify') {
|
||||
// A started invited game seeds its cache so opening it is instant; the lobby badge stays
|
||||
// on the authoritative refresh (R4).
|
||||
// on the authoritative refresh.
|
||||
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
|
||||
void refreshNotifications();
|
||||
}
|
||||
@@ -231,7 +231,7 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* applyLinkResult applies a completed account link or merge (Stage 11): it adopts a
|
||||
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||
* account changed) or, otherwise, refreshes the current profile in place.
|
||||
*/
|
||||
@@ -261,7 +261,7 @@ function syncTelegramChrome(): void {
|
||||
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
||||
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
||||
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its
|
||||
* band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||
*/
|
||||
function syncTelegramSafeArea(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
@@ -275,7 +275,7 @@ function syncTelegramSafeArea(): void {
|
||||
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears (Stage 17).
|
||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
||||
*/
|
||||
function syncViewportHeight(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface GatewayClient {
|
||||
lobbyCancel(): Promise<void>;
|
||||
|
||||
// --- game ---
|
||||
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
|
||||
// The play loop exchanges alphabet indices, so submit/evaluate/exchange/
|
||||
// check-word take the game's variant (to map letters<->indices via the cached alphabet
|
||||
// table), and gameState's includeAlphabet asks the server to embed that table.
|
||||
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||
@@ -82,10 +82,10 @@ export interface GatewayClient {
|
||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||
/** Hide a finished game from the caller's own lobby list (Stage 17); per-account, irreversible. */
|
||||
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
|
||||
hideGame(gameId: string): Promise<void>;
|
||||
|
||||
// --- draft (Stage 17) ---
|
||||
// --- draft ---
|
||||
/** The player's server-persisted client-side composition (rack order + board tiles), so a
|
||||
* reload or a second device resumes the same arrangement. The JSON is opaque to the
|
||||
* gateway; the client owns the {rack_order, board_tiles} shape. */
|
||||
@@ -97,7 +97,7 @@ export interface GatewayClient {
|
||||
chatList(gameId: string): Promise<ChatMessage[]>;
|
||||
nudge(gameId: string): Promise<ChatMessage>;
|
||||
|
||||
// --- friends (Stage 8) ---
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
|
||||
@@ -109,24 +109,24 @@ export interface GatewayClient {
|
||||
friendCodeIssue(): Promise<FriendCode>;
|
||||
friendCodeRedeem(code: string): Promise<AccountRef>;
|
||||
|
||||
// --- blocks (Stage 8) ---
|
||||
// --- blocks ---
|
||||
blocksList(): Promise<AccountRef[]>;
|
||||
block(accountId: string): Promise<void>;
|
||||
unblock(accountId: string): Promise<void>;
|
||||
|
||||
// --- invitations (Stage 8) ---
|
||||
// --- invitations ---
|
||||
invitationsList(): Promise<Invitation[]>;
|
||||
invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation>;
|
||||
invitationAccept(invitationId: string): Promise<Invitation>;
|
||||
invitationDecline(invitationId: string): Promise<Invitation>;
|
||||
invitationCancel(invitationId: string): Promise<void>;
|
||||
|
||||
// --- profile / stats / history (Stage 8) ---
|
||||
// --- profile / stats / history ---
|
||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||
statsGet(): Promise<Stats>;
|
||||
exportGcg(gameId: string): Promise<GcgExport>;
|
||||
|
||||
// --- account linking & merge (Stage 11) ---
|
||||
// --- account linking & merge ---
|
||||
linkEmailRequest(email: string): Promise<void>;
|
||||
linkEmailConfirm(email: string, code: string): Promise<LinkResult>;
|
||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('round-trips a draft save request and view (Stage 17)', () => {
|
||||
it('round-trips a draft save request and view', () => {
|
||||
const json = '{"rack_order":"1,0","board_tiles":[]}';
|
||||
const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
|
||||
expect(req.gameId()).toBe('g1');
|
||||
@@ -35,7 +35,7 @@ describe('codec', () => {
|
||||
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
||||
});
|
||||
|
||||
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
|
||||
it('encodes a SubmitPlayRequest with alphabet indices', () => {
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
@@ -268,10 +268,10 @@ describe('codec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
// The live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
|
||||
// the whole table), so they are independent of order.
|
||||
describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
describe('codec — alphabet on the wire', () => {
|
||||
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
|
||||
+7
-7
@@ -38,7 +38,7 @@ import type {
|
||||
|
||||
// --- request encoders ---
|
||||
|
||||
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
|
||||
// buildPlayTile encodes one to-place tile by its alphabet index; a placed blank
|
||||
// carries its designated letter's index with blank set.
|
||||
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
|
||||
fb.PlayTile.startPlayTile(b);
|
||||
@@ -73,7 +73,7 @@ export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Ui
|
||||
return finish(b, fb.StateRequest.endStateRequest(b));
|
||||
}
|
||||
|
||||
// encodeDraftSave wraps the player's composition JSON (Stage 17). The string is opaque on the
|
||||
// encodeDraftSave wraps the player's composition JSON. The string is opaque on the
|
||||
// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}.
|
||||
export function encodeDraftSave(gameId: string, json: string): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
@@ -324,7 +324,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
|
||||
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
|
||||
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
|
||||
// rack to display letters with it (Stage 13).
|
||||
// rack to display letters with it.
|
||||
function decodeStateViewTable(v: fb.StateView): StateView {
|
||||
const g = v.game();
|
||||
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
|
||||
@@ -355,7 +355,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
|
||||
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
|
||||
const m = r.move();
|
||||
const g = r.game();
|
||||
// The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant.
|
||||
// The actor's refilled rack rides back as alphabet indices; decode it with the game's variant.
|
||||
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
|
||||
const rack: string[] = [];
|
||||
for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
|
||||
@@ -493,7 +493,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stage 8 encoders ---
|
||||
// --- social encoders ---
|
||||
|
||||
export function encodeTarget(accountId: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
@@ -563,7 +563,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
// --- account linking & merge (Stage 11) ---
|
||||
// --- account linking & merge ---
|
||||
|
||||
export function encodeLinkEmailRequest(email: string): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
@@ -604,7 +604,7 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
||||
};
|
||||
}
|
||||
|
||||
// --- Stage 8 decoders ---
|
||||
// --- social decoders ---
|
||||
|
||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to
|
||||
// Global connectivity signal. `online` is false while the app is actively failing to
|
||||
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
|
||||
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
|
||||
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Draft (client-side composition) serialization, kept pure for unit tests. The server stores
|
||||
// the JSON opaquely (Stage 17); only the client interprets {rack_order, board_tiles}. The rack
|
||||
// the JSON opaquely; only the client interprets {rack_order, board_tiles}. The rack
|
||||
// order is a comma-joined permutation of the server rack's indices, in the player's visual
|
||||
// order; the board tiles are the tiles laid but not yet submitted.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('applyMoveDelta', () => {
|
||||
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
|
||||
});
|
||||
|
||||
it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => {
|
||||
it('refetches when the payload carries no delta (older peer / dropped payload)', () => {
|
||||
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move
|
||||
// Pure reducers that advance the per-game cache from live events, so the UI renders a move
|
||||
// from the event without a follow-up game.state + game.history fetch. They never touch the network
|
||||
// or the cache store — the stream handler applies the returned cache and the game screen acts on
|
||||
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
|
||||
@@ -42,7 +42,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De
|
||||
// Nothing cached to advance (the game was never opened on this device): ignore it; the next open
|
||||
// cold-loads the game.
|
||||
if (!cached) return { refetch: false };
|
||||
// A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch.
|
||||
// An older peer, or a dropped payload, carried no delta: the open game must refetch.
|
||||
if (!d.move || !d.game) return { refetch: true };
|
||||
const have = cached.view.game.moveCount;
|
||||
const next = d.game.moveCount;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
||||
// Pure helpers for the public landing page, kept out of the Svelte component so
|
||||
// the per-language Telegram-channel link selection is unit-testable.
|
||||
|
||||
import type { Locale } from './i18n/index.svelte';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three
|
||||
// Pure grouping + ordering of the lobby's game list. The lobby shows three
|
||||
// sections — games awaiting the caller's move, games awaiting the opponent, and finished
|
||||
// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on
|
||||
// top), the other two newest-first.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
|
||||
// Mock alphabet fixtures. In production the per-variant (index, letter, value)
|
||||
// table comes from the server; the mock seeds the same client cache from a local copy so
|
||||
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
|
||||
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
|
||||
|
||||
@@ -98,7 +98,7 @@ export class MockGateway implements GatewayClient {
|
||||
|
||||
constructor() {
|
||||
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
|
||||
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
|
||||
// mock-driven UI is alphabet-agnostic without a backend.
|
||||
seedMockAlphabets();
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
async complaint(): Promise<void> {}
|
||||
|
||||
// Hide a finished game from the caller's list (Stage 17): drop it from the in-memory store so a
|
||||
// Hide a finished game from the caller's list: drop it from the in-memory store so a
|
||||
// subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule.
|
||||
async hideGame(gameId: string): Promise<void> {
|
||||
const g = this.game(gameId);
|
||||
@@ -333,7 +333,7 @@ export class MockGateway implements GatewayClient {
|
||||
this.drafts.delete(gameId);
|
||||
}
|
||||
|
||||
// --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is
|
||||
// --- draft: an in-memory composition store, so the reload/off-turn flow is
|
||||
// exercised without a backend. A committed move clears the actor's own draft, as on the server.
|
||||
async draftGet(gameId: string): Promise<string> {
|
||||
return this.drafts.get(gameId) ?? '';
|
||||
@@ -470,7 +470,7 @@ export class MockGateway implements GatewayClient {
|
||||
Object.assign(this.profile, p);
|
||||
return { ...this.profile };
|
||||
}
|
||||
// --- account linking & merge (Stage 11) ---
|
||||
// --- account linking & merge ---
|
||||
async linkEmailRequest(_email: string): Promise<void> {}
|
||||
async linkEmailConfirm(email: string, _code: string): Promise<LinkResult> {
|
||||
// An address containing "merge" stands in for one already owned by another
|
||||
|
||||
@@ -42,7 +42,7 @@ export const PROFILE: Profile = {
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
||||
// is a durable account so the social surfaces (friends, stats, history) are reachable.
|
||||
// Ann is the active game's opponent but deliberately not a friend, so the in-game
|
||||
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
|
||||
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
|
||||
|
||||
+4
-4
@@ -70,7 +70,7 @@ export interface StateView {
|
||||
export interface MoveResult {
|
||||
move: MoveRecord;
|
||||
game: GameView;
|
||||
/** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */
|
||||
/** The actor's refilled rack after the move, so the mover renders the next state without a refetch. */
|
||||
rack: string[];
|
||||
bagLen: number;
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export interface Session {
|
||||
supportedLanguages: string[];
|
||||
}
|
||||
|
||||
// LinkResult is the outcome of an account link/merge step (Stage 11). status is
|
||||
// LinkResult is the outcome of an account link/merge step. status is
|
||||
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
|
||||
// another account — the secondary* fields summarise it for the irreversible
|
||||
// confirmation) or 'merged'. session is set only when the active account switched
|
||||
@@ -230,8 +230,8 @@ export interface GameList {
|
||||
* A live event delivered over the Subscribe stream. The game events carry the move as a
|
||||
* delta — move plus the post-move summary (and the bag size) — the client applies to its
|
||||
* cached game without a refetch; match_found / game_started carry the recipient's initial
|
||||
* StateView; notify carries the changed lobby payload (R4). The enriched fields are optional
|
||||
* so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer).
|
||||
* StateView; notify carries the changed lobby payload. The enriched fields are optional
|
||||
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
|
||||
*/
|
||||
export type PushEvent =
|
||||
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BOARD_SIZE, centre, premiumGrid } from './premiums';
|
||||
// Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru
|
||||
// share standardBoard (centre is a double word); erudit_ru shares the geometry but a
|
||||
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
|
||||
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
|
||||
// (backend/internal/engine AlphabetTable) — the server now owns that table.
|
||||
describe('premium layout', () => {
|
||||
it('is a 15x15 grid with TW corners', () => {
|
||||
const g = premiumGrid('scrabble_en');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
|
||||
// transmitted on the wire (StateView has no board), so the client renders the premiums
|
||||
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
|
||||
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
|
||||
// Tile values and the alphabet come from the server-sent per-variant table (see
|
||||
// lib/alphabet.ts), so this file is geometry only.
|
||||
|
||||
import type { Variant } from './model';
|
||||
@@ -85,5 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
|
||||
return { row: 7, col: 7 };
|
||||
}
|
||||
|
||||
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
|
||||
// Stage 13); the board geometry above is all this module owns.
|
||||
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts);
|
||||
// the board geometry above is all this module owns.
|
||||
|
||||
@@ -14,7 +14,7 @@ export const maxAwayMinutes = 12 * 60;
|
||||
|
||||
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
|
||||
// may be followed by a single space. No leading separator and no adjacent separators
|
||||
// except "<dot|underscore> <space>"; a single trailing "." is allowed (Stage 17). Same
|
||||
// except "<dot|underscore> <space>"; a single trailing "." is allowed. Same
|
||||
// rule as the Go displayNameRe.
|
||||
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('resultBadge', () => {
|
||||
|
||||
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
|
||||
// The opponent won by resignation (isWinner) although neither side scored — the lobby
|
||||
// must read this as a loss, matching the game-detail screen (Stage 17 regression).
|
||||
// must read this as a loss, matching the game-detail screen (regression).
|
||||
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
|
||||
key: 'result.defeat',
|
||||
emoji: '🥈',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Retry policy + error classification for the gateway transport (Stage 17). When a unary call
|
||||
// Retry policy + error classification for the gateway transport. When a unary call
|
||||
// fails at the transport level the app retries it with capped exponential backoff while showing
|
||||
// the "Connecting…" indicator, instead of flashing a red toast each time.
|
||||
//
|
||||
|
||||
@@ -192,7 +192,7 @@ export function onTelegramPath(): boolean {
|
||||
return location.pathname.startsWith('/telegram/');
|
||||
}
|
||||
|
||||
// --- Login Widget (web sign-in for account linking, Stage 11) ---
|
||||
// --- Login Widget (web sign-in for account linking) ---
|
||||
|
||||
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
||||
// attach a Telegram identity to an existing account from a browser; inside the Mini
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Game variants offered on New Game, and the Stage 15 gating of that choice by the
|
||||
// Game variants offered on New Game, and the gating of that choice by the
|
||||
// languages the sign-in service supports. Kept out of the .svelte screen so the
|
||||
// gating is unit-testable (the project's node-env Vitest layer).
|
||||
|
||||
@@ -14,8 +14,7 @@ export interface VariantOption {
|
||||
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
|
||||
// the game's alphabet, not the interface language: the English-alphabet game is always
|
||||
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
|
||||
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
|
||||
// (Stage 17).
|
||||
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
|
||||
export const ALL_VARIANTS: VariantOption[] = [
|
||||
{ id: 'scrabble_en', label: 'new.english' },
|
||||
{ id: 'scrabble_ru', label: 'new.russian' },
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
||||
}
|
||||
|
||||
// Hiding a finished game (Stage 17). The delete action sits behind each finished row and is
|
||||
// Hiding a finished game. The delete action sits behind each finished row and is
|
||||
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
||||
// per-account and irreversible. Only one row is revealed at a time.
|
||||
let revealedId = $state<string | null>(null);
|
||||
@@ -277,13 +277,13 @@
|
||||
user-select: none;
|
||||
}
|
||||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||
consecutive rows (Stage 17). */
|
||||
consecutive rows. */
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
|
||||
own opaque background hides that action until revealed (Stage 17). */
|
||||
own opaque background hides that action until revealed. */
|
||||
.rowwrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
||||
const AUTO_MATCH_HOURS = 24;
|
||||
|
||||
// The offered variants are gated by the languages the sign-in service supports
|
||||
// (Stage 15); the auto-match list and the friend-invite picker both use this.
|
||||
// The offered variants are gated by the languages the sign-in service supports;
|
||||
// the auto-match list and the friend-invite picker both use this.
|
||||
const variants = $derived(availableVariants(app.session?.supportedLanguages));
|
||||
const timeouts = [
|
||||
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
}
|
||||
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
|
||||
// up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s.
|
||||
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
|
||||
function startPoll() {
|
||||
if (poll) return;
|
||||
poll = setInterval(async () => {
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
|
||||
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
|
||||
// reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
|
||||
// reaper later substitutes a robot for a game the player abandoned.
|
||||
function cancelSearch() {
|
||||
stop();
|
||||
searching = false;
|
||||
@@ -104,7 +104,7 @@
|
||||
let selected = $state<string[]>([]);
|
||||
let friendFilter = $state('');
|
||||
// No default game type yet — the player must pick one (a smarter default from play
|
||||
// history / language is TODO-6). '' renders the disabled placeholder option.
|
||||
// history / language would be a future refinement). '' renders the disabled placeholder option.
|
||||
let inviteVariant = $state<Variant | ''>('');
|
||||
let timeoutSecs = $state(86400);
|
||||
let hints = $state(1);
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
// populate loads the editable form from the current profile. The profile screen is
|
||||
// edited inline (no edit/cancel toggle, Stage 17), so this runs on mount and after a
|
||||
// edited inline (no edit/cancel toggle), so this runs on mount and after a
|
||||
// link/merge swaps the active account.
|
||||
function populate() {
|
||||
const p = app.profile;
|
||||
@@ -215,7 +215,7 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
|
||||
<!-- Linking & merge. Shown to everyone, including guests, who
|
||||
upgrade by binding their first identity. -->
|
||||
<section class="emailbox">
|
||||
<h3>{t('profile.linkAccount')}</h3>
|
||||
@@ -246,7 +246,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
|
||||
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
|
||||
once its entry point is decided; logout() also still runs on an invalid session. -->
|
||||
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => ({
|
||||
base: './',
|
||||
define: {
|
||||
// App version shown on the About screen, injected at build time from `git describe`
|
||||
// via a Docker build-arg (Stage 17). Falls back to "dev" for a plain local/mock build,
|
||||
// via a Docker build-arg. Falls back to "dev" for a plain local/mock build,
|
||||
// so a missing build-arg never breaks the build.
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => ({
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
// Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the
|
||||
// Two entries: the game SPA (index.html, served at /app/ + /telegram/) and the
|
||||
// public landing page (landing.html, served at /). Assets are shared in dist/assets/, and
|
||||
// the relative base lets one build serve under any path.
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user