Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)

- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 13:33:03 +02:00
parent 38be7fea96
commit 2c96c19aac
12 changed files with 621 additions and 287 deletions
+20 -1
View File
@@ -4,7 +4,7 @@
// payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable.
import type { Direction } from './model';
import type { Direction, Tile } from './model';
import type { PlacedTile } from './client';
export interface PendingTile {
@@ -36,6 +36,25 @@ 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));
}