Compare commits
32 Commits
baa9961c3e
...
v1.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 780ff68ec2 | |||
| ca48b3e18e | |||
| a7f483792f | |||
| 80cc2a76e8 | |||
| 1e087be90a | |||
| 84d0385c95 | |||
| 0ed34c7720 | |||
| 52d0c559f9 | |||
| beda6ccd3d | |||
| c1ac77bc6a | |||
| cafe67e9c8 | |||
| 3adc4e32c9 | |||
| 903de7d41d | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -1242,10 +1242,15 @@ browser-language detection — crawlers render with arbitrary languages). The fa
|
||||
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||
no network, and falls every in-scope navigation back to the precached shell (the hash router
|
||||
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded,
|
||||
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is
|
||||
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||
cache-first, and the hash router resolves the route client-side. This navigation route is registered
|
||||
**before** the precache route on purpose: Workbox matches in registration order, and the precache
|
||||
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
|
||||
+30
-6
@@ -27,7 +27,10 @@ async function goOffline(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||
async function typePin(page: Page, digits: string): Promise<void> {
|
||||
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||
}
|
||||
|
||||
@@ -38,8 +41,10 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
await enterLobby(page);
|
||||
await goOffline(page);
|
||||
|
||||
// A known bag seed so the locked seat deals a full rack deterministically.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||
|
||||
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
@@ -54,14 +59,26 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
await typePin(page, '9999');
|
||||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||
|
||||
// Pick the English variant if the picker offers a choice.
|
||||
const variant = page.locator('.fg select').first();
|
||||
if (await variant.isEnabled()) await variant.selectOption('scrabble_en');
|
||||
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
|
||||
// Two players: Ann (PIN-locked) and Bob (open).
|
||||
await page.locator('.pname').nth(0).fill('Ann');
|
||||
await page.locator('.pname').nth(1).fill('Bob');
|
||||
await page.locator('.prow').nth(0).locator('.plink').click(); // Ann's seat PIN
|
||||
|
||||
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||
// tapping the ❌ removes the row.
|
||||
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||
await expect(page.locator('.prow')).toHaveCount(3);
|
||||
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||
await typePin(page, '9999');
|
||||
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||
await expect(rowDel).toBeVisible();
|
||||
await rowDel.click();
|
||||
await expect(page.locator('.prow')).toHaveCount(2);
|
||||
|
||||
// Lock Ann's seat with a PIN.
|
||||
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||
await typePin(page, '1234');
|
||||
await typePin(page, '1234');
|
||||
|
||||
@@ -79,6 +96,13 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
await typePin(page, '1234');
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||
|
||||
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.fico')).toHaveCount(0);
|
||||
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
|
||||
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||
// whose seat is open, so his rack shows without a lock.
|
||||
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||
// visual viewport instead offsets down toward the focused field.
|
||||
async function installFakeViewport(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||
fake.height = window.innerHeight;
|
||||
fake.offsetTop = 0;
|
||||
fake.width = window.innerWidth;
|
||||
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||
});
|
||||
}
|
||||
|
||||
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
([h, t]) => {
|
||||
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||
vv.height = h;
|
||||
vv.offsetTop = t;
|
||||
vv.dispatchEvent(new Event('resize'));
|
||||
vv.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
[height, offsetTop],
|
||||
);
|
||||
}
|
||||
|
||||
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||
return page.evaluate(() => ({
|
||||
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||
bodyTop: getComputedStyle(document.body).top,
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||
await installFakeViewport(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||
const innerH = await page.evaluate(() => window.innerHeight);
|
||||
|
||||
// Keyboard closed: full height, no offset.
|
||||
await setViewport(page, innerH, 0);
|
||||
let s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
|
||||
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||
// content shows empty space below (the iOS bug this fixes).
|
||||
await setViewport(page, innerH - 300, 180);
|
||||
s = await shell(page);
|
||||
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||
expect(s.top).toBe('180px');
|
||||
expect(s.bodyTop).toBe('180px');
|
||||
|
||||
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||
await setViewport(page, innerH, 0);
|
||||
s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,13 @@ const DIST = 'dist';
|
||||
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
||||
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
||||
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
||||
// countdown toast live in the always-loaded game screen (Game.svelte). The heavy parts — the dict
|
||||
// loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS
|
||||
// lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 115, shared: 30, landing: 5 };
|
||||
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||
// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
|
||||
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
|
||||
// chunk, not this JS budget.
|
||||
const BUDGET = { app: 120, shared: 30, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
+9
-1
@@ -162,7 +162,15 @@ html.app-shell {
|
||||
}
|
||||
html.app-shell body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
|
||||
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
|
||||
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
|
||||
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
|
||||
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
|
||||
top: var(--vv-top, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--vvh, 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
async function commit(): Promise<void> {
|
||||
busy = true;
|
||||
try {
|
||||
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
|
||||
// reads as a deliberate response rather than an instant flicker.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
if (phase === 'old') {
|
||||
if (verify && (await verify(buffer))) {
|
||||
buffer = '';
|
||||
|
||||
@@ -16,18 +16,21 @@
|
||||
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
||||
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
||||
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
||||
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
||||
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
|
||||
// A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
|
||||
// Its hub is Dictionary-only.
|
||||
const chatless = $derived(
|
||||
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
|
||||
);
|
||||
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
||||
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||
// a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||
// deep-link falls back to Chat).
|
||||
// An honest-AI game has only the Dictionary, so start there regardless of the entry route — this
|
||||
// keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would
|
||||
// otherwise raise an error toast in offline mode.
|
||||
// A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
|
||||
// (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
|
||||
// an error toast in offline mode.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab);
|
||||
let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
|
||||
$effect(() => {
|
||||
if (vsAi) tab = 'dictionary';
|
||||
if (chatless) tab = 'dictionary';
|
||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||
});
|
||||
</script>
|
||||
@@ -46,7 +49,7 @@
|
||||
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
{#if !vsAi}
|
||||
{#if !chatless}
|
||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||
</button>
|
||||
|
||||
+32
-10
@@ -20,6 +20,7 @@
|
||||
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';
|
||||
@@ -676,6 +677,10 @@
|
||||
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) {
|
||||
@@ -1102,7 +1107,15 @@
|
||||
if (!view) return '';
|
||||
const me = view.game.seats[view.seat];
|
||||
if (me?.isWinner) return t('game.won');
|
||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||
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
|
||||
@@ -1418,8 +1431,9 @@
|
||||
// (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.
|
||||
if (view?.game.vsAi) return false;
|
||||
// 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 &&
|
||||
@@ -1433,7 +1447,7 @@
|
||||
// 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) return false;
|
||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||
}
|
||||
</script>
|
||||
@@ -1491,7 +1505,7 @@
|
||||
{#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)}>{seatName(s)}</div>
|
||||
<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>
|
||||
@@ -1542,10 +1556,11 @@
|
||||
<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}
|
||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary).
|
||||
A hotseat game has no chat either (local players, no accounts). -->
|
||||
{#if !(gameOver && view.game.vsAi) && !view.game.hotseat}
|
||||
<!-- 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>
|
||||
@@ -1616,7 +1631,10 @@
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<strong class="over">{resultText()}</strong>
|
||||
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||
game keeps the "you won/lost" text (one human). -->
|
||||
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
@@ -1859,6 +1877,10 @@
|
||||
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;
|
||||
|
||||
+43
-11
@@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void {
|
||||
root.classList.toggle('tg-fullscreen', top > 0);
|
||||
}
|
||||
|
||||
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
|
||||
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
|
||||
const KEYBOARD_MIN_PX = 120;
|
||||
|
||||
/**
|
||||
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
||||
* syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
|
||||
* `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
|
||||
* keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
|
||||
* Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
|
||||
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
|
||||
* misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
|
||||
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
|
||||
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
|
||||
*/
|
||||
function syncViewportHeight(): void {
|
||||
let keyboardOpen = false;
|
||||
function syncViewport(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
const vv = win ? win.visualViewport : null;
|
||||
const h = vv ? vv.height : win ? win.innerHeight : 0;
|
||||
const top = vv ? vv.offsetTop : 0;
|
||||
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
||||
document.documentElement.style.setProperty('--vv-top', `${top}px`);
|
||||
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
|
||||
if (open && !keyboardOpen) {
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
||||
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
|
||||
}
|
||||
}
|
||||
keyboardOpen = open;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -795,11 +815,23 @@ export async function bootstrap(): Promise<void> {
|
||||
setLocale(guess);
|
||||
}
|
||||
|
||||
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
||||
syncViewportHeight();
|
||||
// Track the visual viewport (height + offset) so screens fit and follow the visible area above an
|
||||
// open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
|
||||
// the visual viewport.
|
||||
syncViewport();
|
||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
||||
window.visualViewport.addEventListener('resize', syncViewport);
|
||||
window.visualViewport.addEventListener('scroll', syncViewport);
|
||||
}
|
||||
|
||||
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
|
||||
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
|
||||
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
|
||||
// fires first (this listener is on the document, so it runs after the field's own handler).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
|
||||
});
|
||||
}
|
||||
|
||||
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||
|
||||
@@ -46,6 +46,9 @@ export const localSource = {
|
||||
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
||||
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)),
|
||||
hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)),
|
||||
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
|
||||
// cached game before the screen is left needs no result.
|
||||
relock: (id) => void load().then((s) => s.relock(id)),
|
||||
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
||||
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
||||
events: (id, onEvent) => {
|
||||
@@ -60,7 +63,7 @@ export const localSource = {
|
||||
};
|
||||
},
|
||||
} satisfies GameLoopSource &
|
||||
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction'>;
|
||||
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
||||
|
||||
/**
|
||||
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
||||
|
||||
@@ -240,6 +240,10 @@ export class LocalGame {
|
||||
handOf(player: number): number[] {
|
||||
return [...this.hands[player]];
|
||||
}
|
||||
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
|
||||
resignedOf(player: number): boolean {
|
||||
return this.resigned[player] ?? false;
|
||||
}
|
||||
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
||||
get winnerIndex(): number {
|
||||
return winner(this.over, this.reason, this.scores, this.resigned);
|
||||
|
||||
@@ -101,6 +101,19 @@ describe('LocalSource hotseat', () => {
|
||||
expect((st as StateView).game.status).toBe('finished');
|
||||
});
|
||||
|
||||
it('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => {
|
||||
const src = await hotseat('local:hR', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
{ kind: 'human', name: 'C' },
|
||||
], '4321');
|
||||
await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active)
|
||||
const st = await src.gameState('local:hR');
|
||||
expect(st.game.seats[1].resigned).toBe(true);
|
||||
expect(st.game.seats[0].resigned).toBe(false);
|
||||
expect(st.game.seats[2].resigned).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the host terminate the game, deleting it', async () => {
|
||||
const src = await hotseat('local:h6', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
@@ -111,6 +124,19 @@ describe('LocalSource hotseat', () => {
|
||||
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
||||
});
|
||||
|
||||
it('re-locks an unlocked seat on relock (returning to the lobby)', async () => {
|
||||
const src = await hotseat('local:h9', [
|
||||
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||
{ kind: 'human', name: 'Bob' },
|
||||
]);
|
||||
const opened = await src.unlockSeat('local:h9', '1234');
|
||||
expect(opened.locked).toBe(false);
|
||||
src.relock('local:h9');
|
||||
const st = await src.gameState('local:h9');
|
||||
expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN
|
||||
expect(st.rack).toEqual([]);
|
||||
});
|
||||
|
||||
it('verifies the master PIN', async () => {
|
||||
const src = await hotseat('local:h7', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
@@ -120,13 +146,14 @@ describe('LocalSource hotseat', () => {
|
||||
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
|
||||
});
|
||||
|
||||
it('persists a naturally finished game (scoreless run) as finished', async () => {
|
||||
it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => {
|
||||
const src = await hotseat('local:h8', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||
{ kind: 'human', name: 'B', pin: await newLock('2222') },
|
||||
]);
|
||||
for (let i = 0; i < 6; i++) await src.pass('local:h8');
|
||||
const st = await src.gameState('local:h8');
|
||||
expect(st.game.status).toBe('finished');
|
||||
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,14 @@ export class LocalSource implements GameLoopSource {
|
||||
return this.stateView(entry);
|
||||
}
|
||||
|
||||
/** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
|
||||
* seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
|
||||
* not cached, or a seat with no PIN (which is never withheld anyway). */
|
||||
relock(gameId: string): void {
|
||||
const entry = this.live.get(gameId);
|
||||
if (entry) entry.unlockedSeat = null;
|
||||
}
|
||||
|
||||
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
|
||||
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
||||
const entry = await this.load(gameId);
|
||||
@@ -418,7 +426,8 @@ export class LocalSource implements GameLoopSource {
|
||||
private stateView(entry: Live): StateView {
|
||||
const hotseat = !!entry.record.hotseat;
|
||||
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
|
||||
const locked = hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
|
||||
// A finished game is never locked — its final rack is shown for review, not an unlock button.
|
||||
const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
|
||||
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
||||
return {
|
||||
game: this.gameView(entry),
|
||||
@@ -455,6 +464,7 @@ export class LocalSource implements GameLoopSource {
|
||||
score: game.scoreOf(i),
|
||||
hintsUsed: 0,
|
||||
isWinner: game.isOver && i === game.winnerIndex,
|
||||
resigned: game.resignedOf(i),
|
||||
}));
|
||||
return {
|
||||
id: record.id,
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface Seat {
|
||||
score: number;
|
||||
hintsUsed: number;
|
||||
isWinner: boolean;
|
||||
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
|
||||
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
|
||||
resigned?: boolean;
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resultBadge } from './result';
|
||||
import { resultBadge, seatMedal } from './result';
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
|
||||
const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({
|
||||
seat: s,
|
||||
accountId,
|
||||
displayName: accountId,
|
||||
score,
|
||||
hintsUsed: 0,
|
||||
isWinner,
|
||||
resigned,
|
||||
});
|
||||
|
||||
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
@@ -74,4 +75,46 @@ describe('resultBadge', () => {
|
||||
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
|
||||
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||
});
|
||||
|
||||
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
|
||||
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
|
||||
// winner(), so this must NOT read as a full draw for everyone.
|
||||
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
|
||||
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
|
||||
});
|
||||
|
||||
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
|
||||
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('seatMedal', () => {
|
||||
it('is empty for a game still in progress', () => {
|
||||
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
|
||||
});
|
||||
|
||||
it('finished: places each seat by final score', () => {
|
||||
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
|
||||
expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st
|
||||
expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd
|
||||
expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd
|
||||
expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th
|
||||
});
|
||||
|
||||
it('a tie for the lead shares first place; the rest place below (competition ranking)', () => {
|
||||
// The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the
|
||||
// lead — they must SHARE the trophy, not all show the last-place medal (the reported bug).
|
||||
const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]);
|
||||
expect(seatMedal(g, 0)).toBe('🏆');
|
||||
expect(seatMedal(g, 2)).toBe('🏆');
|
||||
expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd
|
||||
});
|
||||
|
||||
it('an excluded / resigned seat gets no medal and does not push the others down', () => {
|
||||
const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]);
|
||||
expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest
|
||||
expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st
|
||||
expect(seatMedal(g, 2)).toBe('🥈');
|
||||
});
|
||||
});
|
||||
|
||||
+41
-9
@@ -9,6 +9,13 @@ export interface ResultBadge {
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/** placeBadge maps a 1-based finishing rank to its label + medal (2nd is a defeat in a duel). */
|
||||
function placeBadge(rank: number, players: number): ResultBadge {
|
||||
if (rank === 2) return players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
||||
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
||||
return { key: 'result.place4', emoji: '🏅' };
|
||||
}
|
||||
|
||||
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
|
||||
@@ -19,14 +26,39 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||
}
|
||||
|
||||
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
|
||||
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
|
||||
|
||||
// Someone else won and it is not me, so I did not win — even when scores are level (a
|
||||
// win by resignation or timeout can leave the winner at or below my score). The winner
|
||||
// takes rank 1; place me among the remaining seats by score, starting at rank 2.
|
||||
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
||||
const rank = 2 + ahead;
|
||||
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
||||
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
||||
return { key: 'result.place4', emoji: '🏅' };
|
||||
// No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level
|
||||
// finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish
|
||||
// for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a
|
||||
// three-way "two share first, one is last" apart from a genuine draw — the reported bug.)
|
||||
if (!game.seats.some((s) => s.isWinner)) {
|
||||
const myScore = me?.score ?? 0;
|
||||
const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length;
|
||||
const allLevel = game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore);
|
||||
if (game.endReason === 'aborted' || (higher === 0 && allLevel)) return { key: 'result.draw', emoji: '🏅' };
|
||||
return higher === 0 ? { key: 'result.victory', emoji: '🏆' } : placeBadge(1 + higher, game.players);
|
||||
}
|
||||
|
||||
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
|
||||
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
|
||||
// (non-resigned) seats by score, starting at rank 2.
|
||||
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
||||
return placeBadge(2 + ahead, game.players);
|
||||
}
|
||||
|
||||
/**
|
||||
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
||||
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
|
||||
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
|
||||
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
|
||||
* than reading as a full draw. A resigned / host-excluded seat places last, with no medal, and does
|
||||
* not push the remaining players down. Ranked by score, not by the engine's single-winner flag
|
||||
* (which is -1 on any tie for the lead — the reported all-last-place bug).
|
||||
*/
|
||||
export function seatMedal(game: GameView, seat: number): string {
|
||||
if (game.status !== 'finished') return '';
|
||||
const s = game.seats.find((x) => x.seat === seat);
|
||||
if (!s || s.resigned) return '';
|
||||
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
|
||||
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSeats, commitName, rosterReady, type RosterRow } from './roster';
|
||||
import { buildSeats, commitName, rosterReady, shuffleSeeded, type RosterRow } from './roster';
|
||||
|
||||
describe('commitName (keep-last-valid)', () => {
|
||||
it('commits a valid, trimmed name', () => {
|
||||
@@ -48,3 +48,21 @@ describe('buildSeats', () => {
|
||||
expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleSeeded', () => {
|
||||
it('is deterministic for the same seed', () => {
|
||||
expect(shuffleSeeded([0, 1, 2, 3], 42n)).toEqual(shuffleSeeded([0, 1, 2, 3], 42n));
|
||||
});
|
||||
|
||||
it('keeps every element (a permutation) and does not mutate the input', () => {
|
||||
const input = [0, 1, 2, 3];
|
||||
const out = shuffleSeeded(input, 7n);
|
||||
expect([...out].sort((a, b) => a - b)).toEqual([0, 1, 2, 3]);
|
||||
expect(input).toEqual([0, 1, 2, 3]); // input untouched
|
||||
});
|
||||
|
||||
it('varies the order across seeds (not always the identity)', () => {
|
||||
const orders = new Set([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n].map((s) => shuffleSeeded([0, 1, 2, 3], s).join('')));
|
||||
expect(orders.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,3 +30,25 @@ export function rosterReady(rows: RosterRow[]): boolean {
|
||||
export function buildSeats(rows: RosterRow[]): Seat[] {
|
||||
return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin }));
|
||||
}
|
||||
|
||||
/**
|
||||
* shuffleSeeded returns a deterministic shuffle of items driven by seed — a Fisher-Yates over a small
|
||||
* in-house PRNG (a distinct seed derivation from the tile bag, so the seating is not correlated with
|
||||
* the tile draws). Used to randomise the hotseat seating at game start, so the first mover is random;
|
||||
* being seed-driven keeps it reproducible for replay and tests.
|
||||
*/
|
||||
export function shuffleSeeded<T>(items: readonly T[], seed: bigint): T[] {
|
||||
const out = [...items];
|
||||
let a = (Number(BigInt.asUintN(32, seed ^ 0x9e3779b9n)) >>> 0) || 1;
|
||||
const rand = (): number => {
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -353,9 +353,12 @@
|
||||
>{/each}</span
|
||||
>
|
||||
</span>
|
||||
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
||||
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. A
|
||||
hotseat game shows no lobby medal — its result lives on the in-game seat
|
||||
plaques, and resultBadge is viewer-centric (no seat matches the account). A
|
||||
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
|
||||
{#key blinkNonce.get(g.id) ?? 0}
|
||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
|
||||
{/key}
|
||||
</button>
|
||||
{#if group.finished || g.hotseat}
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
if (busy || code.trim().length === 0) return;
|
||||
busy = true;
|
||||
await loginEmail(email.trim(), code.trim());
|
||||
busy = false;
|
||||
}
|
||||
|
||||
// Track the code and auto-submit once the 6-digit code is complete (Enter submits too).
|
||||
function onCode(v: string): void {
|
||||
code = v;
|
||||
if (code.trim().length === 6) void signIn();
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="login">
|
||||
@@ -46,10 +53,16 @@
|
||||
{:else}
|
||||
<p class="muted">{t('login.codeSent', { email })}</p>
|
||||
<input
|
||||
class="codein"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
placeholder={t('login.codePlaceholder')}
|
||||
bind:value={code}
|
||||
value={code}
|
||||
oninput={(e) => onCode(e.currentTarget.value)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') void signIn();
|
||||
}}
|
||||
/>
|
||||
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
|
||||
{t('login.signIn')}
|
||||
@@ -98,6 +111,11 @@
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* The email code: the same spread-digit look as the friend-code input elsewhere. */
|
||||
.codein {
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
button {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
+117
-76
@@ -13,7 +13,7 @@
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { validDisplayName } from '../lib/profileValidation';
|
||||
import { verifyPin, type PinLock } from '../lib/pin';
|
||||
import { commitName, rosterReady, buildSeats, type RosterRow } from '../lib/roster';
|
||||
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster';
|
||||
import type { AccountRef, Variant } from '../lib/model';
|
||||
import {
|
||||
availableVariants,
|
||||
@@ -167,6 +167,10 @@
|
||||
| { kind: 'seat-change'; index: number }
|
||||
| { kind: 'row-delete'; index: number };
|
||||
let pad = $state<PadTarget | null>(null);
|
||||
// The row whose delete cross is armed: tapping a row's kebab asks the host PIN, and a correct PIN
|
||||
// arms its ❌ (mirroring the lobby delete) rather than removing it silently. Tapping the kebab again
|
||||
// (or arming another row) clears the authorisation.
|
||||
let deleteRow = $state<number | null>(null);
|
||||
|
||||
const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
|
||||
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change';
|
||||
@@ -202,11 +206,32 @@
|
||||
else if (r.kind === 'removed') rows[target.index].pin = undefined;
|
||||
break;
|
||||
case 'row-delete':
|
||||
if (r.kind === 'verified') rows = rows.filter((_, j) => j !== target.index);
|
||||
if (r.kind === 'verified') deleteRow = target.index; // arm the ❌; the actual delete is a tap on it
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// onKebab toggles a row's delete authorisation: a second tap (already armed) closes it and resets
|
||||
// the host authorisation; otherwise it asks the host PIN (which, on success, arms the ❌).
|
||||
function onKebab(i: number): void {
|
||||
if (deleteRow === i) {
|
||||
deleteRow = null;
|
||||
return;
|
||||
}
|
||||
deleteRow = null; // only one row armed at a time
|
||||
pad = { kind: 'row-delete', index: i };
|
||||
}
|
||||
|
||||
function removeRow(i: number): void {
|
||||
rows = rows.filter((_, j) => j !== i);
|
||||
deleteRow = null;
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
rows = [...rows, { name: '', committed: '' }];
|
||||
deleteRow = null;
|
||||
}
|
||||
|
||||
function setHostPlays(yes: boolean): void {
|
||||
askHostPlays = false;
|
||||
if (!yes) return;
|
||||
@@ -230,16 +255,19 @@
|
||||
return;
|
||||
}
|
||||
const id = newLocalGameId();
|
||||
const seed = randomSeed();
|
||||
// Randomise the seating so the FIRST mover is random (the engine starts at seat 0, so shuffling
|
||||
// the seats picks a random starter and turn order). Seed-driven, so replay is consistent.
|
||||
// Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy
|
||||
// fails structured-clone when the record is persisted to IndexedDB (silently, since the store
|
||||
// is best-effort) — so the game would vanish on reload. See lib/localgame/store.
|
||||
// fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store)
|
||||
// — so the game would vanish on reload. See lib/localgame/store.
|
||||
await localSource.create({
|
||||
id,
|
||||
variant: inviteVariant,
|
||||
dictVersion: version,
|
||||
seed: randomSeed(),
|
||||
seed,
|
||||
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
||||
seats: $state.snapshot(buildSeats(rows)),
|
||||
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
|
||||
hotseat: true,
|
||||
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
||||
});
|
||||
@@ -297,7 +325,6 @@
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||
<button
|
||||
@@ -308,65 +335,75 @@
|
||||
{:else if offlineMode.active}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||
<div class="fg">
|
||||
<div class="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>{t('new.gameType')}</span>
|
||||
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
|
||||
<option value="" disabled>—</option>
|
||||
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="roster">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="prow">
|
||||
<input
|
||||
class="pname"
|
||||
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
||||
bind:value={row.name}
|
||||
disabled={!hostPin}
|
||||
placeholder={t('hotseat.playerName')}
|
||||
maxlength="40"
|
||||
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
||||
onblur={() => (row.name = row.committed)}
|
||||
/>
|
||||
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
||||
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
{#if rows.length > 2}
|
||||
<button
|
||||
class="pkebab"
|
||||
disabled={!hostPin}
|
||||
aria-label={t('hotseat.removePlayer')}
|
||||
onclick={() => (pad = { kind: 'row-delete', index: i })}
|
||||
>⋮</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rows.length < 4}
|
||||
<button class="addrow" disabled={!hostPin} onclick={() => (rows = [...rows, { name: '', committed: '' }])}>
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
onclick={startHotseat}
|
||||
>{t('new.start')}</button>
|
||||
<div class="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
||||
<span class="vmain">
|
||||
<span class="vname">{t(v.label)}</span>
|
||||
{#if VARIANT_FLAG[v.id]}
|
||||
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
||||
{:else}
|
||||
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="roster">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="prow">
|
||||
<input
|
||||
class="pname"
|
||||
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
||||
bind:value={row.name}
|
||||
disabled={!hostPin}
|
||||
placeholder={t('hotseat.playerName')}
|
||||
maxlength="40"
|
||||
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
||||
onblur={() => (row.name = row.committed)}
|
||||
/>
|
||||
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
||||
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
{#if rows.length > 2}
|
||||
{#if deleteRow === i}
|
||||
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}>❌</button>
|
||||
{/if}
|
||||
<button
|
||||
class="pkebab"
|
||||
class:armed={deleteRow === i}
|
||||
disabled={!hostPin}
|
||||
aria-label={t('hotseat.removePlayer')}
|
||||
onclick={() => onKebab(i)}
|
||||
>⋮</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rows.length < 4}
|
||||
<button class="addrow" disabled={!hostPin} onclick={addRow}>
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
onclick={startHotseat}
|
||||
>{t('new.start')}</button>
|
||||
{:else if friends.length === 0}
|
||||
<p class="subtitle">{t('new.noFriends')}</p>
|
||||
{:else}
|
||||
@@ -437,12 +474,14 @@
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
/* Natural-flow content (matching the Profile sub-view): no forced height and no pinned CTA, so the
|
||||
screen's own scroll (Screen .content + --vvh) handles overflow and a focused input scrolls into
|
||||
view above the soft keyboard with a single, clean scroll container. */
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.subtitle {
|
||||
@@ -527,8 +566,6 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.fg {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -551,9 +588,6 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.friends-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -614,11 +648,6 @@
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
|
||||
mirroring the friend-game invite button pinned by the .fg scroll area. */
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.invite {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px;
|
||||
@@ -696,6 +725,18 @@
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.pkebab.armed {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* The delete cross a correct host PIN arms next to a player row (mirrors the lobby game delete). */
|
||||
.prow-del {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.addrow {
|
||||
align-self: flex-start;
|
||||
padding: 8px 12px;
|
||||
|
||||
+13
-5
@@ -24,17 +24,19 @@ clientsClaim();
|
||||
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
|
||||
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh
|
||||
// (no-cache) shell from the server — it references the new hashed assets, which are then fetched
|
||||
// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a
|
||||
// short timeout). This keeps the app up to date online while still cold-launching offline. Only the
|
||||
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute above) and
|
||||
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute below) and
|
||||
// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the
|
||||
// admin console so those are never resolved to the app shell.
|
||||
//
|
||||
// This route MUST be registered BEFORE precacheAndRoute: Workbox matches routes in registration
|
||||
// order, and the precache route matches shell navigations too (its directoryIndex is index.html), so
|
||||
// registering it first would shadow this handler and serve the shell CACHE-FIRST — i.e. the old
|
||||
// build's version until the next load. NavigationRoute matches only request.mode === 'navigate', so
|
||||
// hashed-asset requests still fall through to the cache-first precache route below.
|
||||
const NAV_TIMEOUT_MS = 3000;
|
||||
async function freshShell({ request }: { request: Request }): Promise<Response> {
|
||||
const ctrl = new AbortController();
|
||||
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
|
||||
return (await matchPrecache('index.html')) ?? Response.error();
|
||||
}
|
||||
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] }));
|
||||
|
||||
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
|
||||
// needs (the precached index.html is also the offline fallback matchPrecache serves above). The
|
||||
// immutable hashed assets stay cache-first here; the landing page and the old-engine polyfill bundle
|
||||
// are excluded via the build globs.
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
Reference in New Issue
Block a user