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
+116
View File
@@ -0,0 +1,116 @@
// Pure placement state machine for composing a play. The UI lifts tiles from the
// rack onto board cells (via drag or tap); this tracks the pending tiles, infers the
// play direction, supports per-tile recall and a full reset, and builds the submit
// payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable.
import type { Direction } from './model';
import type { PlacedTile } from './client';
export interface PendingTile {
/** Index of the rack slot this tile was lifted from. */
rackIndex: number;
row: number;
col: number;
/** Designated concrete letter (for a blank, the letter the player chose). */
letter: string;
/** Whether this tile came from a blank rack slot ("?"). */
blank: boolean;
}
export interface Placement {
/** The player's rack as dealt, e.g. ['A','Q','?','N','I','W','E']. */
rack: string[];
pending: PendingTile[];
}
export interface RackSlot {
index: number;
letter: string;
used: boolean;
}
export const BLANK = '?';
export function newPlacement(rack: string[]): Placement {
return { rack: [...rack], pending: [] };
}
function usedIndexes(p: Placement): Set<number> {
return new Set(p.pending.map((t) => t.rackIndex));
}
/** rackView lists each rack slot with whether it is currently placed on the board. */
export function rackView(p: Placement): RackSlot[] {
const used = usedIndexes(p);
return p.rack.map((letter, index) => ({ index, letter, used: used.has(index) }));
}
export function isBlankSlot(p: Placement, rackIndex: number): boolean {
return p.rack[rackIndex] === BLANK;
}
export function cellOccupied(p: Placement, row: number, col: number): boolean {
return p.pending.some((t) => t.row === row && t.col === col);
}
/**
* place lifts a rack slot onto (row, col). For a blank slot the caller must pass the
* designated letter. Returns the unchanged placement if the move is invalid (slot out
* of range, already used, target occupied, or a blank with no letter).
*/
export function place(
p: Placement,
rackIndex: number,
row: number,
col: number,
blankLetter?: string,
): Placement {
if (rackIndex < 0 || rackIndex >= p.rack.length) return p;
if (usedIndexes(p).has(rackIndex)) return p;
if (cellOccupied(p, row, col)) return p;
const blank = p.rack[rackIndex] === BLANK;
const letter = blank ? (blankLetter ?? '').toUpperCase() : p.rack[rackIndex];
if (blank && !letter) return p;
return { ...p, pending: [...p.pending, { rackIndex, row, col, letter, blank }] };
}
export function recallAt(p: Placement, row: number, col: number): Placement {
return { ...p, pending: p.pending.filter((t) => !(t.row === row && t.col === col)) };
}
export function recallIndex(p: Placement, rackIndex: number): Placement {
return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
}
export function reset(p: Placement): Placement {
return { ...p, pending: [] };
}
/**
* direction infers the play orientation from the pending tiles: H if they share a row,
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
*/
export function direction(p: Placement): Direction | null {
if (p.pending.length < 2) return null;
const rows = new Set(p.pending.map((t) => t.row));
const cols = new Set(p.pending.map((t) => t.col));
if (rows.size === 1 && cols.size === p.pending.length) return 'H';
if (cols.size === 1 && rows.size === p.pending.length) return 'V';
return null;
}
/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where
* the orientation cannot be inferred; otherwise the inferred direction is used. */
export function toSubmit(
p: Placement,
dirOverride?: Direction,
): { dir: Direction; tiles: PlacedTile[] } | null {
if (p.pending.length === 0) return null;
const dir = dirOverride ?? direction(p) ?? 'H';
const tiles: PlacedTile[] = p.pending
.slice()
.sort((a, b) => a.row - b.row || a.col - b.col)
.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
return { dir, tiles };
}