Files
scrabble-game/ui/src/lib/placement.ts
T
Ilia Denisov 92f48a3b12
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
Backend infers play direction; UI previews words and gates submit on legality
A single tile that only extended a word perpendicular to the client-declared
direction was rejected: the UI always sent dir=H for one-tile plays (the
dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing
"А" above "БАК" to form "АБАК" failed the solver's main-word-length check even
though the word is in the dictionary.

Make the backend infer a play's orientation from the placed tiles and the board
(internal/engine.resolveDirection): two or more tiles by the line they share, a
lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction
becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests
and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V"
(SubmitPlayDir) so a rebuilt game matches the one committed.

UI: stop computing/sending direction; the preview now shows the words a move
forms with its total score (game.previewWords); the make-move control is disabled
until the play is confirmed legal; the "your turn" label hides while tiles are
pending. Delete the orphaned Controls.svelte.

Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode
and the loadtest edge client to the new contract. Bake the decision into
ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
2026-06-11 22:42:33 +02:00

132 lines
4.8 KiB
TypeScript

// 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<number>();
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<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: [] };
}
/**
* 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 };
}