// 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, ChatMessage, EvalResult, FriendCode, GameList, GcgExport, History, HintResult, Invitation, InvitationSettings, LinkResult, MatchResult, MoveResult, 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 = { english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', }; function draw(variant: Variant, n: number): string[] { const pool = POOL[variant]; const out: string[] = []; for (let i = 0; i < n; i++) out.push(pool[Math.floor(Math.random() * pool.length)]); return out; } function removeFromRack(rack: string[], tiles: PlacedTile[]): string[] { const next = [...rack]; for (const t of tiles) { const want = t.blank ? '?' : t.letter.toUpperCase(); const i = next.indexOf(want); if (i >= 0) next.splice(i, 1); } return next; } export class MockGateway implements GatewayClient { private readonly games = seedGames(); private readonly profile: Profile = { ...PROFILE }; private readonly subs = new Set<(e: PushEvent) => void>(); private pendingMatch: string | null = null; private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); 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 (Stage 13). 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(): 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 gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)) }; } // --- lobby --- async lobbyEnqueue(variant: Variant): Promise { // Simulate a 10s-style robot substitution, sped up: match found shortly. const id = crypto.randomUUID(); const g: MockGame = { view: { id, variant, dictVersion: 'v1', status: 'active', players: 2, toMove: 0, turnTimeoutSecs: 86400, moveCount: 0, endReason: '', seats: [ { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, ], }, moves: [], rack: draw(variant, 7), bagLen: 86, hintsRemaining: 1, chat: [], }; this.games.set(id, g); this.pendingMatch = id; setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400); return { matched: false }; } async lobbyPoll(): Promise { 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 --- async gameState(gameId: string, _includeAlphabet: boolean): Promise { const g = this.game(gameId); return { game: structuredClone(g.view), seat: this.mySeat(g), rack: [...g.rack], bagLen: g.bagLen, hintsRemaining: g.hintsRemaining, }; } async gameHistory(gameId: string): Promise { const g = this.game(gameId); return { gameId, moves: structuredClone(g.moves) }; } async submitPlay(gameId: string, dir: 'H' | 'V', 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 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) }; } 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) }; } 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) throw new GatewayError('hint_unavailable'); g.hintsRemaining -= 1; const letter = g.rack.find((l) => l !== '?') ?? 'A'; return { move: { player: this.mySeat(g), action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [{ row: 7, col: 7, letter, blank: false }], words: [letter], count: 1, score: valueForLetter(g.view.variant, letter), total: 0, }, hintsRemaining: g.hintsRemaining, }; } async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise { const g = this.game(gameId); if (tiles.length === 0) return { legal: false, score: 0, words: [] }; let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); if (tiles.length === 7) score += 50; return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] }; } async checkWord(_gameId: string, word: string, _variant: Variant): Promise { return { word, legal: word.trim().length >= 2 }; } async complaint(): Promise {} // --- draft (Stage 17): 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 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 friendRequest(_accountId: string): Promise { // The real backend requires a shared game; the mock simply acknowledges. } 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 { return this.blocks.map((b) => ({ ...b })); } async block(accountId: string): Promise { 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, 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 (Stage 11) --- 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, seat: opp, action: 'play', score: 6, total: move.total }); this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 }); }, 1600); } private firstEmptyPair(g: MockGame): { row: number; col: number } { const occupied = new Set(g.moves.flatMap((m) => m.tiles.map((t) => `${t.row},${t.col}`))); for (let row = 11; row < 15; row++) { for (let col = 0; col < 14; col++) { if (!occupied.has(`${row},${col}`) && !occupied.has(`${row},${col + 1}`)) return { row, col }; } } return { row: 0, col: 0 }; } }