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
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.
132 lines
4.8 KiB
TypeScript
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 };
|
|
}
|