// 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, GcgExport, History, HintResult, Invitation, InvitationSettings, LinkResult, MatchResult, MoveResult, OutgoingList, Profile, ProfileUpdate, PushEvent, Session, StateView, Stats, Variant, WordCheckResult, } 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 = { 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(); 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(); } 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 { // 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 { return { ...SESSION, isGuest: false }; } async authGuest(): Promise { return { ...SESSION }; } async authEmailRequest(): Promise {} async authEmailLogin(): Promise { return { ...SESSION, isGuest: false }; } // --- profile / lists --- async profileGet(): Promise { return { ...this.profile }; } async blockStatus(): Promise { // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. return { blocked: false, permanent: false, until: '', reason: '' }; } async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise { // No local dictionary in mock mode; the caller falls back to the mock evaluate. throw new Error('fetchDict unsupported in mock'); } async reportLocalEval(_counts: Record): Promise { // Telemetry is a no-op in mock mode. } async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)), atGameLimit: this.gameLimit, }; } // --- lobby --- async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise { 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 90–180 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 { 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 { // 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, // g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance. // hints_remaining folds the two together (as the backend does), walletBalance is the wallet. hintsRemaining: g.hintsRemaining + this.profile.hintBalance, walletBalance: this.profile.hintBalance, }; } async gameState(gameId: string, _includeAlphabet: boolean): Promise { return this.stateOf(this.game(gameId)); } async gameHistory(gameId: string): Promise { const g = this.game(gameId); return { gameId, moves: structuredClone(g.moves) }; } async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { 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 { 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 { return this.simpleAction(gameId, 'pass'); } exchange(gameId: string, tiles: string[], _variant: Variant): Promise { return this.simpleAction(gameId, 'exchange', tiles); } resign(gameId: string): Promise { return this.simpleAction(gameId, 'resign'); } async hint(gameId: string): Promise { 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 + this.profile.hintBalance, walletBalance: this.profile.hintBalance, }; } async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise { 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 { return { word, legal: word.trim().length >= 2 }; } async complaint(): Promise {} // 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 { 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 { return this.drafts.get(gameId) ?? ''; } async draftSave(gameId: string, json: string): Promise { this.drafts.set(gameId, json); } // --- chat --- async chatPost(gameId: string, body: string): Promise { 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 { return [...this.game(gameId).chat]; } async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { this.feedbackPending = true; this.feedbackReply = null; // a new message: the previous operator reply no longer shows } async feedbackGet(): Promise { this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply return { canSend: !this.feedbackPending, blockedReason: this.feedbackPending ? 'pending' : '', reply: this.feedbackReply, }; } async feedbackUnread(): Promise { 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 {} async nudge(gameId: string): Promise { 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 { return this.friends.map((f) => ({ ...f })); } async friendsIncoming(): Promise { return this.incoming.map((f) => ({ ...f })); } async friendsOutgoing(): Promise { // 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 { // 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 { 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 {} async unfriend(accountId: string): Promise { this.friends = this.friends.filter((f) => f.accountId !== accountId); } async friendCodeIssue(): Promise { return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 }; } async friendCodeRedeem(code: string): Promise { const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` }; this.friends.push(friend); return { ...friend }; } // --- blocks --- async blocksList(): Promise { // 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 { // 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 { this.blocks = this.blocks.filter((b) => b.accountId !== accountId); } // --- invitations --- async invitationsList(): Promise { return this.invitations.map((i) => structuredClone(i)); } async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise { 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 { return this.respondInvitation(invitationId, 'started'); } async invitationDecline(invitationId: string): Promise { return this.respondInvitation(invitationId, 'declined'); } async invitationCancel(invitationId: string): Promise { this.invitations = this.invitations.filter((i) => i.id !== invitationId); } // --- profile / stats / history --- async profileUpdate(p: ProfileUpdate): Promise { Object.assign(this.profile, p); return { ...this.profile }; } // --- account linking & merge --- async linkEmailRequest(_email: string): Promise {} async linkEmailConfirm(email: string, _code: string): Promise { // 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; return emptyLinked(); } async linkEmailMerge(_email: string, _code: string): Promise { this.profile.isGuest = false; return { ...emptyLinked(), status: 'merged' }; } async linkTelegram(_data: string): Promise { this.profile.isGuest = false; return emptyLinked(); } async linkTelegramMerge(_data: string): Promise { this.profile.isGuest = false; return { ...emptyLinked(), status: 'merged' }; } async statsGet(): Promise { return { ...this.stats }; } async exportGcg(gameId: string): Promise { 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`, }; } // --- 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 }; } }