Files
scrabble-game/ui/src/lib/placement.ts
T
Ilia Denisov d0681c5efe
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
fix(ui): lower-case hint preview words to match evaluate
A hint's MoveRecord words are upper-cased on decode (for the move-history view),
whereas an EvalResult keeps the backend's lower case. Seeding the move preview
from the hint verbatim flipped the score caption to upper case and nudged its
height. Lower-case the words in previewFromHint so the caption reads the same
as the evaluate path it replaces.
2026-06-19 12:12:53 +02:00

190 lines
7.4 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 { 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<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 };
}
/**
* 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<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) };
}
/**
* 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 };
}