Files
scrabble-game/ui/src/lib/mock/client.ts
T
Ilia Denisov 6e77de4c1e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
feat: sparser robot nudges, typed unread badge, lobby unread bump
Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
2026-06-19 16:50:48 +02:00

712 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// In-memory mock implementation of GatewayClient. Drives the playable slice with no
// backend: it serves the seed data, applies plays/passes/exchanges/resigns to local
// state, fabricates plausible scores, and emits live events (a canned opponent reply,
// a match-found after enqueue) so the stream path is exercised too. This same fake is
// reused by the Playwright smoke. It is tree-shaken out of a production (non-mock)
// build.
import type {
GatewayClient,
PlacedTile,
Unsubscribe,
} from '../client';
import { GatewayError } from '../client';
import type {
AccountRef,
BlockList,
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
FriendCode,
GameList,
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,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, ai);
return { matched: true, game: structuredClone(ai.view) };
}
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const g: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'open',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords,
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
unreadChat: false,
unreadMessages: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
rack: draw(variant, 7),
bagLen: 86,
hintsRemaining: 1,
chat: [],
};
this.games.set(id, g);
this.openGameId = id;
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
// through the __mock hook (see lib/gateway.ts).
setTimeout(() => this.fillOpponent(id), 3000);
return { matched: false, game: structuredClone(g.view) };
}
// seatOpponent seats a robot in an open game's empty seat and returns the game (null once it is no
// longer open). Shared by the live join (which then pushes opponent_joined) and the silent e2e
// variant (which omits the push).
private seatOpponent(id: string): MockGame | null {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return null;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
return game;
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void {
const game = this.seatOpponent(id);
if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
}
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
// opponent to the most recently opened game on demand, making the waiting → joined transition
// deterministic.
joinPendingOpponent(): void {
if (this.openGameId) this.fillOpponent(this.openGameId);
}
// joinPendingOpponentSilently seats the opponent WITHOUT pushing opponent_joined — the mock of an
// event shed from a full hub buffer while the stream is alive, so the UI recovers only on a
// foreground resync. e2e hook: window.__mock.joinOpponentSilently.
joinPendingOpponentSilently(): void {
if (this.openGameId) this.seatOpponent(this.openGameId);
}
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
// "New Game" button and the notice update in place. The lobby refetches on any
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
setGameLimit(v: boolean): void {
this.gameLimit = v;
this.emit({ kind: 'notify', sub: 'game_limit' });
}
async lobbyPoll(): Promise<MatchResult> {
if (this.pendingMatch) {
const g = this.games.get(this.pendingMatch);
this.pendingMatch = null;
if (g) return { matched: true, game: structuredClone(g.view) };
}
return { matched: false };
}
async lobbyCancel(): Promise<void> {
// Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
this.pendingMatch = null;
}
// --- game ---
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
private stateOf(g: MockGame): StateView {
return {
game: structuredClone(g.view),
seat: this.mySeat(g),
rack: [...g.rack],
bagLen: g.bagLen,
// 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<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;
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 };
}
}