Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke

- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session
- pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru
- in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend
- board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat
- Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 00:32:50 +02:00
parent 19ae8f04a2
commit 453ddc5e94
48 changed files with 5696 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
// 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 {
ChatMessage,
EvalResult,
GameList,
History,
HintResult,
MatchResult,
MoveResult,
Profile,
PushEvent,
Session,
StateView,
Variant,
WordCheckResult,
} from '../model';
import { tileValue } from '../premiums';
import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data';
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
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;
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 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 gamesList(): Promise<GameList> {
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
}
// --- lobby ---
async lobbyEnqueue(variant: Variant): Promise<MatchResult> {
// 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<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 };
}
// --- game ---
async gameState(gameId: string): Promise<StateView> {
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<History> {
const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) };
}
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): 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 + tileValue(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.scheduleOpponentReply(gameId);
return { move: structuredClone(move), game: structuredClone(g.view) };
}
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;
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<MoveResult> {
return this.simpleAction(gameId, 'pass');
}
exchange(gameId: string, tiles: string[]): 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) 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: tileValue(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
};
}
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult> {
const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
let score = tiles.reduce((s, t) => s + tileValue(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): Promise<WordCheckResult> {
return { word, legal: word.trim().length >= 2 };
}
async complaint(): Promise<void> {}
// --- 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 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;
}
// --- 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 };
}
}