Files
scrabble-game/ui/src/game/Game.svelte
T
Ilia Denisov f5c2404123
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
Stage 17 round 6 (#4/#5/#6): draft persistence wire + gateway + UI
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.
2026-06-07 22:25:29 +02:00

1189 lines
40 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import HoldConfirm from '../components/HoldConfirm.svelte';
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
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, Tile } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
place,
placementFromHint,
rackView,
recallAt,
reorderIndices,
reset,
toSubmit,
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let dirOverride = $state<Direction | undefined>(undefined);
let busy = $state(false);
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
let shuffling = $state(false);
let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let checkOpen = $state(false);
let checkWord = $state('');
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>();
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant));
const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
);
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
// are highlighted. It flashes when the opponent just moved and it is now our turn.
const highlight = $derived(
placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
? new Set<string>()
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
);
const flash = $derived(
!!lastPlay &&
!!view &&
view.game.status === 'active' &&
lastPlay.player !== view.seat &&
view.game.toMove === view.seat,
);
const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// Nudge cooldown (one per hour per game, mirrored from the backend): the control is
// disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
// timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0);
// Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the
// backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side
// (the backend stays authoritative across a reload).
let lastActedAt = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== mine) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
// Engagement since the nudge clears the cooldown: a chat or a move.
return lastChat <= lastNudge && lastActedAt <= lastNudge;
});
async function load() {
try {
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
// game whose variant the client has not cached yet); steady-state polls omit it.
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
const [st, hist] = await Promise.all([
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
]);
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
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);
} catch (e) {
handleError(e);
}
}
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
if (cached) {
view = cached.view;
moves = cached.moves;
placement = newPlacement(cached.view.rack);
rackIds = cached.view.rack.map((_, i) => i);
}
void load();
});
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if (e.kind === 'opponent_moved' && e.gameId === id) {
// Skip the echo of my own move (the backend now notifies the actor too, for the
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
$effect(() => {
if (panel !== 'chat') return;
const h = setInterval(() => (nudgeTick += 1), 20000);
return () => clearInterval(h);
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
// --- tile placement: pointer drag + tap ---
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
// for the movement threshold that distinguishes a drag from a tap.
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
let dragMoved = false;
let swallowClick = false;
let hoverKey = '';
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
downInfo = { src, x0: e.clientX, y0: e.clientY };
dragMoved = false;
dragPointerId = e.pointerId;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
window.addEventListener('pointerdown', onExtraPointer);
}
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
// The starting pointer's own event also bubbles here, so ignore it by id.
function onExtraPointer(e: PointerEvent) {
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
}
function cancelDrag() {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
downInfo = null;
dragMoved = false;
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
// 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 (busy || zoomed || gameOver) return;
beginDrag({ from: 'board', row, col }, e);
}
function cellUnder(x: number, y: number): { row: number; col: number } | null {
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
| HTMLElement
| null;
if (!el?.dataset.row || !el.dataset.col) return null;
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
}
function clearHover() {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hoverKey = '';
dropTarget = null;
}
function clearReorder() {
reorderDragId = null;
reorderTo = null;
}
// overRack reports whether y is within the rack's row (a small margin makes the target
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
function overRack(y: number): boolean {
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
}
function dropSlotAt(x: number): number {
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
for (let i = 0; i < tiles.length; i++) {
const r = tiles[i].getBoundingClientRect();
if (x < r.left + r.width / 2) return i;
}
return tiles.length;
}
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
function reorderRack(fromIndex: number, toSlot: number) {
if (placement.pending.length > 0) return;
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
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;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const src = downInfo.src;
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
reorderTo = null;
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
reorderTo = dropSlotAt(e.clientX);
dropTarget = null;
} else {
dropTarget = null;
reorderTo = null;
}
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer =
c && !zoomed
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first
// (zoom-in) hold centres; once zoomed we never move the board on hover.
if (drag && isCoarse() && !zoomed) {
focus = c;
zoomed = true;
telegramHaptic('light');
}
}, 1000)
: null;
}
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
drag = null;
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
const to = reorderTo;
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
} else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && onRack) {
// 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);
} else if (di && di.src.from === 'rack') {
selected = selected === di.src.index ? null : di.src.index;
drag = null;
} else {
drag = null;
}
clearReorder();
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
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);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
// by a single tap (which recalled too easily — Stage 17).
if (pendingMap.has(`${row},${col}`)) return;
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
// on an occupied cell doesn't cancel placement — wait for an empty cell.
if (board[row]?.[col]) return;
attemptPlace(selected, row, col);
selected = null;
}
}
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;
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
}
placement = place(placement, index, row, col);
telegramHaptic('select');
recompute();
scheduleDraftSave();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
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 () => {
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
} catch {
/* best-effort */
}
}, 250);
}
async function commit() {
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success');
zoomed = false;
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function resetPlacement() {
placement = reset(placement);
preview = null;
selected = null;
dirOverride = undefined;
scheduleDraftSave();
}
async function doPass() {
busy = true;
try {
await gateway.pass(id);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doResign() {
resignOpen = false;
busy = true;
try {
await gateway.resign(id);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doHint() {
try {
const h = await gateway.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
}
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
}
} catch (e) {
// The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info');
} else {
handleError(e);
}
}
}
function shuffle() {
if (placement.pending.length > 0) return;
// Shuffle an index permutation, then apply it to both the letters and the slot ids so
// each tile keeps its id as it flies to a new position (driving Rack's hop animation).
const order = placement.rack.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
shuffling = true;
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();
exchangeSel = [];
exchangeOpen = true;
}
function toggleExch(i: number) {
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
}
async function doExchange() {
if (!view || exchangeSel.length === 0) return;
const tiles = exchangeSel.map((i) => view!.rack[i]);
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function openCheck() {
checkWord = '';
checkResult = null;
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
// Key the cache and the displayed result on the upper-case word the player typed; the
// server echoes the decoded concrete word in the solver's lower case.
checkedWords.set(w, r.legal);
checkResult = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!checkResult) return;
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) {
handleError(e);
}
}
function openChat() {
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
}
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
async function exportGcg() {
try {
await shareOrDownloadGcg(await gateway.exportGcg(id));
} catch (e) {
handleError(e);
}
}
let requested = $state(new Set<string>());
const noop = () => {};
async function addFriend(accountId: string) {
try {
await gateway.friendRequest(accountId);
requested = new Set([...requested, accountId]);
showToast(t('friends.requestSent'));
} catch (e) {
handleError(e);
}
}
const opponents = $derived(
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
);
// In a finished game the menu drops Check word and Drop game, gains Export GCG, and
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
)
: []),
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
]);
</script>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} />
{/snippet}
{#if view}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
</div>
{/each}
</div>
<div class="stage">
{#if historyOpen}
<div class="history">
<ol>
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
</li>
{/each}
{#if moves.length === 0}<li class="hempty"></li>{/if}
</ol>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen}
onclick={() => historyOpen && (historyOpen = false)}
>
<Board
{board}
{premium}
pending={pendingMap}
{highlight}
{flash}
centre={ctr}
{zoomed}
{variant}
labelMode={app.boardLabels}
lines={app.boardLines}
locale={app.locale}
{focus}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
</div>
</div>
<div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
{/if}
<span class="scores">
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
</span>
</div>
<!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack
slots={rackSlots}
{variant}
{selected}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{#snippet tabbar()}
{#if view}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
</TabBar>
{/if}
{/snippet}
</Screen>
{#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
</div>
{/if}
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabetLetters(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>
</Modal>
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
{letter === BLANK ? '?' : letter}
</button>
{/each}
</div>
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
{t('game.exchangeConfirm', { n: exchangeSel.length })}
</button>
</Modal>
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
<div class="check">
<input
value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
{checkResult.legal
? t('game.wordLegal', { word: checkResult.word })
: t('game.wordIllegal', { word: checkResult.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
flex: none;
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
cursor: pointer;
}
.seat {
flex: 1;
text-align: center;
padding: 5px 4px;
border-radius: var(--radius-sm);
/* inactive seats recede: they blend into the bar, slightly sunk */
background: transparent;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
}
.seat .nm {
color: var(--text-muted);
}
.seat.turn {
/* the active seat pops: a raised, accented chip lifted clear of the bar */
background: var(--surface-2);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.3),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
z-index: 1;
}
.seat.turn .nm {
color: var(--accent);
}
.seat.win .sc {
color: var(--ok);
}
.nm {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stage {
position: relative;
/* The board is the only part that scrolls vertically when the game does not fit;
the score bar, status, rack and tab bar stay put (#9). */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.history {
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
height: 62%;
overflow: auto;
scrollbar-gutter: stable;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
}
.history ol {
margin: 0;
padding: 8px 14px;
list-style: decimal;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 0.9rem;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ht {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.hempty {
justify-content: center;
color: var(--text-muted);
}
.boardwrap {
padding: 6px;
transition: transform 0.3s ease;
}
.boardwrap.slid {
transform: translateY(62%);
}
.status {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
.rack-row {
display: flex;
flex: none;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad) 6px;
}
.rack-row.inert {
pointer-events: none;
opacity: 0.55;
}
.rack-wrap {
flex: 1;
min-width: 0;
}
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal (Stage 17). */
.make {
min-width: 56px;
background: none;
color: var(--text);
border: none;
display: grid;
place-items: center;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.4;
}
.pop {
padding: 9px 14px;
border: none;
background: none;
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 500;
text-align: left;
}
.pop:hover {
background: var(--surface-2);
}
.badge {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.68rem;
font-weight: 700;
background: var(--accent);
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
padding: 0 3px;
line-height: 1.4;
text-align: center;
}
.loading {
text-align: center;
color: var(--text-muted);
padding: 40px;
}
.ghost {
position: fixed;
width: 40px;
height: 40px;
transform: translate(-50%, -50%);
background: var(--tile-pending);
color: var(--tile-text);
border-radius: 5px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 1.3rem;
box-shadow: var(--shadow);
pointer-events: none;
z-index: 60;
}
.alpha {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.alpha button {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 700;
}
.exch {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.etile {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 5px;
font-weight: 700;
}
.etile.sel {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.confirm {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-sm);
font-weight: 700;
}
.confirm:disabled {
opacity: 0.5;
}
.check {
display: flex;
gap: 6px;
}
.check input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.complain {
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
</style>