Compare commits

..

32 Commits

Author SHA1 Message Date
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer ca48b3e18e Merge pull request 'feat(offline): local pass-and-play (hotseat) — 2-4 players, seat/host PINs' (#211) from feature/offline-hotseat into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-07 14:27:57 +00:00
Ilia Denisov a7f483792f feat(offline): randomise the hotseat seating (random first mover)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The 2-4 hotseat players now start in a random seating order (so who goes
first, and the turn order, is random) — matching the online games. The
engine starts at seat 0, so shuffling the seats at creation picks the
starter; the shuffle is seed-driven (a distinct derivation from the tile
bag), so replay reproduces it. Scoped to hotseat — vs_ai stays human-first.
- lib/roster.ts: shuffleSeeded (unit-tested); NewGame shuffles buildSeats
  with the game seed before create.
- e2e: pin a seed that keeps the roster order so the lock assertions stay
  deterministic.
2026-07-07 16:20:53 +02:00
Ilia Denisov 80cc2a76e8 fix(ui): unify tie-for-lead result across lobby + game (shared victory, not a draw)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Bring the online lobby badge and the in-game 'you won/lost' text to the
same competition ranking the hotseat seat medals use: in a 3-4 player game
a TIE for the lead is a SHARED victory for the top scorers and a placed
finish for those below — not a full draw for everyone (winner() is -1 on
any tie, so isWinner alone conflated them). An aborted game and a genuine
all-level finish stay a draw; a win by resignation (winner at a not-higher
score) is unchanged.
- result.ts: resultBadge no-winner branch ranks by final score; placeBadge
  helper; resigned seats excluded from the ranking.
- Game.svelte: resultText aligned (shared lead = won, below = lost).
2026-07-07 15:47:34 +02:00
Ilia Denisov 1e087be90a fix(offline): hotseat finished-game medals by final score (fix all-last-place on a tie)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Reported: a hotseat game ended by 6 scoreless passes deducts each rack
(correct rule, same as online: -8/-14/-8 here), which tied two seats for
the lead. The engine's winner() returns -1 on a tie, so seatMedal read it
as a full draw and gave EVERY seat the last-place medal.

- result.ts: seatMedal now ranks by the FINAL score (competition ranking —
  a tie for the lead shares the trophy), not the single-winner flag. A
  resigned / host-excluded seat places last with no medal and does not push
  the others down.
- model.ts: Seat.resigned (offline-only); engine.resignedOf getter; the
  local source surfaces it on the game view.
- The scoreless rack deduction is unchanged (standard rules, owner-confirmed).
2026-07-07 15:37:25 +02:00
Ilia Denisov 84d0385c95 fix(offline): scope the medal treatment to hotseat only
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Per owner: a vs_ai game (one human) keeps its 'you won/lost' status text
AND its lobby medal — the hidden-status + per-seat-medal treatment applies
only to hotseat, where 2-4 local players make a single 'you' meaningless.
isLocalGameId -> game.hotseat at the three call sites (statusBlock, seat
plaque, lobby card).
2026-07-07 15:00:59 +02:00
Ilia Denisov 0ed34c7720 feat(offline): per-seat medals in local games; drop the you-won text + lobby medal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
For a local (offline) game the outcome is not always about a single
'you' (hotseat is 2-4 players), so:
- Game.svelte: a finished local game no longer shows the viewer-centric
  'you won/lost/draw' status; instead each seat plaque shows a per-seat
  place medal, left of the name (result.seatMedal — trophy for the winner,
  then places by score; a draw medals everyone).
- Lobby.svelte: a local game shows no lobby medal (resultBadge is
  viewer-centric and no seat matches the account) — its result lives on
  the in-game plaques.
- result.ts: seatMedal(game, seat), unit-tested.
2026-07-07 14:54:44 +02:00
Ilia Denisov 52d0c559f9 fix(ui): local-game history (hide social, keep dictionary); Enter dismisses keyboard; email-code autosubmit
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
- History drawer in a local (offline) game: the seat plaques no longer
  show the add-friend/block controls — canAddFriend/canBlock now exclude a
  local game (hotseat seats have synthetic account ids that slipped past
  the vs_ai-only guard) — and the Dictionary entry is restored: an active
  hotseat game keeps the comms button, and CommsHub is Dictionary-only for
  a chatless vs_ai OR hotseat game (ChatScreen never mounts offline).
- Enter on any single-line <input> now dismisses the soft keyboard (blur);
  a <textarea> (feedback) keeps Enter for newlines. One global handler.
- Login email code: the friend-code spread-digit style (.codein), a
  6-char cap, auto-submit on the 6th digit, and Enter to submit.
- e2e: the local-game history has no social controls and keeps the
  dictionary entry.
2026-07-07 14:45:53 +02:00
Ilia Denisov beda6ccd3d fix(ui): iOS soft-keyboard shell alignment + hotseat relock on return
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
iOS Safari/WKWebView does not shrink the layout viewport for the soft
keyboard (Android Chrome does): the visual viewport shrinks AND offsets
down toward the focused field, and the values do not cleanly revert. The
pinned shell tracked only the height (--vvh), not the offset, so its
top-anchored content misaligned on iOS — empty space below the form,
worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback):
- app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into
  --vv-top, and scroll the focused field into view on the keyboard-open
  transition (iOS does not reliably scroll a pinned document to it).
- app.css: the pinned app-shell body follows top=--vv-top + height=--vvh
  (was inset:0), so it stays on the visible area.
- e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake
  window.visualViewport) to verify the shell follows, on Chromium+WebKit.

Hotseat: returning to the lobby now re-locks the current seat (its PIN is
re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy.
Root-caused a crash it exposed: a $props() value (id) reads back undefined
during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy
(breaking navigation) — read the id from the loaded view instead.
2026-07-07 14:10:57 +02:00
Ilia Denisov c1ac77bc6a fix(offline): unify NewGame layout to natural-flow + PinPad verdict pause
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
- NewGame: all three forms (quick / friends / hotseat) now use the
  Profile sub-view's natural-flow .page (no forced height, no pinned-CTA
  .grow/.fg scroll), so the screen's single .content scroll + --vvh handle
  overflow and a focused name input scrolls into view cleanly — fixes the
  keyboard blank-space / deep-scroll regression. One layout rule, one place.
- PinPad: pause 250ms after the 4th digit before the verdict, so the fill
  (and the shake on a wrong PIN) reads as a deliberate response.
- e2e: typePin waits for empty dots before typing (robust to the pause).
2026-07-07 13:19:22 +02:00
Ilia Denisov cafe67e9c8 fix(offline): hotseat form — natural scroll + focus-into-view (keyboard)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m38s
The nested scroll region regressed the keyboard behaviour (a double
scroll: the whole screen scrolled with a huge blank area under the Start
button, and a focused bottom row hid behind the keyboard). Drop it: the
hotseat form now flows naturally and scrolls with the screen's own scroll
(.page.scrollform: no forced full height, no pinned button), and a name
input scrolls itself into view (centred above the keyboard) on focus.
2026-07-07 12:54:57 +02:00
Ilia Denisov 3adc4e32c9 fix(offline): hotseat review fixes + PWA stale-shell (SW route order)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Owner review of the pass-and-play PR:
- Roster delete (bug): a correct host PIN now ARMS a delete cross next to
  the row (mirroring the lobby delete) instead of removing it silently;
  tapping the kebab again clears the host authorisation.
- Variant picker (UX): replace the dropdown with the Quick-Match variant
  plaques + the multiple-words toggle, one-to-one.
- Keyboard layout (UX): the roster sits in a scroll region with the Start
  button pinned (friends-form pattern), so a name input's soft keyboard
  shrinks the region instead of leaving a full-height blank spacer painted
  behind the keyboard.
- e2e: variant via plaque + a row-delete (arm then cross) regression step.

PWA stale-version (pre-existing, same PR):
- sw.ts: register the network-first NavigationRoute BEFORE precacheAndRoute.
  Workbox matches in registration order and the precache route (directoryIndex
  index.html) shadowed the shell navigation, serving it cache-first — the old
  build's __APP_VERSION__ until a second load. Fixes both the maintenance
  self-reload and a cold open after a deploy. Doc updated (ARCHITECTURE).
2026-07-07 12:35:33 +02:00
Ilia Denisov 903de7d41d fix(offline): unlock finished hotseat games + raise entry budget to 120
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m47s
- source: a finished hotseat game is never locked (its final rack is
  shown for review, not an Unlock button); guard locked on !isOver.
- bundle-size: raise the app-entry budget 115 -> 120 KB for the hotseat
  UI (PIN pad, roster, host menu), which lives in the always-loaded New
  Game / Game / Lobby screens; the offline engine + PIN hashing stay lazy.
2026-07-07 12:04:46 +02:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
22 changed files with 547 additions and 146 deletions
+9 -4
View File
@@ -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 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 **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 **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 no network. Shell **navigations are network-first** the fresh `no-cache` shell is fetched from the
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded, server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed 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 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 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 an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
+30 -6
View File
@@ -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). // 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> { 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(); 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 enterLobby(page);
await goOffline(page); await goOffline(page);
// A known bag seed so the locked seat deals a full rack deterministically. // A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); // 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). // New game -> the offline mode selector now offers "with friends" (hotseat).
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
@@ -54,14 +59,26 @@ test.describe('offline hotseat (pass-and-play)', () => {
await typePin(page, '9999'); await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
// Pick the English variant if the picker offers a choice. // Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
const variant = page.locator('.fg select').first(); await page.locator('.variant', { hasText: 'Scrabble' }).click();
if (await variant.isEnabled()) await variant.selectOption('scrabble_en');
// Two players: Ann (PIN-locked) and Bob (open). // Two players: Ann (PIN-locked) and Bob (open).
await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob'); 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');
await typePin(page, '1234'); await typePin(page, '1234');
@@ -79,6 +96,13 @@ test.describe('offline hotseat (pass-and-play)', () => {
await typePin(page, '1234'); await typePin(page, '1234');
await expect(page.locator('.rack .tile')).toHaveCount(7); 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, // 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. // whose seat is open, so his rack shows without a lock.
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click(); await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
+69
View File
@@ -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');
});
});
+7 -4
View File
@@ -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 // 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 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 // 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 // countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
// loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS // pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
// lands in the CSS chunk, not this JS budget. // live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
const BUDGET = { app: 115, shared: 30, landing: 5 }; // 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 // 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. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+9 -1
View File
@@ -162,7 +162,15 @@ html.app-shell {
} }
html.app-shell body { html.app-shell body {
position: fixed; 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; overflow: hidden;
} }
+3
View File
@@ -72,6 +72,9 @@
async function commit(): Promise<void> { async function commit(): Promise<void> {
busy = true; busy = true;
try { 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 (phase === 'old') {
if (verify && (await verify(buffer))) { if (verify && (await verify(buffer))) {
buffer = ''; buffer = '';
+12 -9
View File
@@ -16,18 +16,21 @@
// The game is rendered (and cached) before its comms open, so the cache tells us whether // 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. // it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// An honest-AI game has no chat at all, so its hub is Dictionary-only. // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); // 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: // 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). // deep-link falls back to Chat).
// An honest-AI game has only the Dictionary, so start there regardless of the entry route — this // A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
// keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would // (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
// otherwise raise an error toast in offline mode. // an error toast in offline mode.
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab); let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
$effect(() => { $effect(() => {
if (vsAi) tab = 'dictionary'; if (chatless) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat'; else if (tab === 'dictionary' && !active) tab = 'chat';
}); });
</script> </script>
@@ -46,7 +49,7 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
{#if !vsAi} {#if !chatless}
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> <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> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button> </button>
+32 -10
View File
@@ -20,6 +20,7 @@
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board'; import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { seatMedal } from '../lib/result';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
@@ -676,6 +677,10 @@
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
} }
localUnsub?.(); 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) { function onCell(row: number, col: number) {
@@ -1102,7 +1107,15 @@
if (!view) return ''; if (!view) return '';
const me = view.game.seats[view.seat]; const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won'); 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 // 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 // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player. // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
if (view?.game.vsAi) return false; // 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 ( return (
!!s.accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
@@ -1433,7 +1447,7 @@
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion. // 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). // An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean { 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); return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
} }
</script> </script>
@@ -1491,7 +1505,7 @@
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if} {#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)} {#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="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]}> <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} {#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div> </div>
@@ -1542,10 +1556,11 @@
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/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 <!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
A hotseat game has no chat either (local players, no accounts). --> — so drop the entry only when a local game is finished; an active local game keeps it for
{#if !(gameOver && view.game.vsAi) && !view.game.hotseat} 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')}> <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} {#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button> </button>
@@ -1616,7 +1631,10 @@
<div class="status"> <div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span> <span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver} {#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} {: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> <span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if} {/if}
@@ -1859,6 +1877,10 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
.medal {
margin-right: 3px;
}
.sc { .sc {
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
+43 -11
View File
@@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void {
root.classList.toggle('tg-fullscreen', top > 0); 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 * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
* without the page scrolling, so the layout no longer jumps when the keyboard appears. * 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; if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null; const win = typeof window !== 'undefined' ? window : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; 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`); 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); setLocale(guess);
} }
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). // Track the visual viewport (height + offset) so screens fit and follow the visible area above an
syncViewportHeight(); // 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) { if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight); window.visualViewport.addEventListener('resize', syncViewport);
window.visualViewport.addEventListener('scroll', syncViewportHeight); 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 // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
+4 -1
View File
@@ -46,6 +46,9 @@ export const localSource = {
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)), unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(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)), 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 // 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. // the real subscription once the engine loads and, until then, cancels a pending subscribe.
events: (id, onEvent) => { events: (id, onEvent) => {
@@ -60,7 +63,7 @@ export const localSource = {
}; };
}, },
} satisfies GameLoopSource & } 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 * gameSource returns the source that runs the game with the given id: the local engine for a local
+4
View File
@@ -240,6 +240,10 @@ export class LocalGame {
handOf(player: number): number[] { handOf(player: number): number[] {
return [...this.hands[player]]; 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). */ /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
get winnerIndex(): number { get winnerIndex(): number {
return winner(this.over, this.reason, this.scores, this.resigned); return winner(this.over, this.reason, this.scores, this.resigned);
+30 -3
View File
@@ -101,6 +101,19 @@ describe('LocalSource hotseat', () => {
expect((st as StateView).game.status).toBe('finished'); 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 () => { it('lets the host terminate the game, deleting it', async () => {
const src = await hotseat('local:h6', [ const src = await hotseat('local:h6', [
{ kind: 'human', name: 'A' }, { kind: 'human', name: 'A' },
@@ -111,6 +124,19 @@ describe('LocalSource hotseat', () => {
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' }); 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 () => { it('verifies the master PIN', async () => {
const src = await hotseat('local:h7', [ const src = await hotseat('local:h7', [
{ kind: 'human', name: 'A' }, { kind: 'human', name: 'A' },
@@ -120,13 +146,14 @@ describe('LocalSource hotseat', () => {
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false); 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', [ const src = await hotseat('local:h8', [
{ kind: 'human', name: 'A' }, { kind: 'human', name: 'A', pin: await newLock('1111') },
{ kind: 'human', name: 'B' }, { kind: 'human', name: 'B', pin: await newLock('2222') },
]); ]);
for (let i = 0; i < 6; i++) await src.pass('local:h8'); for (let i = 0; i < 6; i++) await src.pass('local:h8');
const st = await src.gameState('local:h8'); const st = await src.gameState('local:h8');
expect(st.game.status).toBe('finished'); expect(st.game.status).toBe('finished');
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
}); });
}); });
+11 -1
View File
@@ -214,6 +214,14 @@ export class LocalSource implements GameLoopSource {
return this.stateView(entry); 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. */ /** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
async verifyHostPin(gameId: string, pin: string): Promise<boolean> { async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
const entry = await this.load(gameId); const entry = await this.load(gameId);
@@ -418,7 +426,8 @@ export class LocalSource implements GameLoopSource {
private stateView(entry: Live): StateView { private stateView(entry: Live): StateView {
const hotseat = !!entry.record.hotseat; const hotseat = !!entry.record.hotseat;
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry); 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)); const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
return { return {
game: this.gameView(entry), game: this.gameView(entry),
@@ -455,6 +464,7 @@ export class LocalSource implements GameLoopSource {
score: game.scoreOf(i), score: game.scoreOf(i),
hintsUsed: 0, hintsUsed: 0,
isWinner: game.isOver && i === game.winnerIndex, isWinner: game.isOver && i === game.winnerIndex,
resigned: game.resignedOf(i),
})); }));
return { return {
id: record.id, id: record.id,
+3
View File
@@ -29,6 +29,9 @@ export interface Seat {
score: number; score: number;
hintsUsed: number; hintsUsed: number;
isWinner: boolean; 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 { export interface GameView {
+45 -2
View File
@@ -1,14 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resultBadge } from './result'; import { resultBadge, seatMedal } from './result';
import type { GameView, Seat } from './model'; 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, seat: s,
accountId, accountId,
displayName: accountId, displayName: accountId,
score, score,
hintsUsed: 0, hintsUsed: 0,
isWinner, isWinner,
resigned,
}); });
function game(seats: Seat[], status = 'finished', toMove = 0): GameView { 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)]); 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: '🥈' }); 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
View File
@@ -9,6 +9,13 @@ export interface ResultBadge {
emoji: string; 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 { export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId); 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 (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 // No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level
// win by resignation or timeout can leave the winner at or below my score). The winner // finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish
// takes rank 1; place me among the remaining seats by score, starting at rank 2. // for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length; // three-way "two share first, one is last" apart from a genuine draw — the reported bug.)
const rank = 2 + ahead; if (!game.seats.some((s) => s.isWinner)) {
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' }; const myScore = me?.score ?? 0;
if (rank === 3) return { key: 'result.place3', emoji: '🥉' }; const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length;
return { key: 'result.place4', emoji: '🏅' }; 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 ? '🥉' : '🏅';
} }
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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)', () => { describe('commitName (keep-last-valid)', () => {
it('commits a valid, trimmed name', () => { it('commits a valid, trimmed name', () => {
@@ -48,3 +48,21 @@ describe('buildSeats', () => {
expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true); 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);
});
});
+22
View File
@@ -30,3 +30,25 @@ export function rosterReady(rows: RosterRow[]): boolean {
export function buildSeats(rows: RosterRow[]): Seat[] { export function buildSeats(rows: RosterRow[]): Seat[] {
return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin })); 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;
}
+5 -2
View File
@@ -353,9 +353,12 @@
>{/each}</span >{/each}</span
> >
</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} {#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} {/key}
</button> </button>
{#if group.finished || g.hotseat} {#if group.finished || g.hotseat}
+19 -1
View File
@@ -17,10 +17,17 @@
} }
async function signIn() { async function signIn() {
if (busy || code.trim().length === 0) return;
busy = true; busy = true;
await loginEmail(email.trim(), code.trim()); await loginEmail(email.trim(), code.trim());
busy = false; 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> </script>
<main class="login"> <main class="login">
@@ -46,10 +53,16 @@
{:else} {:else}
<p class="muted">{t('login.codeSent', { email })}</p> <p class="muted">{t('login.codeSent', { email })}</p>
<input <input
class="codein"
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
maxlength="6"
placeholder={t('login.codePlaceholder')} 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}> <button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
{t('login.signIn')} {t('login.signIn')}
@@ -98,6 +111,11 @@
color: var(--text); color: var(--text);
font-size: 1rem; 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 { button {
padding: 12px; padding: 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
+117 -76
View File
@@ -13,7 +13,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { validDisplayName } from '../lib/profileValidation'; import { validDisplayName } from '../lib/profileValidation';
import { verifyPin, type PinLock } from '../lib/pin'; 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 type { AccountRef, Variant } from '../lib/model';
import { import {
availableVariants, availableVariants,
@@ -167,6 +167,10 @@
| { kind: 'seat-change'; index: number } | { kind: 'seat-change'; index: number }
| { kind: 'row-delete'; index: number }; | { kind: 'row-delete'; index: number };
let pad = $state<PadTarget | null>(null); 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' => const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? '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; else if (r.kind === 'removed') rows[target.index].pin = undefined;
break; break;
case 'row-delete': 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; 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 { function setHostPlays(yes: boolean): void {
askHostPlays = false; askHostPlays = false;
if (!yes) return; if (!yes) return;
@@ -230,16 +255,19 @@
return; return;
} }
const id = newLocalGameId(); 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 // 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 // fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store)
// is best-effort) — so the game would vanish on reload. See lib/localgame/store. // — so the game would vanish on reload. See lib/localgame/store.
await localSource.create({ await localSource.create({
id, id,
variant: inviteVariant, variant: inviteVariant,
dictVersion: version, dictVersion: version,
seed: randomSeed(), seed,
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords), multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
seats: $state.snapshot(buildSeats(rows)), seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
hotseat: true, hotseat: true,
hostPin: hostPin ? $state.snapshot(hostPin) : undefined, hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
}); });
@@ -297,7 +325,6 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <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} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
@@ -308,65 +335,75 @@
{:else if offlineMode.active} {:else if offlineMode.active}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master <!-- 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. --> PIN first (it gates the roster); each seat may add its own PIN. -->
<div class="fg"> <div class="hostpin">
<div class="hostpin"> <span class="ftitle">{t('hotseat.hostPin')}</span>
<span class="ftitle">{t('hotseat.hostPin')}</span> <button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}> {hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')} </button>
</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> </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} {:else if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p> <p class="subtitle">{t('new.noFriends')}</p>
{:else} {:else}
@@ -437,12 +474,14 @@
</Screen> </Screen>
<style> <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 { .page {
padding: var(--pad); padding: var(--pad);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.subtitle { .subtitle {
@@ -527,8 +566,6 @@
border-color: var(--accent); border-color: var(--accent);
} }
.fg { .fg {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -551,9 +588,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.friends-scroll { .friends-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -614,11 +648,6 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; 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 { .invite {
flex: 0 0 auto; flex: 0 0 auto;
padding: 14px; padding: 14px;
@@ -696,6 +725,18 @@
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1; 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 { .addrow {
align-self: flex-start; align-self: flex-start;
padding: 8px 12px; padding: 8px 12px;
+13 -5
View File
@@ -24,17 +24,19 @@ clientsClaim();
// Drop precaches left by a previous SW revision (including the old install-only shell cache). // Drop precaches left by a previous SW revision (including the old install-only shell cache).
cleanupOutdatedCaches(); 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 // 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 // (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 // 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 // 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 // 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. // 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; const NAV_TIMEOUT_MS = 3000;
async function freshShell({ request }: { request: Request }): Promise<Response> { async function freshShell({ request }: { request: Request }): Promise<Response> {
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
return (await matchPrecache('index.html')) ?? Response.error(); return (await matchPrecache('index.html')) ?? Response.error();
} }
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] })); 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);