// 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 { EvalResult, MoveRecord, 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 }; } /** * previewFromHint turns a hint's move into a move preview. A hint is the engine's own * top-ranked, fully scored legal play, so its move already carries the score, words and * direction an evaluate would compute for the same placement; reusing it lets the client * skip a redundant evaluate round-trip after auto-placing the hint. The words are * lower-cased to match the evaluate result the caption renders: a hint's MoveRecord words * are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the * backend's lower case, so reusing them verbatim would otherwise change the caption's case. */ export function previewFromHint(move: MoveRecord): EvalResult { return { legal: true, score: move.score, words: move.words.map((w) => w.toLowerCase()), dir: move.dir }; } 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) }; } /** * recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the * given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the * player chose) instead of snapping the tile back to its original slot. It returns the new * placement together with the index permutation applied to the rack, so the caller can permute * its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the * recall (those still on the board are skipped); it is clamped to a valid position. The result * is the unchanged placement and an identity permutation when no pending tile sits at (row, col). */ export function recallToSlot( p: Placement, row: number, col: number, toSlot: number, ): { placement: Placement; order: number[] } { const identity = p.rack.map((_, i) => i); const tile = p.pending.find((t) => t.row === row && t.col === col); if (!tile) return { placement: p, order: identity }; const ri = tile.rackIndex; const rest = p.pending.filter((t) => t !== tile); const usedByRest = new Set(rest.map((t) => t.rackIndex)); // Reinsert ri just before the toSlot-th slot that stays visible after the recall. const others = identity.filter((i) => i !== ri); let visible = 0; let insertPos = others.length; for (let pos = 0; pos < others.length; pos++) { if (usedByRest.has(others[pos])) continue; if (visible === toSlot) { insertPos = pos; break; } visible++; } const order = [...others]; order.splice(insertPos, 0, ri); const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx])); return { placement: { rack: order.map((i) => p.rack[i]), pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })), }, order, }; } 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 }; }