Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s

Complete the client-side draft feature on top of the shipped backend
foundation (the game_drafts store/service):

- FB: DraftRequest{game_id,json} + DraftView{json} (a draft get reuses
  GameActionRequest); regenerated committed Go + TS bindings.
- Backend REST: GET/PUT /games/:id/draft, a draftDTO
  (rack_order/board_tiles) mapped to game.Draft.
- Gateway: draft.get/draft.save transcode forwarding the composition
  JSON verbatim (json.RawMessage both ways -- no double-encode).
- UI: debounced save of the rack order + board tiles and restore on
  load (lib/draft.ts), plus #5 -- tiles may be arranged on the
  opponent's turn (placement relaxed; the preview and Make-move stay
  your-turn-only, so an off-turn draft is position-only).

Tests: backend handler validation, gateway pass-through round-trip, UI
draft/codec units, and a draft-restore e2e.
This commit is contained in:
Ilia Denisov
2026-06-07 22:25:29 +02:00
parent 353dff20c4
commit f5c2404123
22 changed files with 721 additions and 7 deletions
+53 -7
View File
@@ -12,7 +12,7 @@
import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
@@ -33,6 +33,7 @@
toSubmit,
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
let { id }: { id: string } = $props();
@@ -127,15 +128,43 @@
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
placement = newPlacement(st.rack);
rackIds = st.rack.map((_, i) => i);
preview = null;
selected = null;
dirOverride = undefined;
await applyDraft(st);
recompute();
} catch (e) {
handleError(e);
}
}
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
// debounce; best-effort, so a failed save never interrupts play (Stage 17).
function scheduleDraftSave() {
if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}, 500);
}
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
// order (when still a valid permutation of the rack) and the board tiles whose cell is still
// free. Best-effort — a draft fetch never blocks opening the game.
async function applyDraft(st: StateView) {
let order = st.rack.map((_, i) => i);
let tiles: Tile[] = [];
try {
const parsed = parseDraft(await gateway.draftGet(id));
if (parsed) {
order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order;
const committed = replay(moves);
tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
}
} catch {
/* best-effort */
}
rackIds = order;
const rack = order.map((i) => st.rack[i]);
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
@@ -226,13 +255,16 @@
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
// Tiles may be arranged on the opponent's turn too (Stage 17 #5): only placement is
// relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is
// position-only (never scored or submitted).
if (busy || gameOver) return;
beginDrag({ from: 'rack', index }, e);
}
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
function onBoardDown(e: PointerEvent, row: number, col: number) {
if (!isMyTurn || busy || zoomed) return;
if (busy || zoomed || gameOver) return;
beginDrag({ from: 'board', row, col }, e);
}
function cellUnder(x: number, y: number): { row: number; col: number } | null {
@@ -274,6 +306,7 @@
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
scheduleDraftSave();
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
@@ -342,6 +375,7 @@
// Dropped a pending tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
recompute();
scheduleDraftSave();
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
@@ -359,6 +393,11 @@
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
// Flush a pending draft save so leaving mid-composition still persists it (Stage 17).
if (draftSaveTimer) {
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
telegramClosingConfirmation(false);
});
@@ -378,6 +417,7 @@
function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col);
recompute();
scheduleDraftSave();
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
@@ -391,6 +431,7 @@
placement = place(placement, index, row, col);
telegramHaptic('select');
recompute();
scheduleDraftSave();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
@@ -398,12 +439,15 @@
blankPrompt = null;
telegramHaptic('select');
recompute();
scheduleDraftSave();
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
// Off-turn the composition is position-only: no score preview or evaluate (Stage 17 #5).
if (!isMyTurn) return;
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
previewTimer = setTimeout(async () => {
@@ -436,6 +480,7 @@
preview = null;
selected = null;
dirOverride = undefined;
scheduleDraftSave();
}
async function doPass() {
@@ -507,6 +552,7 @@
setTimeout(() => (shuffling = false), 600);
// A short "shake": a few quick light taps rather than one.
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
scheduleDraftSave();
}
function openExchange() {
resetPlacement();
@@ -729,7 +775,7 @@
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || !isMyTurn || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}