Files
scrabble-game/ui/src/lib/mock/client.ts
T
developer 8793bd34f2
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
2026-06-17 22:17:27 +00:00

706 lines
25 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,
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
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<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(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(): Promise<void> {}
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 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,
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,
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): 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<AccountRef[]> {
return this.outgoing.map((f) => ({ ...f }));
}
async friendRequest(accountId: 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<AccountRef[]> {
return this.blocks.map((b) => ({ ...b }));
}
async block(accountId: string): Promise<void> {
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;
return emptyLinked();
}
async linkEmailMerge(_email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
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`,
};
}
// --- 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 };
}
}