2c465c01d2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
771 lines
28 KiB
TypeScript
771 lines
28 KiB
TypeScript
// 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,
|
||
} 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>();
|
||
|
||
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<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): 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: '' };
|
||
}
|
||
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||
// 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<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 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<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,
|
||
// 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<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 + this.profile.hintBalance,
|
||
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();
|
||
}
|
||
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 };
|
||
}
|
||
}
|