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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Seed data for the mock transport. Enough to exercise the playable slice locally
|
||||
// (pnpm start) with no backend: a profile, one active mid-game whose board already
|
||||
// has tiles, and two finished games. Coordinates are 0-indexed (centre 7,7). Words do
|
||||
// not need to be strictly legal here — this is a visual/interaction fixture; real
|
||||
// legality and scoring come from the backend.
|
||||
|
||||
import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model';
|
||||
|
||||
export const ME = 'me';
|
||||
|
||||
export const SESSION: Session = {
|
||||
token: 'mock-token',
|
||||
userId: ME,
|
||||
isGuest: true,
|
||||
displayName: 'You',
|
||||
};
|
||||
|
||||
export const PROFILE: Profile = {
|
||||
userId: ME,
|
||||
displayName: 'You',
|
||||
preferredLanguage: 'en',
|
||||
timeZone: 'UTC',
|
||||
hintBalance: 3,
|
||||
blockChat: false,
|
||||
blockFriendRequests: false,
|
||||
isGuest: true,
|
||||
};
|
||||
|
||||
function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat {
|
||||
return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner };
|
||||
}
|
||||
|
||||
function play(
|
||||
player: number,
|
||||
dir: 'H' | 'V',
|
||||
tiles: Array<[number, number, string]>,
|
||||
words: string[],
|
||||
score: number,
|
||||
total: number,
|
||||
): MoveRecord {
|
||||
const ts = tiles.map(([row, col, letter]) => ({ row, col, letter, blank: false }));
|
||||
return {
|
||||
player,
|
||||
action: 'play',
|
||||
dir,
|
||||
mainRow: ts[0]?.row ?? 7,
|
||||
mainCol: ts[0]?.col ?? 7,
|
||||
tiles: ts,
|
||||
words,
|
||||
count: words.length,
|
||||
score,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockGame {
|
||||
view: GameView;
|
||||
moves: MoveRecord[];
|
||||
rack: string[];
|
||||
bagLen: number;
|
||||
hintsRemaining: number;
|
||||
chat: ChatMessage[];
|
||||
}
|
||||
|
||||
// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn ---
|
||||
|
||||
const G1_MOVES: MoveRecord[] = [
|
||||
play(0, 'H', [
|
||||
[7, 5, 'H'],
|
||||
[7, 6, 'E'],
|
||||
[7, 7, 'L'],
|
||||
[7, 8, 'L'],
|
||||
[7, 9, 'O'],
|
||||
], ['HELLO'], 16, 16),
|
||||
play(1, 'V', [
|
||||
[6, 9, 'W'],
|
||||
[8, 9, 'R'],
|
||||
[9, 9, 'L'],
|
||||
[10, 9, 'D'],
|
||||
], ['WORLD'], 9, 9),
|
||||
play(0, 'H', [
|
||||
[8, 10, 'A'],
|
||||
[8, 11, 'T'],
|
||||
], ['RAT'], 3, 19),
|
||||
play(1, 'V', [
|
||||
[9, 10, 'N'],
|
||||
[10, 10, 'D'],
|
||||
], ['AND'], 4, 13),
|
||||
];
|
||||
|
||||
function activeGame(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g1',
|
||||
variant: 'english',
|
||||
dictVersion: 'v1',
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: G1_MOVES.length,
|
||||
endReason: '',
|
||||
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
||||
},
|
||||
moves: G1_MOVES,
|
||||
rack: ['R', 'E', 'T', 'I', 'N', 'A', '?'],
|
||||
bagLen: 58,
|
||||
hintsRemaining: 1,
|
||||
chat: [
|
||||
{
|
||||
id: 'c1',
|
||||
gameId: 'g1',
|
||||
senderId: 'ann',
|
||||
kind: 'message',
|
||||
body: 'good luck!',
|
||||
createdAtUnix: Math.floor(Date.now() / 1000) - 3600,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- finished games ---
|
||||
|
||||
function finishedG2(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g2',
|
||||
variant: 'english',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 2,
|
||||
endReason: 'normal',
|
||||
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
||||
},
|
||||
moves: [
|
||||
play(0, 'H', [
|
||||
[7, 6, 'Q'],
|
||||
[7, 7, 'U'],
|
||||
[7, 8, 'I'],
|
||||
[7, 9, 'Z'],
|
||||
], ['QUIZ'], 48, 48),
|
||||
play(1, 'V', [
|
||||
[6, 9, 'J'],
|
||||
[8, 9, 'A'],
|
||||
[9, 9, 'M'],
|
||||
], ['JAZM'], 30, 30),
|
||||
],
|
||||
rack: [],
|
||||
bagLen: 0,
|
||||
hintsRemaining: 0,
|
||||
chat: [],
|
||||
};
|
||||
}
|
||||
|
||||
function finishedG3(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g3',
|
||||
variant: 'russian',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 1,
|
||||
endReason: 'resignation',
|
||||
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
||||
},
|
||||
moves: [
|
||||
play(0, 'H', [
|
||||
[7, 6, 'С'],
|
||||
[7, 7, 'Л'],
|
||||
[7, 8, 'О'],
|
||||
[7, 9, 'В'],
|
||||
[7, 10, 'О'],
|
||||
], ['СЛОВО'], 12, 12),
|
||||
],
|
||||
rack: [],
|
||||
bagLen: 0,
|
||||
hintsRemaining: 0,
|
||||
chat: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function seedGames(): Map<string, MockGame> {
|
||||
const m = new Map<string, MockGame>();
|
||||
for (const g of [activeGame(), finishedG2(), finishedG3()]) m.set(g.view.id, g);
|
||||
return m;
|
||||
}
|
||||
Reference in New Issue
Block a user