4adb608ad1
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
- vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI game has no comms at all, so its 💬 history-header entry is hidden too. - Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its right edge sits under the preview caption), so the rack keeps a fixed tile size — this reverts the tile-shrink that resized the letters at 6 tiles. - Recall: dragging a placed tile back to the rack now drops it at the slot the pointer is over (drag-to-position, a gap opens), instead of snapping to its original slot; double-tap still recalls to the origin. New pure recallToSlot. Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e updated. Docs: UI_DESIGN + FUNCTIONAL (+ru).
177 lines
6.6 KiB
TypeScript
177 lines
6.6 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) };
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|