// 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 { Tile } 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: [] }; } /** * placementFromHint turns a hint move's tiles into a pending placement by matching each * tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the * player sees the suggested move laid out and decides whether to commit it. */ export function placementFromHint(tiles: Tile[], rack: string[]): Placement { const used = new Set(); const pending: PendingTile[] = []; const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i)); for (const t of tiles) { let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase()); if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank if (idx < 0) continue; used.add(idx); pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK }); } return { rack: [...rack], pending }; } function usedIndexes(p: Placement): Set { 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: [] }; } /** * reorderIndices returns the permutation of [0, n) that lifts the element at `from` and * drops it at slot `toSlot` among the remaining elements (clamped to a valid slot). It is * applied in parallel to the rack letters and their stable ids when a tile is dragged to a * new rack position. */ export function reorderIndices(n: number, from: number, toSlot: number): number[] { const order: number[] = []; for (let i = 0; i < n; i++) if (i !== from) order.push(i); order.splice(Math.max(0, Math.min(toSlot, order.length)), 0, from); return order; } /** toSubmit builds the submit payload: the placed tiles in board order. The backend * infers the play's orientation from the tiles and the board, so none is sent. */ export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null { if (p.pending.length === 0) return null; 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 { tiles }; }