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:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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
+6 -6
View File
@@ -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;
+8 -8
View File
@@ -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>;
+4 -4
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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 });
});
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 }
+1 -1
View File
@@ -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');
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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.
//
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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' },