Files
scrabble-game/ui/src/lib/mock/client.ts
T
Ilia Denisov 82648a4398
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
fix(game): show granted/bought hints in-game — finish the D31 wire removal
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00

870 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
AccountRef,
BlockList,
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
FriendCode,
GameList,
ExportUrl,
GcgExport,
History,
HintResult,
Invitation,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
OutgoingList,
Profile,
ProfileUpdate,
PushEvent,
Session,
StateView,
Stats,
Variant,
WordCheckResult,
Wallet,
WalletOrder,
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
import { seedMockAlphabets } from './alphabet';
import {
ME,
MOCK_FRIENDS,
MOCK_INCOMING,
MOCK_STATS,
PROFILE,
SESSION,
mockInvitations,
seedGames,
type MockGame,
} from './data';
// emptyLinked is a "linked" LinkResult with no secondary summary or session switch.
function emptyLinked(): LinkResult {
return {
status: 'linked',
secondaryUserId: '',
secondaryDisplayName: '',
secondaryGames: 0,
secondaryFriends: 0,
session: null,
};
}
const POOL: Record<Variant, string> = {
scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
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;
// Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
// clears it and raises the badge.
private feedbackPending = false;
private feedbackReply: FeedbackReply | null = null;
private feedbackReplyUnread = false;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
// gameLimit forces the lobby's at_game_limit flag for the e2e (window.__mock.setGameLimit).
private gameLimit = false;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = [];
private blocks: AccountRef[] = [];
private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS };
private readonly drafts = new Map<string, string>();
// The mock chip wallet: a web/native (direct) context holding a direct and a vk segment, so the
// storefront, the priority draw (direct→vk→tg) and the web-spend warning (a value whose price
// reaches into the vk segment) are all exercisable without a backend.
private readonly mockWallet: Wallet = {
segments: [
{ source: 'direct', chips: 120, spendable: true },
{ source: 'vk', chips: 400, spendable: true },
],
adsForever: false,
adsPaidUntilMs: 0,
hints: 5,
rewardChips: 5,
};
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
private readonly mockCatalog: Catalog = {
products: [
{ kind: 'value', productId: 'val-hints-50', title: '50 подсказок', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
{ kind: 'value', productId: 'val-noads-30', title: 'Без рекламы: 30 дней', chips: 300, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'noads_days', quantity: 30 }] },
{ kind: 'pack', productId: 'pack-chips-100', title: '100 фишек', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [{ atomType: 'chips', quantity: 100 }] },
],
};
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.
seedMockAlphabets();
// e2e seam: `?guest` seeds a guest profile so the durable-only surfaces (Friends, Wallet) can
// be asserted hidden. The mock profile is otherwise a durable account.
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('guest')) {
this.profile.isGuest = true;
}
}
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 authTelegram(initData: string): Promise<Session> {
// e2e hook: an initData carrying this sentinel simulates a backend that rejects the launch,
// so the Mini App boot-failure path (silent retries → boot-error screen) can be exercised.
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
return { ...SESSION, isGuest: false };
}
async authVK(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
}
async authEmailLogin(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
// --- profile / lists ---
async profileGet(): Promise<Profile> {
return { ...this.profile };
}
async blockStatus(): Promise<BlockStatus> {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' };
}
// --- wallet ---
async wallet(): Promise<Wallet> {
return this.cloneWallet();
}
async catalog(): Promise<Catalog> {
return { products: this.mockCatalog.products.map((p) => ({ ...p, atoms: p.atoms.map((a) => ({ ...a })) })) };
}
async walletBuy(productId: string): Promise<Wallet> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'value') throw new GatewayError('product_not_found');
// Draw the chip price across the segments by priority direct→vk→tg, mirroring the backend.
let remaining = p.chips;
for (const src of ['direct', 'vk', 'telegram'] as const) {
if (remaining <= 0) break;
const seg = this.mockWallet.segments.find((s) => s.source === src);
if (!seg) continue;
const take = Math.min(seg.chips, remaining);
seg.chips -= take;
remaining -= take;
}
if (remaining > 0) throw new GatewayError('insufficient_chips');
for (const a of p.atoms) {
if (a.atomType === 'hints') this.mockWallet.hints += a.quantity;
if (a.atomType === 'noads_days') {
this.mockWallet.adsPaidUntilMs = Math.max(Date.now(), this.mockWallet.adsPaidUntilMs) + a.quantity * 86_400_000;
}
}
return this.cloneWallet();
}
async walletOrder(productId: string): Promise<WalletOrder> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
}
async walletReward(_nonce: string): Promise<Wallet> {
// Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
if (vk) vk.chips += this.mockWallet.rewardChips;
return this.cloneWallet();
}
private cloneWallet(): Wallet {
return {
segments: this.mockWallet.segments.map((s) => ({ ...s })),
adsForever: this.mockWallet.adsForever,
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
hints: this.mockWallet.hints,
rewardChips: this.mockWallet.rewardChips,
};
}
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
// vs_ai game can generate real moves. Version is ignored (a single served file per variant).
const res = await fetch(`/e2edict/${variant}.dawg`, { signal: opts?.signal });
if (!res.ok) throw new Error(`fetchDict ${variant}: HTTP ${res.status}`);
return res.arrayBuffer();
}
async reportLocalEval(_counts: Record<string, number>): Promise<void> {
// Telemetry is a no-op in mock mode.
}
async gamesList(): Promise<GameList> {
return {
games: [...this.games.values()].map((g) => structuredClone(g.view)),
atGameLimit: this.gameLimit,
};
}
// --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult> {
const id = crypto.randomUUID();
if (vsAi) {
// An honest-AI game starts active, already seated with a robot (rendered as 🤖 from the
// vs_ai flag); there is no open/wait phase.
const ai: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
players: 2,
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
unreadChat: false,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, ai);
return { matched: true, game: structuredClone(ai.view) };
}
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const g: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'open',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
unreadChat: false,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, g);
this.openGameId = id;
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
// through the __mock hook (see lib/gateway.ts).
setTimeout(() => this.fillOpponent(id), 3000);
return { matched: false, game: structuredClone(g.view) };
}
// seatOpponent seats a robot in an open game's empty seat and returns the game (null once it is no
// longer open). Shared by the live join (which then pushes opponent_joined) and the silent e2e
// variant (which omits the push).
private seatOpponent(id: string): MockGame | null {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return null;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
return game;
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void {
const game = this.seatOpponent(id);
if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
}
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
// opponent to the most recently opened game on demand, making the waiting → joined transition
// deterministic.
joinPendingOpponent(): void {
if (this.openGameId) this.fillOpponent(this.openGameId);
}
// joinPendingOpponentSilently seats the opponent WITHOUT pushing opponent_joined — the mock of an
// event shed from a full hub buffer while the stream is alive, so the UI recovers only on a
// foreground resync. e2e hook: window.__mock.joinOpponentSilently.
joinPendingOpponentSilently(): void {
if (this.openGameId) this.seatOpponent(this.openGameId);
}
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
// "New Game" button and the notice update in place. The lobby refetches on any
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
setGameLimit(v: boolean): void {
this.gameLimit = v;
this.emit({ kind: 'notify', sub: 'game_limit' });
}
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 };
}
async lobbyCancel(): Promise<void> {
// Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
this.pendingMatch = null;
}
// --- game ---
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
private stateOf(g: MockGame): StateView {
return {
game: structuredClone(g.view),
seat: this.mySeat(g),
rack: [...g.rack],
bagLen: g.bagLen,
// hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the
// client adds it (lib/hints).
hintsRemaining: g.hintsRemaining,
};
}
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
return this.stateOf(this.game(gameId));
}
async gameHistory(gameId: string): Promise<History> {
const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) };
}
async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): 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 + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
const total = g.view.seats[seat].score + score;
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
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.drafts.delete(gameId);
this.scheduleOpponentReply(gameId);
return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
}
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;
this.drafts.delete(gameId);
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), rack: structuredClone(g.rack), bagLen: g.bagLen };
}
pass(gameId: string): Promise<MoveResult> {
return this.simpleAction(gameId, 'pass');
}
exchange(gameId: string, tiles: string[], _variant: Variant): 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 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
// Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a
// wallet hint in one game lowers the count shown in every other game (the bug this fixes).
if (g.hintsRemaining > 0) g.hintsRemaining -= 1;
else this.profile.hintBalance -= 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: valueForLetter(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
walletBalance: this.profile.hintBalance,
};
}
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise<EvalResult> {
const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir };
}
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
return { word, legal: word.trim().length >= 2 };
}
async complaint(): Promise<void> {}
// 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);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
this.games.delete(gameId);
this.drafts.delete(gameId);
}
// --- 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) ?? '';
}
async draftSave(gameId: string, json: string): Promise<void> {
this.drafts.set(gameId, json);
}
// --- 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 feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise<void> {
this.feedbackPending = true;
this.feedbackReply = null; // a new message: the previous operator reply no longer shows
}
async feedbackGet(): Promise<FeedbackState> {
this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply
return {
canSend: !this.feedbackPending,
blockedReason: this.feedbackPending ? 'pending' : '',
reply: this.feedbackReply,
};
}
async feedbackUnread(): Promise<boolean> {
return this.feedbackReplyUnread;
}
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
// reply and raises the badge via a live admin_reply notification. e2e hook:
// window.__mock.adminReply.
mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
this.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true;
this.emit({ kind: 'notify', sub: 'admin_reply' });
}
// The mock holds no server-side unread state; the read ack is a no-op (the client clears its
// own unread flag optimistically).
async markChatRead(): Promise<void> {}
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;
}
// --- friends ---
private nameFor(id: string): string {
return this.friends.find((f) => f.accountId === id)?.displayName ?? id;
}
async friendsList(): Promise<AccountRef[]> {
return this.friends.map((f) => ({ ...f }));
}
async friendsIncoming(): Promise<AccountRef[]> {
return this.incoming.map((f) => ({ ...f }));
}
async friendsOutgoing(): Promise<OutgoingList> {
// The mock models human outgoing requests only (no disguised robots); robots stays empty.
return { requests: this.outgoing.map((f) => ({ ...f })), robots: [] };
}
async friendRequest(accountId: string, _gameId?: string): Promise<void> {
// The real backend requires a shared game; the mock records the outgoing request so
// the game's "add to friends" item reads as sent across reloads.
if (!this.outgoing.some((o) => o.accountId === accountId)) {
this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
}
}
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
if (i < 0) throw new GatewayError('request_not_found');
const [r] = this.incoming.splice(i, 1);
if (accept) this.friends.push(r);
this.emit({ kind: 'notify', sub: 'friend_request' });
}
async friendCancel(_accountId: string): Promise<void> {}
async unfriend(accountId: string): Promise<void> {
this.friends = this.friends.filter((f) => f.accountId !== accountId);
}
async friendCodeIssue(): Promise<FriendCode> {
return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 };
}
async friendCodeRedeem(code: string): Promise<AccountRef> {
const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` };
this.friends.push(friend);
return { ...friend };
}
// --- blocks ---
async blocksList(): Promise<BlockList> {
// The mock models human blocks only (no disguised robots); robots stays empty.
return { blocked: this.blocks.map((b) => ({ ...b })), robots: [] };
}
async block(accountId: string, _gameId?: string): Promise<void> {
// A block hides the blocked person from the blocker's own friends list (the real
// ListFriends filters them out), without deleting the underlying friendship.
this.friends = this.friends.filter((f) => f.accountId !== accountId);
if (!this.blocks.some((b) => b.accountId === accountId)) {
this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
}
}
async unblock(accountId: string): Promise<void> {
this.blocks = this.blocks.filter((b) => b.accountId !== accountId);
}
// --- invitations ---
async invitationsList(): Promise<Invitation[]> {
return this.invitations.map((i) => structuredClone(i));
}
async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation> {
const inv: Invitation = {
id: crypto.randomUUID(),
inviter: { accountId: ME, displayName: 'You' },
invitees: inviteeIds.map((id, k) => ({ accountId: id, displayName: this.nameFor(id), seat: k + 1, response: 'pending' })),
variant: settings.variant,
turnTimeoutSecs: settings.turnTimeoutSecs,
hintsAllowed: settings.hintsAllowed,
hintsPerPlayer: settings.hintsPerPlayer,
multipleWordsPerTurn: settings.multipleWordsPerTurn,
dropoutTiles: settings.dropoutTiles,
status: 'pending',
gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
};
this.invitations.push(inv);
return structuredClone(inv);
}
private respondInvitation(invitationId: string, status: string): Invitation {
const inv = this.invitations.find((i) => i.id === invitationId);
if (!inv) throw new GatewayError('invitation_not_found');
inv.status = status;
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
return structuredClone(inv);
}
async invitationAccept(invitationId: string): Promise<Invitation> {
return this.respondInvitation(invitationId, 'started');
}
async invitationDecline(invitationId: string): Promise<Invitation> {
return this.respondInvitation(invitationId, 'declined');
}
async invitationCancel(invitationId: string): Promise<void> {
this.invitations = this.invitations.filter((i) => i.id !== invitationId);
}
// --- profile / stats / history ---
async profileUpdate(p: ProfileUpdate): Promise<Profile> {
Object.assign(this.profile, p);
return { ...this.profile };
}
// --- 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
// account, so the mock can drive the irreversible-merge confirmation.
if (email.includes('merge')) {
return {
status: 'merge_required',
secondaryUserId: 'mock-secondary',
secondaryDisplayName: 'Ann',
secondaryGames: 7,
secondaryFriends: 3,
session: null,
};
}
this.profile.isGuest = false;
this.profile.email = email;
return emptyLinked();
}
// e2e hooks (window.__mock, see lib/gateway.ts): mockClearEmail drops the seeded email so the
// sign-in section offers the add-email flow; mockConfirmEmailOutOfBand attaches the address
// WITHOUT a live event — standing in for the one-tap confirm link opened in another browser,
// so the open code form must reflect it by polling the profile, not by a push.
mockClearEmail(): void {
this.profile.email = '';
this.emit({ kind: 'notify', sub: 'profile' });
}
mockConfirmEmailOutOfBand(email: string): void {
this.profile.email = email;
}
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.email = email;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.telegramLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return emptyLinked();
}
async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkUnlink(kind: string): Promise<LinkResult> {
if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false;
return { ...emptyLinked(), status: 'unlinked' };
}
async changeEmailRequest(_email: string): Promise<void> {}
async changeEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// An address containing "taken" stands in for one confirmed by another account, so the
// mock can drive the non-disclosing refusal (never a merge).
if (email.includes('taken')) throw new GatewayError('email_taken');
this.profile.email = email;
return { ...emptyLinked(), status: 'changed' };
}
async deleteRequest(): Promise<{ method: 'email' | 'phrase' }> {
return { method: this.profile.email ? 'email' : 'phrase' };
}
async deleteConfirm(_code: string, _phrase: string): Promise<void> {}
async statsGet(): Promise<Stats> {
return { ...this.stats };
}
async exportGcg(gameId: string): Promise<GcgExport> {
const g = this.game(gameId);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
return {
gameId,
filename: `game-${gameId}.gcg`,
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
};
}
async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
const g = this.game(gameId);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
// A shape-faithful signed path; the e2e intercepts the route and serves bytes.
return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
}
// --- 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, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen });
this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, opponentName: g.view.seats[opp].displayName, moveCount: g.view.moveCount });
}, 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 };
}
}