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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user