bf46b9492d
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.
Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
top pad); a top gap above the landscape rack (it sat flush under the docked history);
more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.
Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2417 lines
102 KiB
Svelte
2417 lines
102 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount, untrack } from 'svelte';
|
|
import Screen from '../components/Screen.svelte';
|
|
import TabBar from '../components/TabBar.svelte';
|
|
import TapConfirm from '../components/TapConfirm.svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import PinPad from '../components/PinPad.svelte';
|
|
import DictWarmup from '../components/DictWarmup.svelte';
|
|
import Board from './Board.svelte';
|
|
import Rack from './Rack.svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
|
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
|
import { navigate } from '../lib/router.svelte';
|
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
|
import { connection } from '../lib/connection.svelte';
|
|
import { offlineMode } from '../lib/offline.svelte';
|
|
import { maybeShowInterstitial } from '../lib/ads';
|
|
import { GatewayError } from '../lib/client';
|
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
|
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
|
import { lastMoveCells, replay } from '../lib/board';
|
|
import { badgeKind } from '../lib/unread';
|
|
import { seatMedal } from '../lib/result';
|
|
import { historyGrid } from '../lib/history';
|
|
import { centre, premiumGrid } from '../lib/premiums';
|
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
|
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
|
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
|
import { patchLobbyGame } from '../lib/lobbycache';
|
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
|
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
|
|
import { haptic } from '../lib/haptics';
|
|
import {
|
|
BLANK,
|
|
newPlacement,
|
|
place,
|
|
placementFromHint,
|
|
previewFromHint,
|
|
rackView,
|
|
recallAt,
|
|
recallToSlot,
|
|
reorderIndices,
|
|
reset,
|
|
toSubmit,
|
|
type Placement,
|
|
} from '../lib/placement';
|
|
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
|
import { badgePlacement, formedGeometry } from '../lib/formed';
|
|
|
|
let { id }: { id: string } = $props();
|
|
|
|
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
|
|
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
|
|
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
|
|
const source = $derived(gameSource(id));
|
|
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
|
|
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
|
|
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
|
// toasts a blocked call would otherwise raise).
|
|
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
|
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
|
let localUnsub: (() => void) | null = null;
|
|
|
|
let view = $state<StateView | null>(null);
|
|
let moves = $state<MoveRecord[]>([]);
|
|
let placement = $state<Placement>(newPlacement([]));
|
|
let preview = $state<EvalResult | null>(null);
|
|
let busy = $state(false);
|
|
|
|
// The local move-preview subsystem (dynamically imported when enabled) and the warm-up
|
|
// overlay state. dict is null until the subsystem loads, or when the feature is disabled.
|
|
let dict = $state<typeof import('../lib/dict') | null>(null);
|
|
let dictWarming = $state(false);
|
|
let warmStarted = false;
|
|
// Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game)
|
|
// so a large download does not keep starving the channel on a slow link.
|
|
let warmCtrl: AbortController | null = null;
|
|
let zoomed = $state(false);
|
|
let selected = $state<number | null>(null);
|
|
let focus = $state<{ row: number; col: number } | null>(null);
|
|
// Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the
|
|
// hint recentring an already-zoomed board on the word it just played (a plain focus change is
|
|
// deliberately ignored by the board so placing/dragging tiles never jumps it).
|
|
let recenter = $state(0);
|
|
// 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 historyOpen = $state(false);
|
|
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
|
let exchangeOpen = $state(false);
|
|
let exchangeSel = $state<number[]>([]);
|
|
let resignOpen = $state(false);
|
|
// hintUsedThisTurn marks that a hint was applied on the current turn, so a confirmed play earns
|
|
// the hint-kind interstitial (its own 1-min cooldown) instead of the plain move one. Set in
|
|
// doHint when the hint's tiles land, read in commit, and cleared on any turn boundary
|
|
// (applyMoveResult) so a hint-then-pass does not leak into the next turn's move.
|
|
let hintUsedThisTurn = $state(false);
|
|
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
|
// Landscape (wide) layout: when the viewport is wider than tall the game switches to a
|
|
// two-column layout — the board fills the right side as a square fitted to the height (no
|
|
// zoom), while the rack, status, scores, the always-open history and the controls stack in a
|
|
// left panel. A plain matchMedia flag (like isCoarse below) drives it; the portrait layout is
|
|
// unchanged. The wiring effect is below, after isCoarse.
|
|
let landscape = $state(false);
|
|
// The history is shown either because the player opened the drawer (portrait) or always in
|
|
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
|
|
// affordance and its confirm reset so both work regardless of layout.
|
|
const historyShown = $derived(historyOpen || landscape);
|
|
// A nonce bumped to replay the 💬 fade-blink each time the history is shown with unread chat.
|
|
let chatBlink = $state(0);
|
|
// Opening the move history counts as reading the chat (even without entering it): when the
|
|
// history is shown with unread present, play the 💬 fade-blink (a two-cycle nudge) and mark the
|
|
// chat read. In landscape the history is docked open, so a message arriving while it is visible
|
|
// blinks and is read at once. markChatRead clears the flag (and acks the backend), so this
|
|
// settles after one pass and re-fires only when a new message raises unread again.
|
|
$effect(() => {
|
|
if (historyShown && app.chatUnread[id]) {
|
|
chatBlink++;
|
|
markChatRead(id);
|
|
}
|
|
});
|
|
|
|
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
|
const board = $derived(replay(moves));
|
|
const premium = $derived(premiumGrid(variant));
|
|
const ctr = $derived(centre(variant));
|
|
const pendingMap = $derived(
|
|
new Map(
|
|
placement.pending
|
|
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
|
|
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
|
),
|
|
);
|
|
// The recent-move highlight tracks the LAST move overall (not the last word): a play
|
|
// highlights its tiles, a trailing pass/exchange shows nothing — so the board never lights
|
|
// up the opponent's old word after our own pass. It is event-driven (refreshRecent), set
|
|
// only on a real game event (open/refresh, opponent move, our own committed move) and
|
|
// dismissed the moment we start composing, so recalling a just-placed tile never
|
|
// re-triggers it. recentFlash plays the one-off flash when the opponent just moved and it
|
|
// is now our turn.
|
|
let recent = $state<Set<string>>(new Set());
|
|
let recentFlash = $state(false);
|
|
function refreshRecent() {
|
|
const v = view;
|
|
if (!v || v.game.status === 'finished') {
|
|
recent = new Set();
|
|
recentFlash = false;
|
|
return;
|
|
}
|
|
recent = lastMoveCells(moves);
|
|
const last = moves.length ? moves[moves.length - 1] : null;
|
|
recentFlash = !!last && last.action === 'play' && last.player !== v.seat && v.game.toMove === v.seat;
|
|
}
|
|
$effect(() => {
|
|
// Composing dismisses the highlight until the next event refreshes it (so recalling the
|
|
// tiles we just placed leaves the board cleared, not re-lit).
|
|
if (placement.pending.length > 0) {
|
|
recent = new Set();
|
|
recentFlash = false;
|
|
}
|
|
});
|
|
const slots = $derived(rackView(placement));
|
|
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
|
|
// 'open' is an auto-match game still waiting for an opponent: the starter may move on their
|
|
// turn just like an active game, so "playable" covers both; only 'finished' is over.
|
|
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
|
|
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
|
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
|
const gameOver = $derived(!!view && view.game.status === 'finished');
|
|
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
|
|
// While locked the board stays visible but the rack is withheld and the move controls disabled;
|
|
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
|
|
const locked = $derived(!!view?.locked);
|
|
const canMove = $derived(isMyTurn && !locked);
|
|
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
|
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
|
// hint was spent in another game (see lib/hints).
|
|
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
|
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
|
|
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
|
|
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
|
|
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
|
|
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
|
|
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
|
|
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
|
|
let gateLeftMs = $state(0);
|
|
let monoNow = $state(performance.now());
|
|
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
|
|
const hintGated = $derived(hintRemaining > 0);
|
|
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
|
|
// load from the view, and on the robot's move to the full window (the robot just moved).
|
|
function armHintGate(leftSeconds: number): void {
|
|
if (leftSeconds > 0) {
|
|
hintGateStart = performance.now();
|
|
gateLeftMs = leftSeconds * 1000;
|
|
} else {
|
|
hintGateStart = null;
|
|
gateLeftMs = 0;
|
|
}
|
|
}
|
|
$effect(() => {
|
|
if (!view?.game.vsAi) return;
|
|
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
|
|
return () => clearInterval(iv);
|
|
});
|
|
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
|
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
|
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
|
const RACK_SIZE = 7;
|
|
// Exchange is legal only while the bag still holds a full rack; below that the player may pass
|
|
// but not swap, so the merged Exchange/Pass dialog offers pass alone there.
|
|
const canExchange = $derived((view?.bagLen ?? 0) >= RACK_SIZE);
|
|
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
|
|
// move while the game is active, but never the viewer themselves (their own pending cell
|
|
// stays empty) and never on a finished game.
|
|
const thinkingSeat = $derived(
|
|
!view || gameOver || view.game.toMove === view.seat ? null : view.game.toMove,
|
|
);
|
|
|
|
// moveActionLabel localizes a non-play move's history label (pass/exchange/resign/timeout);
|
|
// an unknown action (forward-compat for the MoveAction string union) falls back to its raw
|
|
// wire string.
|
|
const MOVE_LABELS = new Set(['pass', 'exchange', 'resign', 'timeout']);
|
|
function moveActionLabel(action: string): string {
|
|
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
|
|
}
|
|
|
|
// syncWallet adopts the server's authoritative hint-wallet balance into the global profile.
|
|
// The wallet is global, so keeping it live here (rather than per-game) is what stops the hint
|
|
// badge from going stale when a wallet hint was spent in another game.
|
|
function syncWallet(walletBalance: number) {
|
|
if (app.profile) app.profile.hintBalance = walletBalance;
|
|
}
|
|
|
|
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);
|
|
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
|
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
|
const [st, hist, draft] = await Promise.all([
|
|
source.gameState(id, includeAlphabet),
|
|
source.gameHistory(id),
|
|
source.draftGet(id).catch(() => ''),
|
|
]);
|
|
view = st;
|
|
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
|
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
|
|
// The game state no longer carries the hint wallet (it lives on the profile); do not touch
|
|
// app.profile.hintBalance here — that would clobber the authoritative payments balance.
|
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
|
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
|
moves = hist.moves;
|
|
setCachedGame(id, st, hist.moves, draft);
|
|
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
|
|
// stale flash before the lobby's own background refresh lands.
|
|
patchLobbyGame(st.game);
|
|
selected = null;
|
|
applyDraft(st, draft);
|
|
recompute();
|
|
refreshRecent();
|
|
} 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. The cache is updated at once
|
|
// so leaving and re-entering the game in the same session paints the latest composition.
|
|
function scheduleDraftSave() {
|
|
const json = serializeDraft(rackIds, placement.pending);
|
|
setCachedDraft(id, json);
|
|
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
|
draftSaveTimer = setTimeout(() => {
|
|
void source.draftSave(id, json).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. It takes the already-fetched draft JSON (load and the warm onMount supply it), so the
|
|
// composition is applied synchronously — no extra fetch, no second rack→board step.
|
|
function applyDraft(st: StateView, draftJson: string) {
|
|
let order = st.rack.map((_, i) => i);
|
|
let tiles: Tile[] = [];
|
|
const parsed = parseDraft(draftJson);
|
|
if (parsed) {
|
|
order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order;
|
|
const committed = replay(moves);
|
|
tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
|
|
}
|
|
rackIds = order;
|
|
const rack = order.map((i) => st.rack[i]);
|
|
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
|
}
|
|
onMount(() => {
|
|
// 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;
|
|
// Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a
|
|
// warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below.
|
|
armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0);
|
|
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
|
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
|
applyDraft(cached.view, cached.draft ?? '');
|
|
refreshRecent();
|
|
}
|
|
void load();
|
|
void loadFriends();
|
|
void loadBlocked();
|
|
// Load the local move-preview subsystem (kept out of the initial bundle) when enabled,
|
|
// so composing a move can be scored on-device; the network preview stays the fallback.
|
|
// Skipped in mock mode (no dictionary server; the mock uses the network preview path and
|
|
// must never see the warm-up overlay, which would intercept the e2e's taps).
|
|
if (import.meta.env.MODE !== 'mock') {
|
|
void import('../lib/dict').then((m) => {
|
|
dict = m;
|
|
});
|
|
}
|
|
// A local (offline) game has no live stream: route the source's robot-reply events through the
|
|
// same app event hub the network stream feeds, so the event effect above reacts to
|
|
// opponent_moved / game_over identically.
|
|
if (isLocalGameId(id)) {
|
|
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
|
|
}
|
|
});
|
|
|
|
// Warm the game's dictionary for the local move preview once, when both the game and the
|
|
// dynamically-imported preview subsystem are available (see warmDict).
|
|
$effect(() => {
|
|
if (dict && view && !warmStarted) {
|
|
warmStarted = true;
|
|
void warmDict();
|
|
}
|
|
});
|
|
|
|
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
|
|
function cacheSnapshot(): CachedGame | undefined {
|
|
return view ? { view, moves } : undefined;
|
|
}
|
|
// applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a
|
|
// flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing
|
|
// payload — see lib/gamedelta).
|
|
function applyDelta(res: DeltaResult): void {
|
|
if (res.cache) {
|
|
view = res.cache.view;
|
|
moves = res.cache.moves;
|
|
setCachedGame(id, view, moves);
|
|
recompute();
|
|
refreshRecent();
|
|
} else if (res.refetch) {
|
|
void load();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const e = app.lastEvent;
|
|
if (!e) return;
|
|
// Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
|
|
// view/moves/placement, so tracking those reads would re-run this effect from its own
|
|
// `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
|
|
// opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
|
|
// game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
|
|
// likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
|
|
// out of the effect's dependencies.
|
|
untrack(() => {
|
|
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
|
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
|
// the move as a delta with no fetch.
|
|
if (placement.pending.length > 0) void load();
|
|
else {
|
|
applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
|
// The robot just moved (vs_ai) → the human's turn begins with the full idle window.
|
|
if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000);
|
|
}
|
|
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
|
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
|
// only if we missed the move (our cached count trails the event's).
|
|
if (view && e.moveCount > view.game.moveCount) void load();
|
|
} else if (e.kind === 'game_over' && e.gameId === id) {
|
|
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
|
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
|
|
// The opponent took the empty seat: adopt the new participants and status in place,
|
|
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
|
|
const r = applyOpponentJoined(cacheSnapshot(), e.state);
|
|
if (r) {
|
|
view = r.view;
|
|
setCachedGame(id, r.view, r.moves);
|
|
}
|
|
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
|
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
|
void loadFriends();
|
|
} else if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
|
|
// A block/unblock applied (this or another of the viewer's sessions): reconcile the
|
|
// blocked set — and the friend set, since a block hides a blocked friend — in place.
|
|
void loadBlocked();
|
|
void loadFriends();
|
|
}
|
|
});
|
|
});
|
|
|
|
// The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped
|
|
// while the stream is down — or shed from a full subscriber buffer — is gone for good.
|
|
// opponent_moved and game_over self-heal via the next event's move-count gap check, but
|
|
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
|
|
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
|
|
|
|
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
|
|
// while it was down. The common case is a mobile suspend that drops the stream, the opponent
|
|
// joining during it, then a silent resume; this also rescues a missed opponent_moved/game_over.
|
|
let wasStreamAlive = app.streamAlive;
|
|
$effect(() => {
|
|
const alive = app.streamAlive;
|
|
if (alive && !wasStreamAlive) void load();
|
|
wasStreamAlive = alive;
|
|
});
|
|
|
|
// (B) While still waiting for an opponent with the stream down, the opponent_joined push cannot
|
|
// reach us at all; poll the game state until the seat fills (then waitingForOpponent flips and
|
|
// the timer is torn down) or the stream returns and (A) takes over.
|
|
$effect(() => {
|
|
if (!waitingForOpponent || app.streamAlive) return;
|
|
const timer = setInterval(() => void load(), 2500);
|
|
return () => clearInterval(timer);
|
|
});
|
|
|
|
// (C) Returning to the foreground without the stream having dropped (so (A) does not fire) may
|
|
// still have missed an event shed from a full hub buffer while suspended; refetch once per resync.
|
|
let lastResync = app.resync;
|
|
$effect(() => {
|
|
const r = app.resync;
|
|
if (r !== lastResync) {
|
|
lastResync = r;
|
|
void load();
|
|
}
|
|
});
|
|
|
|
function isCoarse(): boolean {
|
|
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
|
}
|
|
|
|
// Track the viewport orientation to drive the landscape layout switch (see `landscape`). The
|
|
// listener is torn down when the screen unmounts; falls back to portrait where matchMedia is
|
|
// unavailable.
|
|
$effect(() => {
|
|
if (typeof matchMedia === 'undefined') return;
|
|
const mq = matchMedia('(orientation: landscape)');
|
|
const update = () => (landscape = mq.matches);
|
|
update();
|
|
mq.addEventListener('change', update);
|
|
return () => mq.removeEventListener('change', update);
|
|
});
|
|
|
|
// --- 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. Null over an occupied cell.
|
|
let dropTarget = $state<{ row: number; col: number } | null>(null);
|
|
// Rack reordering: 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);
|
|
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
|
// hidden from the board (the ghost stands in) like a lifted rack tile.
|
|
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
|
// A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the
|
|
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
|
// they return the moment the tile is dragged back out over the board.
|
|
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
|
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
|
|
// formed word runs through — greened on the board — and the main word that anchors the score
|
|
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
|
|
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
|
const EMPTY_FORMED = new Set<string>();
|
|
const formedGeom = $derived(
|
|
preview?.legal && !recallOverRack
|
|
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
|
|
: null,
|
|
);
|
|
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
|
|
const boardBadge = $derived(
|
|
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
|
|
);
|
|
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
|
|
// the play's legality — green when legal, a calm pink when not.
|
|
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : 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);
|
|
boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer
|
|
clearHover();
|
|
clearReorder();
|
|
downInfo = null;
|
|
dragMoved = false;
|
|
drag = null;
|
|
}
|
|
function onRackDown(e: PointerEvent, index: number) {
|
|
// Tiles may be arranged on the opponent's turn too: 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 placed (pending) tile can be dragged to relocate it on the board or back to the rack —
|
|
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
|
|
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
|
if (busy || 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;
|
|
draggingPend = 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, touch: e.pointerType === 'touch' };
|
|
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
|
|
// placed board tile is likewise lifted off its cell while relocated.
|
|
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
|
|
draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : 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.
|
|
// Both auto-zooms are portrait-only — landscape fits the whole board.
|
|
}
|
|
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 (
|
|
overRack(e.clientY) &&
|
|
// A rack-source reorder (only with a clean, pending-free rack) or a board tile dragged
|
|
// back to the rack — both preview the drop slot as a gap the dropped tile will fill.
|
|
((reorderDragId != null && placement.pending.length === 0) || draggingPend != null)
|
|
) {
|
|
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() && !landscape && !zoomed && app.zoomBoard) {
|
|
focus = c;
|
|
zoomed = true;
|
|
haptic('light');
|
|
}
|
|
}, 700)
|
|
: null;
|
|
}
|
|
}
|
|
function onWinUp(e: PointerEvent) {
|
|
window.removeEventListener('pointermove', onWinMove);
|
|
window.removeEventListener('pointerup', onWinUp);
|
|
window.removeEventListener('pointerdown', onExtraPointer);
|
|
// A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it),
|
|
// but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the
|
|
// boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the
|
|
// next swipe read as multi-touch and kill the shuffle / history-pull gesture.
|
|
boardPointers.delete(e.pointerId);
|
|
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' && cell) {
|
|
// Dropped a placed tile on another board cell → relocate it there.
|
|
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
|
|
} else if (di.src.from === 'board' && onRack) {
|
|
// Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
|
|
// at (drag-to-position), or to its original slot when the drop is not over a gap.
|
|
if (to != null) {
|
|
const res = recallToSlot(placement, di.src.row, di.src.col, to);
|
|
rackIds = res.order.map((i) => rackIds[i] ?? i);
|
|
placement = res.placement;
|
|
} else {
|
|
placement = recallAt(placement, di.src.row, di.src.col);
|
|
}
|
|
selected = null;
|
|
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(() => {
|
|
// Leaving the game: abort a still-downloading dictionary and any in-flight preview so they
|
|
// stop holding the channel.
|
|
warmCtrl?.abort();
|
|
evalCtrl?.abort();
|
|
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.
|
|
if (draftSaveTimer) {
|
|
clearTimeout(draftSaveTimer);
|
|
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
|
}
|
|
localUnsub?.();
|
|
// Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its
|
|
// PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined
|
|
// during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup.
|
|
if (view?.game.hotseat) localSource.relock(view.game.id);
|
|
});
|
|
|
|
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).
|
|
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);
|
|
selected = null;
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
|
|
// (a board→board drag), keeping its rack slot and any blank letter.
|
|
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
|
|
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
|
|
if (!pt) return;
|
|
if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) {
|
|
return;
|
|
}
|
|
let p = recallAt(placement, fromRow, fromCol);
|
|
p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined);
|
|
placement = p;
|
|
focus = { row: toRow, col: toCol };
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
function attemptPlace(index: number, row: number, col: number) {
|
|
if (board[row]?.[col]) return;
|
|
if (pendingMap.has(`${row},${col}`)) return;
|
|
focus = { row, col };
|
|
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
|
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
|
|
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
|
|
if (placement.rack[index] === BLANK) {
|
|
blankPrompt = { rackIndex: index, row, col };
|
|
return;
|
|
}
|
|
placement = place(placement, index, row, col);
|
|
haptic('select');
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
function chooseBlank(letter: string) {
|
|
if (!blankPrompt) return;
|
|
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
|
blankPrompt = null;
|
|
haptic('select');
|
|
recompute();
|
|
scheduleDraftSave();
|
|
}
|
|
|
|
// warmDict loads the game's dictionary for the local move preview when the player opens the
|
|
// game. When it is already cached the preview is instant and no overlay shows; a cold
|
|
// dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after
|
|
// a 5s cap — the preview then uses the network until the download finishes in the background.
|
|
// The bad-connection breaker skips the overlay and goes straight to the network.
|
|
async function warmDict() {
|
|
const d = dict;
|
|
const v = view;
|
|
if (!d || !v) return;
|
|
if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return;
|
|
const ctrl = new AbortController();
|
|
warmCtrl = ctrl;
|
|
let settled = false;
|
|
// A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the
|
|
// overlay if the load has not settled by now — a cold (network) load always outlasts it.
|
|
const grace = setTimeout(() => {
|
|
if (!settled) dictWarming = true;
|
|
}, 120);
|
|
const loaded = await Promise.race([
|
|
d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal),
|
|
new Promise<null>((r) => setTimeout(() => r(null), 5000)),
|
|
]);
|
|
settled = true;
|
|
clearTimeout(grace);
|
|
dictWarming = false;
|
|
if (!loaded) {
|
|
// Did not load within the cap: abort the download so it stops starving the channel this
|
|
// session, and count the miss toward the bad-connection breaker (3 -> stop warming).
|
|
ctrl.abort();
|
|
d.noteDictMiss();
|
|
}
|
|
}
|
|
|
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let evalCtrl: AbortController | null = null;
|
|
function recompute() {
|
|
preview = null;
|
|
if (previewTimer) clearTimeout(previewTimer);
|
|
// The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so
|
|
// aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and
|
|
// rapid placements do not pile up requests on a slow link.
|
|
evalCtrl?.abort();
|
|
evalCtrl = null;
|
|
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
|
|
if (!canMove) return;
|
|
const sub = toSubmit(placement);
|
|
if (!sub) return;
|
|
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
|
const d = dict;
|
|
const v = view;
|
|
if (d && v) {
|
|
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
|
|
if (reader && hasAlphabet(v.game.variant)) {
|
|
try {
|
|
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
|
|
notePreviewLocal();
|
|
return;
|
|
} catch {
|
|
/* fall through to the network preview */
|
|
}
|
|
}
|
|
}
|
|
const ctrl = new AbortController();
|
|
evalCtrl = ctrl;
|
|
previewTimer = setTimeout(async () => {
|
|
try {
|
|
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
|
|
notePreviewNetwork();
|
|
} catch {
|
|
/* best-effort (or aborted) */
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
|
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
|
function applyMoveResult(r: MoveResult) {
|
|
// A turn boundary (play / pass / exchange / resign): clear the hint marker so it never leaks
|
|
// into the next turn. commit captures it before this runs.
|
|
hintUsedThisTurn = false;
|
|
view = {
|
|
game: r.game,
|
|
seat: r.move.player,
|
|
rack: r.rack,
|
|
bagLen: r.bagLen,
|
|
// A move is not a hint, so the per-game allowance is unchanged: carry it forward. The badge
|
|
// adds the live wallet from the profile.
|
|
hintsRemaining: view?.hintsRemaining ?? 0,
|
|
};
|
|
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
|
// moving is already cleared server-side, so reconcile the unread flag from it.
|
|
seedChatUnread(id, r.game.unreadChat, r.game.unreadMessages);
|
|
moves = [...moves, r.move];
|
|
// A committed move clears the actor's draft on the server, so clear the cached draft too;
|
|
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
|
|
setCachedGame(id, view, moves, '');
|
|
patchLobbyGame(r.game);
|
|
rackIds = r.rack.map((_, i) => i);
|
|
placement = newPlacement(r.rack);
|
|
selected = null;
|
|
recompute();
|
|
refreshRecent();
|
|
}
|
|
|
|
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
|
|
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
|
|
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
|
|
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
|
|
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
|
|
let hostPinEntered = $state('');
|
|
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
|
|
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
|
|
let hostDone = $state(false); // the transient success ✅ shown after an override
|
|
|
|
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
|
|
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
|
|
function hotseatName(i: number): string {
|
|
return view?.game.seats[i]?.displayName ?? '';
|
|
}
|
|
|
|
function startSkip(): void {
|
|
if (!view) return;
|
|
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
|
|
}
|
|
|
|
function flashDone(): void {
|
|
hostDone = true;
|
|
setTimeout(() => (hostDone = false), 1100);
|
|
}
|
|
|
|
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
|
|
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
|
|
async function advanceHotseat(): Promise<void> {
|
|
const st = await source.gameState(id, false);
|
|
view = st;
|
|
rackIds = st.rack.map((_, i) => i);
|
|
placement = newPlacement(st.rack);
|
|
selected = null;
|
|
preview = null;
|
|
setCachedGame(id, st, moves, '');
|
|
}
|
|
|
|
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
|
|
// and applies the unlocked state; handed to the PIN pad as its verifier.
|
|
async function onUnlock(pin: string): Promise<boolean> {
|
|
try {
|
|
const st = await localSource.unlockSeat(id, pin);
|
|
view = st;
|
|
rackIds = st.rack.map((_, i) => i);
|
|
placement = newPlacement(st.rack);
|
|
selected = null;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
|
|
async function verifyHost(pin: string): Promise<boolean> {
|
|
hostPinEntered = pin;
|
|
return localSource.verifyHostPin(id, pin);
|
|
}
|
|
|
|
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
|
|
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
|
|
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
|
|
return t('hotseat.askTerminate');
|
|
}
|
|
|
|
async function runHostAction(): Promise<void> {
|
|
if (!hostConfirm) return;
|
|
const { action, seat } = hostConfirm;
|
|
hostConfirm = null;
|
|
hostMenuStep = 'closed';
|
|
busy = true;
|
|
try {
|
|
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
|
|
if (st === null) {
|
|
navigate('/'); // terminated: the game is deleted, so return to the lobby
|
|
return;
|
|
}
|
|
view = st;
|
|
rackIds = st.rack.map((_, i) => i);
|
|
placement = newPlacement(st.rack);
|
|
selected = null;
|
|
preview = null;
|
|
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
|
|
setCachedGame(id, st, moves, '');
|
|
flashDone();
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
hostPinEntered = '';
|
|
}
|
|
}
|
|
|
|
async function commit() {
|
|
const sub = toSubmit(placement);
|
|
if (!sub) return;
|
|
busy = true;
|
|
// Capture the hint marker before applyMoveResult clears it: a move played off a hint earns the
|
|
// hint-kind interstitial (own cooldown), a plain move the move-kind one.
|
|
const usedHint = hintUsedThisTurn;
|
|
try {
|
|
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
|
|
if (view?.game.hotseat) await advanceHotseat();
|
|
haptic('success');
|
|
zoomed = false;
|
|
// A confirmed move may trigger a post-move interstitial (VK, frequency-gated, client-mirrored).
|
|
// Only submitPlay earns one — never a pass / exchange / resign. Fire-and-forget.
|
|
void maybeShowInterstitial(app.profile?.ads, usedHint ? 'hint' : 'move', {
|
|
vsAi: !!view?.game.vsAi,
|
|
online: connection.online && !offlineMode.active,
|
|
});
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
function resetPlacement() {
|
|
placement = reset(placement);
|
|
preview = null;
|
|
selected = null;
|
|
scheduleDraftSave();
|
|
}
|
|
|
|
async function doPass() {
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await source.pass(id));
|
|
if (view?.game.hotseat) await advanceHotseat();
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
// onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign
|
|
// on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action).
|
|
async function onResignClick(): Promise<void> {
|
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
|
if (await telegramShowConfirm(t('game.confirmResign'))) doResign();
|
|
return;
|
|
}
|
|
resignOpen = true;
|
|
}
|
|
|
|
async function doResign() {
|
|
resignOpen = false;
|
|
busy = true;
|
|
try {
|
|
applyMoveResult(await source.resign(id));
|
|
// Reveal the final board once the game is resigned: close the move-history drawer
|
|
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
|
historyOpen = false;
|
|
zoomed = false;
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
async function doHint() {
|
|
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
|
|
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
|
|
if (view?.game.vsAi) {
|
|
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
|
if (remaining > 0) {
|
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
const h = await source.hint(id);
|
|
if (h.move.tiles.length && view) {
|
|
placement = placementFromHint(h.move.tiles, view.rack);
|
|
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
|
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
|
hintUsedThisTurn = true;
|
|
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
|
|
// word — highlighted green while composing — is guaranteed visible rather than possibly
|
|
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
|
|
// focus/recenter step is gone with the zoom-in.
|
|
if (zoomed) zoomed = false;
|
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
|
syncWallet(h.walletBalance);
|
|
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
|
// preview instead of a redundant evaluate (same engine call, same placement). Cancel any
|
|
// pending evaluate so a stale one cannot clobber it; a later manual edit re-arms recompute().
|
|
if (previewTimer) {
|
|
clearTimeout(previewTimer);
|
|
previewTimer = null;
|
|
}
|
|
preview = previewFromHint(h.move);
|
|
}
|
|
} 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 if (e instanceof GatewayError && e.code === 'hint_locked') {
|
|
// The server-clock backstop refused a vs_ai hint the client thought was open (a rare client
|
|
// clock desync): re-sync the countdown from a fresh fetch and explain the gate.
|
|
await load();
|
|
const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), '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(() => haptic('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 {
|
|
applyMoveResult(await source.exchange(id, tiles, variant));
|
|
if (view?.game.hotseat) await advanceHotseat();
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
// confirmExchangeOrPass routes the merged dialog's confirm button: no tiles selected means a
|
|
// pass (a distinct game action), any selection means a tile exchange.
|
|
async function confirmExchangeOrPass() {
|
|
if (exchangeSel.length === 0) {
|
|
exchangeOpen = false;
|
|
await doPass();
|
|
return;
|
|
}
|
|
await doExchange();
|
|
}
|
|
|
|
function resultText(): string {
|
|
if (!view) return '';
|
|
const me = view.game.seats[view.seat];
|
|
if (me?.isWinner) return t('game.won');
|
|
if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
|
|
const myScore = me?.score ?? 0;
|
|
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
|
|
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
|
|
return t('game.lost');
|
|
}
|
|
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
|
|
// all-level finish is a draw.
|
|
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
|
|
}
|
|
|
|
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
|
// image — behind one 📤 button, both minted as one signed relative URL and delivered by
|
|
// the best affordance each platform actually has (every branch owner-verified on-device):
|
|
// TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own
|
|
// preview shares onwards). All bridge calls — activation-safe.
|
|
// TG iOS our modal chooser → the OS share sheet (WKWebView has
|
|
// navigator.share; a native-popup callback could not open it — no
|
|
// user activation — so the chooser stays the app modal here).
|
|
// VK iOS our modal (VK has no native chooser) → VKWebAppDownloadFile for
|
|
// both formats — the client lands in its native share/preview flow.
|
|
// VK Android our modal → the PNG opens in VK's native image viewer (its save
|
|
// works; the Android DownloadFile hangs), the GCG copies to the
|
|
// clipboard (the pre-URL route, always solid there).
|
|
// VK desktop iframe our modal → plain anchor downloads (an ordinary browser).
|
|
// mobile browsers our modal → the OS share sheet with the fetched file.
|
|
// desktop browsers our modal → plain anchor download.
|
|
let exportOpen = $state(false);
|
|
// TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a
|
|
// legacy client without downloadFile keeps the old GCG clipboard copy and hides the
|
|
// image option (and the chooser stays our modal).
|
|
const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios');
|
|
const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile());
|
|
|
|
async function onExportClick() {
|
|
if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) {
|
|
const choice = await telegramShowPopup({
|
|
message: t('game.exportChoice'),
|
|
buttons: [
|
|
{ id: 'png', type: 'default', text: t('game.exportImageOpt') },
|
|
{ id: 'gcg', type: 'default', text: t('game.exportGcgOpt') },
|
|
{ type: 'cancel' },
|
|
],
|
|
});
|
|
if (choice === 'png' || choice === 'gcg') void exportArtifact(choice);
|
|
return;
|
|
}
|
|
exportOpen = true;
|
|
}
|
|
|
|
// Legacy-Telegram GCG delivery (no downloadFile): fetch the text and copy it to the
|
|
// clipboard, as before the unified URL route.
|
|
async function exportGcgLegacy() {
|
|
try {
|
|
const gcg = await gateway.exportGcg(id);
|
|
const outcome = await shareOrDownloadGcg(gcg, true, copyGcgText);
|
|
if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info');
|
|
else if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// exportArtifact delivers a finished game's artifact by the unified signed-URL route.
|
|
// The device date locale, its IANA time zone and the UI-localized non-play labels ride
|
|
// the URL so the server-rendered image matches what the player would have seen locally.
|
|
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
|
|
async function exportArtifact(kind: 'png' | 'gcg') {
|
|
if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) {
|
|
void exportGcgLegacy();
|
|
return;
|
|
}
|
|
try {
|
|
const labels = EXPORT_ACTIONS.map((a) => t(`move.${a}` as MessageKey));
|
|
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
|
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
|
const url = new URL(path, location.origin).href;
|
|
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
|
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
|
if (insideVK()) {
|
|
// Per-VK-client delivery (each branch owner-verified): the iOS client's
|
|
// VKWebAppDownloadFile lands in a native share flow and is perfect; the ANDROID
|
|
// client's download hangs indefinitely, so the PNG opens in VK's native image
|
|
// viewer (its "save" works) and the GCG goes to the clipboard; the desktop
|
|
// iframe is an ordinary browser — plain anchor downloads.
|
|
if (vkAndroidWebView()) {
|
|
if (kind === 'png' && (await vkShowImages(url))) return;
|
|
if (kind === 'gcg') {
|
|
void exportGcgLegacy();
|
|
return;
|
|
}
|
|
} else if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) {
|
|
return;
|
|
}
|
|
downloadUrl(url, filename);
|
|
return;
|
|
}
|
|
// TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to
|
|
// a download where files cannot be shared — the desktop browser).
|
|
const outcome = await shareUrlAsFile(url, filename, mime);
|
|
if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead
|
|
// <a download>): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where
|
|
// navigator.clipboard is blocked — and navigator.clipboard elsewhere.
|
|
async function copyGcgText(text: string): Promise<boolean> {
|
|
if (insideVK()) return vkCopyText(text);
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
|
// The boardwrap surface drives two gestures, selected by `historyOpen`:
|
|
// - open: the slid board is inert (CSS pointer-events), so the whole board reads as a
|
|
// "tap or swipe up to close" surface and the stage cannot scroll instead of close. The
|
|
// tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
|
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
|
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
|
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
|
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
|
// On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the
|
|
// rack (a convenience mirror of the shuffle control).
|
|
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
|
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
|
let stageEl = $state<HTMLDivElement>();
|
|
let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null;
|
|
// Active pointers on the board wrapper. A second finger is a pinch-zoom (Board owns it), not
|
|
// a swipe, so the open/close pull is armed and advanced only while a single pointer is down —
|
|
// otherwise a two-finger pinch-out also slid the history open.
|
|
const boardPointers = new Set<number>();
|
|
function toggleHistory() {
|
|
historyOpen = !historyOpen;
|
|
}
|
|
function closeHistoryByGesture() {
|
|
if (!historyOpen) return;
|
|
historyOpen = false;
|
|
boardSwipe = null;
|
|
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
|
|
swallowClick = true;
|
|
setTimeout(() => (swallowClick = false), 120);
|
|
}
|
|
function openHistoryByGesture() {
|
|
if (historyOpen) return;
|
|
historyOpen = true;
|
|
boardSwipe = null;
|
|
// Swallow the click the opening pull may synthesise, so it neither places a tile nor
|
|
// immediately re-closes the panel via the boardwrap's tap-to-close.
|
|
swallowClick = true;
|
|
setTimeout(() => (swallowClick = false), 120);
|
|
}
|
|
function onBoardWrapDown(e: PointerEvent) {
|
|
if (landscape) return; // the history is docked open in landscape — no slide-drawer gesture
|
|
boardPointers.add(e.pointerId);
|
|
if (boardPointers.size > 1) {
|
|
boardSwipe = null; // multi-touch (a pinch) is never a swipe
|
|
return;
|
|
}
|
|
if (historyOpen) {
|
|
boardSwipe = { x: e.clientX, y: e.clientY, mode: 'close' };
|
|
return;
|
|
}
|
|
// Arm the open pull only where a downward drag is free to mean "reveal the history":
|
|
// the zoom-out board scrolled to its top, a touch pointer, no tile drag under way.
|
|
const onTile = !!(e.target as Element | null)?.closest?.('.cell.pending');
|
|
const atTop = (stageEl?.scrollTop ?? 0) <= 2;
|
|
boardSwipe =
|
|
!zoomed && atTop && !drag && !onTile && e.pointerType !== 'mouse'
|
|
? { x: e.clientX, y: e.clientY, mode: 'open' }
|
|
: null;
|
|
}
|
|
function onBoardWrapMove(e: PointerEvent) {
|
|
if (!boardSwipe || boardPointers.size > 1) return; // single-finger pull only
|
|
const dx = e.clientX - boardSwipe.x;
|
|
const dy = e.clientY - boardSwipe.y;
|
|
if (boardSwipe.mode === 'close') {
|
|
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
|
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
|
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
|
} else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) {
|
|
// The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out
|
|
// board with no staged tiles shuffles the rack (a convenience mirror of the shuffle
|
|
// control). Disarm and swallow the synthesised click so it cannot also place a tile.
|
|
shuffle();
|
|
boardSwipe = null;
|
|
swallowClick = true;
|
|
setTimeout(() => (swallowClick = false), 120);
|
|
}
|
|
}
|
|
function onBoardWrapUp(e: PointerEvent) {
|
|
boardPointers.delete(e.pointerId);
|
|
boardSwipe = null;
|
|
}
|
|
// A closed history clears every per-seat add-friend and block confirmation.
|
|
$effect(() => {
|
|
if (!historyShown) {
|
|
addConfirm = {};
|
|
blockConfirm = {};
|
|
}
|
|
});
|
|
|
|
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
|
// card while the history is open), derived from the server so it is correct across reloads
|
|
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
|
|
// `requested` are the addressees already requested (pending or declined — both block a
|
|
// re-send and disable the 🤝).
|
|
let friends = $state(new Set<string>());
|
|
let requested = $state(new Set<string>());
|
|
// `requestedRobotSeats` are the seat indices of disguised-robot opponents already requested in
|
|
// THIS game: a robot request is recorded per game+seat (never the shared robot account), so the
|
|
// 🤝 disables for just this seat and never leaks across the requester's other games.
|
|
let requestedRobotSeats = $state(new Set<number>());
|
|
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and
|
|
// their name is struck. Derived from the server so it is correct across reloads and live-updates
|
|
// on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked
|
|
// disguised-robot opponents in THIS game (a robot block is recorded per game+seat, not by
|
|
// account, so it never touches the shared robot account or other games).
|
|
let blocked = $state(new Set<string>());
|
|
let blockedRobotSeats = $state(new Set<number>());
|
|
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them);
|
|
// while set, that seat's card shows "Add friend?" / "Block?" in place of the score, and the
|
|
// opposite control is hidden so the two never overlap. Reset when history closes.
|
|
let addConfirm = $state<Record<number, boolean>>({});
|
|
let blockConfirm = $state<Record<number, boolean>>({});
|
|
|
|
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
|
|
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
|
|
async function loadFriends() {
|
|
if (app.profile?.isGuest) return;
|
|
try {
|
|
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
|
|
friends = new Set(fl.map((f) => f.accountId));
|
|
requested = new Set(out.requests.map((f) => f.accountId));
|
|
requestedRobotSeats = new Set(out.robots.filter((r) => r.gameId === id).map((r) => r.seat));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
// loadBlocked refreshes the blocked sets for a non-guest: blocked humans by account, plus the
|
|
// seats of any blocked disguised robots in this game. Best-effort.
|
|
async function loadBlocked() {
|
|
if (app.profile?.isGuest) return;
|
|
try {
|
|
const bl = await gateway.blocksList();
|
|
blocked = new Set(bl.blocked.map((b) => b.accountId));
|
|
blockedRobotSeats = new Set(bl.robots.filter((r) => r.gameId === id).map((r) => r.seat));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
// seatBlocked reports whether the viewer has blocked this seat — a human (by account) or a
|
|
// disguised robot (by this game's seat). It drives the struck name and hidden controls.
|
|
function seatBlocked(s: { accountId: string; seat: number }): boolean {
|
|
return blocked.has(s.accountId) || blockedRobotSeats.has(s.seat);
|
|
}
|
|
|
|
// addFriend and blockUser apply the new relationship optimistically (so the controls and score
|
|
// caption update the instant the confirm fires) and roll back to the prior state if the command
|
|
// fails to reach the server. The confirming user_blocked / user_added event then just reconciles
|
|
// the same state in place (no flicker).
|
|
async function addFriend(s: { accountId: string; seat: number }) {
|
|
// Optimistically mark this seat requested (disables the 🤝 for both a human and a disguised
|
|
// robot until the server confirms). The request carries the game id so a robot opponent is
|
|
// recorded as a per-game request pinned to this seat, not against the shared robot account.
|
|
const hadSeat = requestedRobotSeats.has(s.seat);
|
|
requestedRobotSeats = new Set([...requestedRobotSeats, s.seat]);
|
|
try {
|
|
await gateway.friendRequest(s.accountId, id);
|
|
showToast(t('friends.requestSent'));
|
|
} catch (e) {
|
|
if (!hadSeat) requestedRobotSeats = new Set([...requestedRobotSeats].filter((x) => x !== s.seat));
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
async function blockUser(s: { accountId: string; seat: number }) {
|
|
// Optimistically mark this seat blocked (covers the struck name + hidden controls for both a
|
|
// human and a disguised robot until the server confirms). The block carries the game id so a
|
|
// robot opponent is recorded as a per-game block; the user_blocked event then reconciles.
|
|
const hadSeat = blockedRobotSeats.has(s.seat);
|
|
blockedRobotSeats = new Set([...blockedRobotSeats, s.seat]);
|
|
try {
|
|
await gateway.block(s.accountId, id);
|
|
} catch (e) {
|
|
if (!hadSeat) blockedRobotSeats = new Set([...blockedRobotSeats].filter((x) => x !== s.seat));
|
|
handleError(e);
|
|
}
|
|
}
|
|
|
|
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
|
|
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
|
|
// display name.
|
|
function seatName(s: { accountId: string; displayName: string } | undefined): string {
|
|
if (!s) return '';
|
|
if (s.accountId === app.session?.userId) return t('common.you');
|
|
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
|
|
if (view?.game.vsAi) return '🤖';
|
|
if (!s.accountId) return t('game.searchingForOpponent');
|
|
return s.displayName;
|
|
}
|
|
|
|
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
|
|
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
|
|
function turnLabel(): string {
|
|
if (view?.game.vsAi) return '🤖';
|
|
const s = view?.game.seats[view?.game.toMove ?? -1];
|
|
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
|
|
return t('game.opponentsTurn');
|
|
}
|
|
|
|
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
|
|
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
|
// opponent still shows it, but disabled).
|
|
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
|
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
|
|
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
|
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
|
return (
|
|
!!s.accountId &&
|
|
!app.profile?.isGuest &&
|
|
s.accountId !== app.session?.userId &&
|
|
!friends.has(s.accountId) &&
|
|
!seatBlocked(s)
|
|
);
|
|
}
|
|
|
|
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend
|
|
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
|
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
|
function canBlock(s: { accountId: string; seat: number }): boolean {
|
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
|
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
|
}
|
|
</script>
|
|
|
|
<Screen
|
|
title={t(variantNameKey(variant))}
|
|
back="/"
|
|
growNav={!landscape}
|
|
column
|
|
scroll={false}
|
|
tabbar={landscape ? undefined : bottomBar}
|
|
selfInset={landscape}
|
|
>
|
|
{#if view}
|
|
{#if landscape}
|
|
<div class="game-land">
|
|
<div class="leftpane">
|
|
{@render turnStrip()}
|
|
{@render scoreboardBlock()}
|
|
<div class="history land">{@render historyBody()}</div>
|
|
{@render rackRow()}
|
|
<TabBar>{@render controlButtons()}</TabBar>
|
|
</div>
|
|
<div class="rightpane">{@render boardBlock()}</div>
|
|
</div>
|
|
{:else}
|
|
{@render turnStrip()}
|
|
{@render scoreboardBlock()}
|
|
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
|
{#if historyOpen}
|
|
<div class="history">{@render historyBody()}</div>
|
|
{/if}
|
|
{@render boardBlock()}
|
|
</div>
|
|
{@render rackRow()}
|
|
{/if}
|
|
{:else}
|
|
<p class="loading">{t('common.loading')}</p>
|
|
{/if}
|
|
</Screen>
|
|
|
|
<!-- Warm-up overlay while the game's dictionary loads for the local move preview. -->
|
|
{#if dictWarming}
|
|
<DictWarmup />
|
|
{/if}
|
|
|
|
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
|
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
|
{#snippet turnStrip()}
|
|
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
|
|
word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
|
|
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
|
|
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
|
|
{#if view && !(gameOver && view.game.hotseat)}
|
|
<div class="turnstrip" class:result={gameOver}>
|
|
{#if gameOver}
|
|
{resultText()}
|
|
{:else if preview?.legal && !recallOverRack}
|
|
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
|
{:else}
|
|
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet scoreboardBlock()}
|
|
{#if view}
|
|
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div class="scoreboard" class:flat={landscape} data-coach="game-header" onclick={landscape ? undefined : toggleHistory}>
|
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
|
{#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" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
|
|
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
|
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
|
</div>
|
|
{#if historyShown && canBlock(s) && !addConfirm[s.seat]}
|
|
<span class="blockuser">
|
|
<TapConfirm
|
|
label={t('friends.blockFromGame')}
|
|
onConfirming={(v) => (blockConfirm[s.seat] = v)}
|
|
onconfirm={() => blockUser(s)}
|
|
>
|
|
<span class="fico">✖️</span>
|
|
</TapConfirm>
|
|
</span>
|
|
{/if}
|
|
{#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]}
|
|
<span class="addfriend">
|
|
<TapConfirm
|
|
label={t('friends.addFromGame')}
|
|
disabled={requested.has(s.accountId) || requestedRobotSeats.has(s.seat)}
|
|
onConfirming={(v) => (addConfirm[s.seat] = v)}
|
|
onconfirm={() => addFriend(s)}
|
|
>
|
|
<span class="fico">🤝</span>
|
|
</TapConfirm>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet historyBody()}
|
|
{#if view}
|
|
<div class="hhead">
|
|
{#if gameOver}
|
|
{#if view.game.vsAi}
|
|
<!-- GCG export is not offered for a practice AI game; an empty slot keeps the comms
|
|
icon pinned right (the header is space-between). -->
|
|
<span aria-hidden="true"></span>
|
|
{:else}
|
|
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
|
{/if}
|
|
{:else if view.game.hotseat}
|
|
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
|
<span aria-hidden="true"></span>
|
|
{:else}
|
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
|
{/if}
|
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
|
<!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
|
|
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
|
— so drop the entry only when a local game is finished; an active local game keeps it for
|
|
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
|
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<div class="hgridwrap">
|
|
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
|
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
|
|
{#each row as cell (cell.player)}
|
|
<div class="hcell">
|
|
{#if cell.kind === 'play'}
|
|
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
|
|
{:else if cell.kind === 'action'}
|
|
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
|
|
{:else if cell.kind === 'thinking'}
|
|
<span class="hsys">{t('game.thinking')}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
{#if view.game.endReason === 'aborted'}
|
|
<p class="horganizer">{t('game.abortedNote')}</p>
|
|
{/if}
|
|
</div>
|
|
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet boardBlock()}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
class="boardwrap"
|
|
class:slid={historyOpen && !landscape}
|
|
onpointerdown={onBoardWrapDown}
|
|
onpointermove={onBoardWrapMove}
|
|
onpointerup={onBoardWrapUp}
|
|
onpointercancel={onBoardWrapUp}
|
|
onclick={() => {
|
|
if (!swallowClick) closeHistoryByGesture();
|
|
}}
|
|
>
|
|
<Board
|
|
{board}
|
|
{premium}
|
|
pending={pendingMap}
|
|
highlight={recent}
|
|
flash={recentFlash}
|
|
centre={ctr}
|
|
{zoomed}
|
|
{landscape}
|
|
{variant}
|
|
labelMode={app.boardLabels}
|
|
locale={app.locale}
|
|
{focus}
|
|
{recenter}
|
|
{dropTarget}
|
|
{previewLegal}
|
|
formed={formedCells}
|
|
scoreBadge={boardBadge}
|
|
oncell={onCell}
|
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
|
onrecall={onRecall}
|
|
onpenddown={onBoardDown}
|
|
/>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet rackRow()}
|
|
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
|
<div class="rack-wrap">
|
|
{#if locked}
|
|
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
|
above stays visible; only this tray is gated. -->
|
|
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
|
{:else}
|
|
<Rack
|
|
slots={rackSlots}
|
|
{variant}
|
|
{selected}
|
|
shuffling={shuffling && !app.reduceMotion}
|
|
draggingId={reorderDragId}
|
|
dropIndex={reorderTo}
|
|
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
|
ondown={onRackDown}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet confirmBtn()}
|
|
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
|
|
play is staged; disabled until the preview says it is legal. -->
|
|
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
|
{/snippet}
|
|
|
|
{#snippet controlButtons()}
|
|
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
|
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
|
|
</button>
|
|
{#if view?.game.hotseat}
|
|
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
|
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
|
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
|
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
|
</button>
|
|
{:else if view?.game.vsAi}
|
|
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
|
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
|
lifts live at the unlock moment. -->
|
|
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
|
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
|
|
<span class="lbl">{t('game.hint')}</span>
|
|
</button>
|
|
{:else}
|
|
<TapConfirm
|
|
triggerClass="tab"
|
|
label={t('game.hint')}
|
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
|
onconfirm={doHint}
|
|
>
|
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
|
<span class="lbl">{t('game.hint')}</span>
|
|
</TapConfirm>
|
|
{/if}
|
|
{#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" data-coach="game-shuffle">🔀</span>
|
|
</button>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet bottomBar()}
|
|
{#if view}
|
|
<TabBar>{@render controlButtons()}</TabBar>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#if drag}
|
|
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
|
<span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : 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')}
|
|
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
|
|
onclose={() => (exchangeOpen = false)}
|
|
>
|
|
<div class="exch">
|
|
{#each view.rack as letter, i (i)}
|
|
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
|
|
{letter === BLANK ? '?' : letter}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<button class="confirm" onclick={confirmExchangeOrPass}>
|
|
{exchangeSel.length === 0 ? t('game.passNoExchange') : t('game.exchangeConfirm', { n: exchangeSel.length })}
|
|
</button>
|
|
</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} disabled={!netReady}>{t('game.dropGame')}</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<!-- Offline hotseat: unlock the current seat's rack. -->
|
|
{#if unlockOpen && view}
|
|
<PinPad
|
|
mode="verify"
|
|
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
|
|
verify={onUnlock}
|
|
onclose={() => (unlockOpen = false)}
|
|
onresult={() => (unlockOpen = false)}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
|
|
{#if hostMenuStep === 'pin'}
|
|
<PinPad
|
|
mode="verify"
|
|
title={t('hotseat.host')}
|
|
verify={verifyHost}
|
|
onclose={() => (hostMenuStep = 'closed')}
|
|
onresult={() => (hostMenuStep = 'menu')}
|
|
/>
|
|
{/if}
|
|
{#if hostMenuStep === 'menu' && view}
|
|
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
|
|
{#if hostConfirm}
|
|
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
|
|
<div class="confirm-row">
|
|
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
|
|
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
|
|
</div>
|
|
{:else}
|
|
<div class="host-menu">
|
|
<button class="host-act" onclick={startSkip}>
|
|
{t('hotseat.skip')}
|
|
</button>
|
|
<div class="host-sub">{t('hotseat.exclude')}</div>
|
|
{#each view.game.seats as s (s.seat)}
|
|
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
|
|
{s.displayName}
|
|
</button>
|
|
{/each}
|
|
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
|
|
</div>
|
|
{/if}
|
|
</Modal>
|
|
{/if}
|
|
{#if hostDone}
|
|
<div class="host-done" aria-hidden="true">✅</div>
|
|
{/if}
|
|
|
|
{#if exportOpen}
|
|
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
|
<div class="export-opts">
|
|
{#if exportImageAvailable}
|
|
<button
|
|
class="confirm"
|
|
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
|
disabled={!netReady}
|
|
>
|
|
{t('game.exportImageOpt')}
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
|
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
|
disabled={!netReady}
|
|
>
|
|
{t('game.exportGcgOpt')}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<style>
|
|
.scoreboard {
|
|
position: relative;
|
|
display: flex;
|
|
flex: none;
|
|
gap: 6px;
|
|
padding: 8px var(--pad);
|
|
background: var(--bg-elev);
|
|
cursor: pointer;
|
|
}
|
|
.seat {
|
|
position: relative;
|
|
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;
|
|
}
|
|
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
|
|
.medal {
|
|
margin-right: 3px;
|
|
}
|
|
.sc {
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
|
|
play, accented for the viewer's win/lose result once the game ends. */
|
|
.turnstrip {
|
|
flex: none;
|
|
/* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
|
|
even strip read as too tall with the text pushed up. */
|
|
padding: 4px var(--pad) 2px;
|
|
text-align: center;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
background: var(--bg-elev);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.turnstrip.result {
|
|
color: var(--accent);
|
|
}
|
|
.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;
|
|
}
|
|
/* While the history is open the stage must not scroll — a swipe up on the board closes
|
|
the panel instead of scrolling the slid board out from under it. */
|
|
.stage.histopen {
|
|
overflow: 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. It is a flex
|
|
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
|
|
height: 62%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--surface-2);
|
|
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
/* The history is a ruled matrix: one column per seat (aligned under the score plaque), each
|
|
seat's moves filling its column top to bottom. The thin grid lines are the 1px gap showing
|
|
the border colour through the cells' surface fill; there is no outer frame (cells sit flush
|
|
to the grid edge — the .hhead border above and the .history border below close the table).
|
|
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
|
plaques. */
|
|
.hgridwrap {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
|
|
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
|
overscroll-behavior: none;
|
|
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
|
|
scrollbar-gutter: stable;
|
|
padding: 8px var(--pad);
|
|
}
|
|
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
|
|
as moves accumulate. The same count also rides the exchange control as a badge (see
|
|
controlButtons), which is the always-visible indicator when the table is closed. */
|
|
.hbagfoot {
|
|
flex: none;
|
|
padding: 6px var(--pad);
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.hgrid {
|
|
display: grid;
|
|
gap: 1px;
|
|
background: var(--border);
|
|
font-size: 0.9rem;
|
|
}
|
|
.hcell {
|
|
display: grid;
|
|
place-items: center;
|
|
text-align: center;
|
|
min-height: 1.7em;
|
|
padding: 6px 8px;
|
|
background: var(--surface-2);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
/* Secondary annotations within a cell — the parenthesised move score, a non-play action
|
|
label, the awaited "thinking…" — read muted, like the old running-total/system labels. */
|
|
.hsc,
|
|
.hsys {
|
|
color: var(--text-muted);
|
|
}
|
|
.hsc {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
/* The impersonal organizer note closing an aborted (un-replayable) game, under the grid. */
|
|
.horganizer {
|
|
margin: 0;
|
|
padding: 8px var(--pad);
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
font-style: italic;
|
|
text-align: center;
|
|
}
|
|
.boardwrap {
|
|
padding: 6px;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
.boardwrap.slid {
|
|
transform: translateY(62%);
|
|
}
|
|
/* The slid board is inert: the whole surface reads as "tap or swipe up to close". */
|
|
.boardwrap.slid :global(.viewport) {
|
|
pointer-events: none;
|
|
}
|
|
/* The single-word-rule label centred in the history header between its two icons. */
|
|
.oneword-label {
|
|
flex: 1;
|
|
text-align: center;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.rack-row {
|
|
position: relative;
|
|
display: flex;
|
|
flex: none;
|
|
align-items: stretch;
|
|
padding: 0 var(--pad) 6px;
|
|
}
|
|
.rack-row.inert {
|
|
pointer-events: none;
|
|
opacity: 0.55;
|
|
}
|
|
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
|
|
small gap above; portrait gets its spacing from the board's own padding. */
|
|
.game-land .rack-row {
|
|
padding-top: 6px;
|
|
}
|
|
.rack-wrap {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
|
|
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
|
|
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
|
|
.make {
|
|
grid-column: 7;
|
|
align-self: stretch;
|
|
display: grid;
|
|
place-items: center;
|
|
background: none;
|
|
color: var(--text);
|
|
border: none;
|
|
font-size: 1.8rem;
|
|
}
|
|
.make:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
|
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
|
.hhead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 4px 8px;
|
|
background: var(--surface-2);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.hicon {
|
|
position: relative;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text);
|
|
font-size: 1.3rem;
|
|
line-height: 1;
|
|
padding: 4px 8px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.hicon:active {
|
|
background: var(--bg-elev);
|
|
}
|
|
/* The 🤝 add-friend control: pinned to the seat's right edge so the centred name and
|
|
score never shift; the TapConfirm inside swaps it for a fading ✅ on tap. */
|
|
.addfriend {
|
|
position: absolute;
|
|
right: 2px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 1.15rem;
|
|
}
|
|
/* The ✖️ block control mirrors add-friend on the seat's left edge. */
|
|
.blockuser {
|
|
position: absolute;
|
|
left: 2px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 1.15rem;
|
|
}
|
|
.fico {
|
|
line-height: 1;
|
|
}
|
|
/* A blocked opponent's name is struck through. */
|
|
.nm.struck {
|
|
text-decoration: line-through;
|
|
}
|
|
/* The "Block?" confirm caption replaces the score in the danger colour (theme-aware). */
|
|
.sc.blockprompt {
|
|
color: var(--danger);
|
|
}
|
|
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
|
|
when the history is opened with unread present, rather than carrying a count badge. Red for an
|
|
unread message, a softer amber when only nudges are unread. */
|
|
.unread-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--danger);
|
|
}
|
|
.unread-dot.nudge {
|
|
background: var(--warn);
|
|
}
|
|
.sbadge-dot {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 6px;
|
|
}
|
|
.chat-ico {
|
|
display: inline-block;
|
|
line-height: 1;
|
|
}
|
|
.chat-ico.blink {
|
|
animation: chat-blink 1s ease-in-out 2;
|
|
}
|
|
@keyframes chat-blink {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
.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;
|
|
}
|
|
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */
|
|
.ghost.touch {
|
|
transform: translate(-50%, -50%) scale(1.5);
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
}
|
|
/* Export chooser: the image option leads (accent .confirm), the GCG file is the quiet
|
|
alternative below it. */
|
|
.export-opts {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.export-alt {
|
|
width: 100%;
|
|
padding: 11px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
.export-alt:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* --- Landscape (wide) layout ------------------------------------------------------------
|
|
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,
|
|
status, scores, the always-open history, the controls) and the board on the right, fitted
|
|
to the available height as a square (no zoom). All the portrait styles above are untouched;
|
|
these rules apply only inside the landscape branch's `.game-land` wrapper. */
|
|
.game-land {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
|
gap: 4px;
|
|
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
|
grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
|
|
controls bar and the board reach the screen edge — the controls bar owns the home-indicator
|
|
inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
|
|
padding: 4px var(--pad) 0 0;
|
|
overflow: hidden;
|
|
}
|
|
.leftpane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
/* The home-indicator inset on the controls side: the left panel's controls bar paints its own
|
|
chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
|
|
is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
|
|
indicator. */
|
|
.game-land .leftpane :global(.tabbar) {
|
|
padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
|
|
}
|
|
.rightpane {
|
|
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
|
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
|
grows past the pane and pans within it, occupying the full width up to the left panel. The
|
|
board has the lowest priority, so the pane shrinks the board (by width) when narrow. */
|
|
container-type: size;
|
|
display: grid;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
.rightpane .boardwrap {
|
|
padding: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
/* The history docked open in the left panel: a normal flex child that fills the remaining
|
|
height and scrolls, its sticky header staying pinned — no slide-down drawer, no transform. */
|
|
.history.land {
|
|
position: static;
|
|
height: auto;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
box-shadow: none;
|
|
/* Sits between the score plaques (above) and the status/rack/controls (below): rule it off on
|
|
both edges (the base rule already draws the bottom border). */
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
/* The score plaques do not toggle the history in landscape (it is always open). */
|
|
.scoreboard.flat {
|
|
cursor: default;
|
|
}
|
|
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
|
|
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
|
|
.sq .lock {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
font-size: 0.62rem;
|
|
line-height: 1;
|
|
}
|
|
/* --- offline hotseat --- */
|
|
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
|
|
stays visible. */
|
|
.unlock {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 44px;
|
|
border: 1px dashed var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
.host-menu {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.host-act,
|
|
.host-seat {
|
|
padding: 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
text-align: left;
|
|
}
|
|
.host-act.danger {
|
|
border-color: var(--danger);
|
|
color: var(--danger);
|
|
}
|
|
.host-sub {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
margin-top: 4px;
|
|
}
|
|
.host-q {
|
|
margin: 0 0 12px;
|
|
font-weight: 600;
|
|
}
|
|
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
|
|
.host-done {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 4rem;
|
|
z-index: 50;
|
|
pointer-events: none;
|
|
animation: host-done-fade 1.1s ease forwards;
|
|
}
|
|
@keyframes host-done-fade {
|
|
0% { opacity: 0; transform: scale(0.7); }
|
|
25% { opacity: 1; transform: scale(1); }
|
|
75% { opacity: 1; transform: scale(1); }
|
|
100% { opacity: 0; transform: scale(1); }
|
|
}
|
|
</style>
|