Compare commits

...

45 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
Ilia Denisov baa9961c3e docs(offline): document hotseat pass-and-play
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) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
ARCHITECTURE (offline section) + FUNCTIONAL (+_ru mirror): the local
pass-and-play mode — host/referee master PIN, per-seat optional PIN
locks (social lock, salted SHA-256, client-only), the board-visible seat
unlock, host overrides (skip/exclude/terminate), no hints/chat, and the
master-PIN-gated lobby deletion.
2026-07-07 11:56:40 +02:00
Ilia Denisov 66278e34b7 test(offline): e2e hotseat flow + fix $state-proxy persist
Add the Playwright mock e2e for offline pass-and-play (Chromium+WebKit):
create a 2-seat hotseat with a PIN-locked seat, verify the rack is
withheld, unlock (wrong then right PIN), host-skip the current turn, and
terminate from the lobby behind the master PIN.

The e2e surfaced a persistence bug: the roster PINs are $state proxies,
which fail structured-clone on the IndexedDB put (silently — best-effort
store), so a created hotseat game vanished on reload. Snapshot the seats
+ host PIN to plain objects at the create() call site.
2026-07-07 11:53:50 +02:00
Ilia Denisov 452a686e07 feat(offline): hotseat lobby delete behind the master PIN
Lobby.svelte: hotseat games show the kebab / swipe delete in BOTH the
active and finished groups (not finished-only); deleting one opens a
master-PIN pad first — active = terminate (no result), finished = remove
— so the last mover cannot instantly wipe a game. Local games stay
deletable offline. Non-hotseat games are unchanged (finished-only, free).
2026-07-07 11:43:02 +02:00
Ilia Denisov 4ddc690c38 feat(offline): hotseat in-game seat lock + host menu
Game.svelte drives a hotseat game:
- the seat-to-move's rack is replaced by an Unlock button when the seat
  is PIN-locked (board stays visible); canMove gates the move controls
  on the unlock; PinPad(verify) -> source.unlockSeat reveals it.
- the hint slot becomes a host button (hints off in hotseat), always
  enabled while the game runs (acts on a locked seat too). Master-PIN ->
  action sheet: skip current / exclude a player / end the game, each with
  a confirm + a fading check; terminate deletes and returns to the lobby.
- per-turn advance re-fetches the next seat's (locked) state; self-resign
  and chat are hidden (hotseat resign is a host action, no comms).
- gamesource: expose unlockSeat / verifyHostPin / hostAction on the local
  proxy. i18n hotseat.* (en + ru).
2026-07-07 11:39:43 +02:00
Ilia Denisov 6216359c6f feat(offline): hotseat creation roster + host-participate flow
NewGame offline 'with friends' now builds a local pass-and-play game:
- lib/roster.ts: keep-last-valid name + roster->seats (unit-tested).
- NewGame.svelte: master host-PIN gate (rows disabled until set),
  'are you playing too?' prompt seating the host at row 0, 2-4 player
  rows with inline name validation + optional per-seat PIN, add/remove
  (remove behind the master PIN), variant + multiple-words, Start.
- PinPad: verification via a verify(pin) callback (UI-held lock at
  creation, source-held in-game) instead of a lock prop.
- i18n: hotseat.* (en + ru); the offline mode selector is now shown offline.
2026-07-07 11:28:17 +02:00
Ilia Denisov 8c67d679d9 feat(offline): hotseat record schema + source (locks, host actions)
Extend the local game to offline pass-and-play (hotseat):
- serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old
  vs_ai records read hotseat as false).
- source.ts: create() takes hotseat/hostPin + per-seat pins; stateView
  reveals the seat-to-move's rack and withholds it (locked) when that
  seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock
  re-locks on advance; new unlockSeat / verifyHostPin / hostAction
  (skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag.
- model.ts: GameView.hotseat, StateView.locked (offline-only, optional).

Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate,
master-PIN gate, natural-end persistence) + serialize hotseat shape.
2026-07-07 11:20:39 +02:00
Ilia Denisov 69673e7727 feat(offline): PIN lock primitives + Apple-style keypad
Groundwork for offline pass-and-play (hotseat) seat/host PIN locks:
- lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin);
  a social lock for a shared device (documented, not cryptography).
- components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit
  on the 4th digit, shake on mismatch, hardware-keyboard support.
- i18n: pin.* keys (en + ru).

Engine/source/UI wiring follows.
2026-07-07 11:11:13 +02:00
developer d7f3d93c6c Merge pull request 'fix(router): sync route in navigate() — kill boot lobby-flash + offline e2e flake' (#210) from fix/boot-route-lag-e2e-flake into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 23:51:21 +00:00
Ilia Denisov 16cd3d0411 fix(router): update route rune synchronously in navigate()
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 1m4s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
navigate() wrote location.hash and left router.route to the asynchronous
hashchange event. bootstrap flips app.ready in the same tick right after
navigate('/login') on an unauthenticated cold start, so for one frame the app
rendered with app.ready=true and the stale route ('lobby', from the empty hash)
— briefly mounting the lobby shell (tab bar + a doomed games.list) under the new
login screen before hashchange settled it.

Update router.route synchronously inside navigate(); the later hashchange
re-parses to the same value. This removes the boot lobby-flash (and its spurious
games.list 401) and, as the root cause, the offline e2e flake: enterLobby could
latch that transient lobby tab-bar instead of clicking through login, then the
Settings-tab click at line 44 fought the tab detaching into the login slide
(~7% of runs, both engines).

Also:
- offline.spec enterLobby: wait for the guest button and click it, instead of a
  point-in-time count() that a pre-login splash frame sampled as 0 (skipping the
  click and hanging / latching the transient).
- New e2e router.spec pins the synchronous-route property (RED on the old code,
  which returned the previous route; GREEN now) via a mock-only __router seam.

Verify: check 0 / unit 490 / build / bundle 114.3/115 / full e2e 200 / the
offline+router stress at 40x on both engines 160/160 (was 6/80 failing).
2026-07-07 01:44:16 +02:00
developer ef8a32bb82 Merge pull request 'feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)' (#209) from feature/online-vsai-hint into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 23:04:38 +00:00
Ilia Denisov 53311cbc95 fix(hint): arm the vs_ai idle-gate from the warm cache so the lock shows without a delay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Entering a vs_ai game from the lobby armed the idle-hint countdown only in load() (after
the gameState round-trip), so the lock popped in after a visible delay. Arm it on the
instant warm-cache render in onMount too — the preloadGames-warmed StateView carries
hint_unlock_left_seconds; load() then refreshes the snapshot. A first move (0 seconds
left) stays open.
2026-07-07 00:59:06 +02:00
Ilia Denisov 7fc1301b31 feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and
wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served
online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic
error toast. Now online vs_ai uses the SAME idle-gate model as offline.

Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used
(owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock --
it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else
serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed
seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper
hintUnlockLeftSeconds unit-tested.

Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway
transcode carry it (round-trip test).

Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the
source's seconds-left when it lands (on load from the view; to the full window when the
robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh
value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the
hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the
wire/model/delta (the offline record keeps the absolute for persistence; the view exposes
seconds-left).

Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode
tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing
webkit offline flake) / app entry 114.2/115.
2026-07-07 00:42:43 +02:00
developer ee7ff06403 Merge pull request 'feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)' (#207) from feature/offline-hint-gate 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 1m11s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 21:46:18 +00: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
49 changed files with 2235 additions and 244 deletions
+23
View File
@@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) {
}
}
func TestHintUnlockLeftSeconds(t *testing.T) {
now := time.Now()
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
cases := []struct {
name string
g Game
seat int
want int
}{
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
{"not the caller's turn is open", gated, 1, 0},
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
}
for _, c := range cases {
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
}
}
}
func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed")
+47 -4
View File
@@ -1058,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
const hintIdleWindow = 30 * time.Minute
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
return 0
}
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
if left <= 0 {
return 0
}
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
@@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled
}
if pre.VsAI {
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
// touching the allowance, the wallet or hints_used.
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
return HintResult{}, ErrHintLocked
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
return HintResult{Move: move}, nil
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return HintResult{}, err
@@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil
}
+9
View File
@@ -65,6 +65,10 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
// been stuck the idle window since the robot's last move). The client normally pre-gates from
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
ErrHintLocked = errors.New("game: hint is not available yet")
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
// simultaneous quick games and tries to create another game (quick or by invitation).
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
@@ -240,6 +244,11 @@ type StateView struct {
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
HintUnlockLeftSeconds int
}
// HistoryMove is one decoded journal row, independent of any dictionary.
+17 -13
View File
@@ -163,13 +163,16 @@ type alphabetEntryDTO struct {
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
type stateDTO struct {
Game gameDTO `json:"game"`
Seat int `json:"seat"`
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
Game gameDTO `json:"game"`
Seat int `json:"seat"`
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
// game / first move / not your turn). The client anchors a monotonic countdown to it.
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
}
// matchDTO reports whether the caller has been paired into a game.
@@ -315,12 +318,13 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
return stateDTO{}, err
}
dto := stateDTO{
Game: gameDTOFromGame(v.Game),
Seat: v.Seat,
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
Game: gameDTOFromGame(v.Game),
Seat: v.Seat,
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
+3
View File
@@ -239,6 +239,9 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "no_hint_available"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
return http.StatusConflict, "hint_unavailable"
case errors.Is(err, game.ErrHintLocked):
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
return http.StatusConflict, "hint_locked"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
+32 -12
View File
@@ -1242,23 +1242,43 @@ 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
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled
or hidden. A local `vs_ai` hint is unlimited and wallet-free but idle-gated (unlocked ~30 min into a
stuck turn). The gate is a **persisted wall-clock unlock instant** (`hintUnlockAtMs` on the record +
the game view, stamped from the robot's reply, carried on the move delta), so the wait survives a
relaunch — but it is **sanitised on read** (capped at `now + window`, `lib/hints` + `source.ts`) so a
device clock the player sets **back** cannot push the unlock away and freeze the gate; a clock set
**forward** merely opens the hint early, harmless for a solo game. An online `vs_ai` game will gate the
same way but from the server's clock (a follow-up). To have data ready before the switch, the **Profile advertises the current dictionary
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
hint (online and offline alike) is unlimited and wallet-free but idle-gated
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
and by the offline source from the device clock (persisted, capped at the window). The **client
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
version per variant** (`dict_versions`,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
+21 -7
View File
@@ -220,11 +220,11 @@ personal hint wallet once the per-game allowance is spent. Against the robot
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
on a turn** (timed from the robot's last move; the very first move, before the robot
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
tap shows how long remains; the lock lifts live at the mark. The wait **persists across
leaving and reopening the app**, so a stuck turn is not forgotten. It stays robust to a
device clock change: the remaining is capped at the window, so a clock set back cannot
freeze it, and a clock set forward merely opens the hint early — harmless in a solo game.
The game ends when the
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
counts toward no hint statistic. The game ends when the
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
24 hours): a missed turn auto-resigns, except while the player is inside their
@@ -269,8 +269,8 @@ statistics.
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *with friends* and *random opponent* options in New Game).
Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
@@ -280,6 +280,20 @@ and waits briefly, and if they cannot be readied it stays online with a short *n
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
+22 -6
View File
@@ -227,10 +227,11 @@ e-mail) либо ввод фразы. Активные игры форфейтя
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Отсчёт **переживает выход и повторный вход в
приложение**, так что застрявший ход не забывается. И он устойчив к переводу часов: остаток
ограничен окном, поэтому перевод назад не заморозит гейт, а перевод вперёд просто откроет
подсказку раньше — безвредно в соло-игре. Партия
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
одной статистике подсказок. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
@@ -273,8 +274,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
@@ -284,6 +285,21 @@ e-mail) либо ввод фразы. Активные игры форфейтя
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
+8 -7
View File
@@ -181,13 +181,14 @@ type AlphabetEntryJSON struct {
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
// Alphabet is present only when the request asked for it.
type StateResp struct {
Game GameResp `json:"game"`
Seat int `json:"seat"`
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
Game GameResp `json:"game"`
Seat int `json:"seat"`
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
}
// MatchResp reports an auto-match outcome.
+8 -7
View File
@@ -299,13 +299,14 @@ func toWireState(s backendclient.StateResp) wire.StateView {
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
}
return wire.StateView{
Game: toWireGame(s.Game),
Seat: s.Seat,
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet,
Game: toWireGame(s.Game),
Seat: s.Seat,
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
Alphabet: alphabet,
}
}
+3 -3
View File
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
})
defer cleanup()
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
t.Fatalf("handler: %v", err)
}
st := fb.GetRootAsStateView(payload, 0)
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
}
game := st.Game(nil)
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
+7
View File
@@ -316,6 +316,13 @@ table StateView {
// the wallet is a single global figure the client keeps live across games (added trailing —
// backward-compatible).
wallet_balance:int;
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
// yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a
// client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing
// (additive, backward-compatible).
hint_unlock_left_seconds:int;
}
// GameActionRequest carries just a game id (pass / resign / hint / history).
+16 -1
View File
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
return rcv._tab.MutateInt32Slot(16, n)
}
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
return rcv._tab.MutateInt32Slot(18, n)
}
func StateViewStart(builder *flatbuffers.Builder) {
builder.StartObject(7)
builder.StartObject(8)
}
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
@@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
builder.PrependInt32Slot(6, walletBalance, 0)
}
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
}
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+9 -7
View File
@@ -87,13 +87,14 @@ type AlphabetEntry struct {
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
// recipient may not have cached the variant's display table yet.
type StateView struct {
Game GameView
Seat int
Rack []int
BagLen int
HintsRemaining int
WalletBalance int
Alphabet []AlphabetEntry
Game GameView
Seat int
Rack []int
BagLen int
HintsRemaining int
WalletBalance int
HintUnlockLeftSeconds int
Alphabet []AlphabetEntry
}
// AccountRef is a referenced account with its display name resolved.
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
fb.StateViewAddBagLen(b, int32(s.BagLen))
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
if hasAlphabet {
fb.StateViewAddAlphabet(b, alphabet)
}
+126
View File
@@ -0,0 +1,126 @@
import { test, expect, type Page } from './fixtures';
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
// Settings offline toggle shows.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q: string) =>
(q.includes('display-mode: standalone')
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
: orig(q)) as MediaQueryList;
});
}
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
async function goOffline(page: Page): Promise<void> {
await page.locator('button.tab').nth(2).click(); // Settings
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// 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();
}
test.describe('offline hotseat (pass-and-play)', () => {
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
await goOffline(page);
// 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();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
// The player rows are disabled until the mandatory host (master) PIN is set.
await expect(page.locator('.pname').first()).toBeDisabled();
// Set the host PIN (enter + confirm), then decline taking a seat.
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
// 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');
// 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 page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
await expect(page.locator('.unlock')).toBeVisible();
await expect(page.locator('.rack .tile')).toHaveCount(0);
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
await page.locator('.unlock').click();
await typePin(page, '0000');
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
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();
await typePin(page, '9999');
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
await expect(page.locator('.unlock')).toHaveCount(0);
await expect(page.locator('.rack .tile')).toHaveCount(7);
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap')).toHaveCount(1);
await page.locator('.kebab').first().click();
const del = page.locator('.rowwrap.revealed .del');
await expect(del).toBeVisible();
await del.click();
await typePin(page, '9999');
await expect(page.locator('.rowwrap')).toHaveCount(0);
});
});
+5 -3
View File
@@ -13,10 +13,12 @@ async function forceStandalone(page: Page): Promise<void> {
});
}
// After a load or reload, land in the lobby (dismiss the login screen if it is shown).
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
async function enterLobby(page: Page): Promise<void> {
const guest = page.getByRole('button', { name: /guest|гост/i });
if (await guest.count()) await guest.first().click();
await page.getByRole('button', { name: /guest|гост/i }).first().click();
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
// onboarding strips after it completes).
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from './fixtures';
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
// latch that transient lobby tab-bar instead of clicking through the login screen.
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
await page.goto('/');
// The bundle has loaded and installed the mock __router seam once the login screen is up.
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
// the pre-fix code returned the previous route (it waited for the async hashchange).
const routeAfterNavigate = await page.evaluate(() => {
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
r.navigate('/settings');
return r.route();
});
expect(routeAfterNavigate).toBe('settings');
// The hash was written too, so a reload/back still resolves the same route.
expect(new URL(page.url()).hash).toBe('#/settings');
});
+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
// 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
View File
@@ -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;
}
+268
View File
@@ -0,0 +1,268 @@
<script lang="ts">
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
// Modes:
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
// or shakes + clears on a wrong PIN.
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
// storage is a social lock, not cryptography — see lib/pin.ts.
import Modal from './Modal.svelte';
import { t } from '../lib/i18n/index.svelte';
import { newLock, type PinLock } from '../lib/pin';
/** PinResult is the outcome handed to onresult when the pad completes. */
export type PinResult =
| { kind: 'set'; lock: PinLock }
| { kind: 'verified' }
| { kind: 'removed' };
let {
mode,
title = '',
verify,
allowRemove = false,
onclose,
onresult,
}: {
mode: 'set' | 'verify' | 'change';
title?: string;
/** Verifies an entered PIN (required for 'verify' and 'change'). */
verify?: (pin: string) => Promise<boolean>;
allowRemove?: boolean;
onclose?: () => void;
onresult: (r: PinResult) => void;
} = $props();
const LEN = 4;
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
// initial value here is intentional.
// svelte-ignore state_referenced_locally
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
let buffer = $state('');
let first = $state(''); // the first entry, awaiting confirmation
let error = $state(false); // drives the error prompt + shake
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
let busy = $state(false); // guards the async check between the 4th digit and the outcome
function promptText(): string {
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
switch (phase) {
case 'old':
return t('pin.enterCurrent');
case 'confirm':
return t('pin.repeat');
default:
return mode === 'verify' ? t('pin.enter') : t('pin.create');
}
}
function fail(): void {
buffer = '';
error = true;
shakeSeq += 1;
}
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 = '';
error = false;
phase = 'menu';
} else fail();
} else if (phase === 'enter' && mode === 'verify') {
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
else fail();
} else if (phase === 'enter') {
first = buffer;
buffer = '';
error = false;
phase = 'confirm';
} else if (phase === 'confirm') {
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
else {
first = '';
phase = 'enter';
fail();
}
}
} finally {
busy = false;
}
}
function press(d: string): void {
if (busy || phase === 'menu' || buffer.length >= LEN) return;
error = false;
buffer += d;
if (buffer.length === LEN) void commit();
}
function back(): void {
if (busy || phase === 'menu') return;
buffer = buffer.slice(0, -1);
}
function startNew(): void {
error = false;
first = '';
buffer = '';
phase = 'enter';
}
function onkey(e: KeyboardEvent): void {
if (e.key >= '0' && e.key <= '9') {
press(e.key);
e.preventDefault();
} else if (e.key === 'Backspace') {
back();
e.preventDefault();
} else if (e.key === 'Escape') {
onclose?.();
}
}
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
const slots = Array.from({ length: LEN }, (_, i) => i);
</script>
<svelte:window onkeydown={onkey} />
<Modal {title} {onclose} overlayKeyboard>
<div class="pad">
{#if phase === 'menu'}
<div class="menu">
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
{#if allowRemove}
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
{t('pin.remove')}
</button>
{/if}
</div>
{:else}
<p class="prompt" class:err={error}>{promptText()}</p>
{#key shakeSeq}
<div class="dots" class:shake={error} aria-hidden="true">
{#each slots as i}
<span class="dot" class:on={i < buffer.length}></span>
{/each}
</div>
{/key}
<div class="grid">
{#each keys as k}
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
{/each}
<span class="key spacer"></span>
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
</div>
{/if}
</div>
</Modal>
<style>
.pad {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding-top: 4px;
}
.prompt {
margin: 0;
font-size: 0.95rem;
color: var(--muted, var(--text));
min-height: 1.2em;
}
.prompt.err {
color: var(--danger);
}
.dots {
display: flex;
gap: 16px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid var(--text);
box-sizing: border-box;
}
.dot.on {
background: var(--text);
}
.shake {
animation: shake 0.32s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-7px); }
40% { transform: translateX(7px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.key {
width: 62px;
height: 62px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1.4rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.key:active {
background: var(--accent);
color: var(--accent-text);
}
.key.back {
border-color: transparent;
background: transparent;
font-size: 1.2rem;
}
.key.back:disabled {
opacity: 0.3;
}
.key.spacer {
border: none;
background: none;
pointer-events: none;
}
.menu {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.opt {
padding: 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
font-weight: 600;
}
.opt.danger {
border-color: var(--danger);
color: var(--danger);
}
</style>
+12 -9
View File
@@ -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>
+316 -38
View File
@@ -4,6 +4,7 @@
import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import DictWarmup from '../components/DictWarmup.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
@@ -19,11 +20,12 @@
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread';
import { seatMedal } from '../lib/result';
import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from '../lib/hints';
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
@@ -163,24 +165,40 @@
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
// While locked the board stays visible but the rack is withheld and the move controls disabled;
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
const locked = $derived(!!view?.locked);
const canMove = $derived(isMyTurn && !locked);
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
// hint was spent in another game (see lib/hints).
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
// unlocks only after the player has been stuck ~30 min on a turn). The unlock is a persisted
// wall-clock instant carried on the game view (`hintUnlockAtMs`, stamped by the source off the
// robot's reply and kept fresh by the move delta), so the wait survives leaving and reopening the
// app. It is sanitised on read (source + lib/hints cap it at now + the window) so a device clock
// set BACK cannot freeze the gate; a clock set forward just opens the hint early, harmless for a
// solo game. `now` ticks so the 🔒 lifts live at the mark. 0 = open (the human's first move).
let now = $state(Date.now());
const hintUnlockAt = $derived(view?.game.vsAi ? (view.hintUnlockAtMs ?? 0) : 0);
const hintRemaining = $derived(hintGateRemainingMs(hintUnlockAt, now));
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
let gateLeftMs = $state(0);
let monoNow = $state(performance.now());
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
const hintGated = $derived(hintRemaining > 0);
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
// load from the view, and on the robot's move to the full window (the robot just moved).
function armHintGate(leftSeconds: number): void {
if (leftSeconds > 0) {
hintGateStart = performance.now();
gateLeftMs = leftSeconds * 1000;
} else {
hintGateStart = null;
gateLeftMs = 0;
}
}
$effect(() => {
if (!view?.game.vsAi) return;
const iv = setInterval(() => (now = Date.now()), 10_000);
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
return () => clearInterval(iv);
});
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
@@ -225,6 +243,8 @@
source.draftGet(id).catch(() => ''),
]);
view = st;
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
@@ -277,6 +297,9 @@
if (cached) {
view = cached.view;
moves = cached.moves;
// Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a
// warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below.
armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0);
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
applyDraft(cached.view, cached.draft ?? '');
@@ -345,7 +368,11 @@
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch.
if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs }));
else {
applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
// The robot just moved (vs_ai) → the human's turn begins with the full idle window.
if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000);
}
} else if (e.kind === 'your_turn' && e.gameId === id) {
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
// only if we missed the move (our cached count trails the event's).
@@ -650,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) {
@@ -754,8 +785,8 @@
// rapid placements do not pile up requests on a slow link.
evalCtrl?.abort();
evalCtrl = null;
// Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return;
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
if (!canMove) return;
const sub = toSubmit(placement);
if (!sub) return;
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
@@ -813,12 +844,106 @@
refreshRecent();
}
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
let hostPinEntered = $state('');
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
let hostDone = $state(false); // the transient success ✅ shown after an override
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
function hotseatName(i: number): string {
return view?.game.seats[i]?.displayName ?? '';
}
function startSkip(): void {
if (!view) return;
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
}
function flashDone(): void {
hostDone = true;
setTimeout(() => (hostDone = false), 1100);
}
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
async function advanceHotseat(): Promise<void> {
const st = await source.gameState(id, false);
view = st;
rackIds = st.rack.map((_, i) => i);
placement = newPlacement(st.rack);
selected = null;
preview = null;
setCachedGame(id, st, moves, '');
}
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
// and applies the unlocked state; handed to the PIN pad as its verifier.
async function onUnlock(pin: string): Promise<boolean> {
try {
const st = await localSource.unlockSeat(id, pin);
view = st;
rackIds = st.rack.map((_, i) => i);
placement = newPlacement(st.rack);
selected = null;
return true;
} catch {
return false;
}
}
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
async function verifyHost(pin: string): Promise<boolean> {
hostPinEntered = pin;
return localSource.verifyHostPin(id, pin);
}
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
return t('hotseat.askTerminate');
}
async function runHostAction(): Promise<void> {
if (!hostConfirm) return;
const { action, seat } = hostConfirm;
hostConfirm = null;
hostMenuStep = 'closed';
busy = true;
try {
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
if (st === null) {
navigate('/'); // terminated: the game is deleted, so return to the lobby
return;
}
view = st;
rackIds = st.rack.map((_, i) => i);
placement = newPlacement(st.rack);
selected = null;
preview = null;
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
setCachedGame(id, st, moves, '');
flashDone();
} catch (e) {
handleError(e);
} finally {
busy = false;
hostPinEntered = '';
}
}
async function commit() {
const sub = toSubmit(placement);
if (!sub) return;
busy = true;
try {
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
haptic('success');
zoomed = false;
} catch (e) {
@@ -838,6 +963,7 @@
busy = true;
try {
applyMoveResult(await source.pass(id));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) {
handleError(e);
} finally {
@@ -873,7 +999,7 @@
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
if (view?.game.vsAi) {
const remaining = hintGateRemainingMs(hintUnlockAt, Date.now());
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
if (remaining > 0) {
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
return;
@@ -915,6 +1041,12 @@
// The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info');
} else if (e instanceof GatewayError && e.code === 'hint_locked') {
// The server-clock backstop refused a vs_ai hint the client thought was open (a rare client
// clock desync): re-sync the countdown from a fresh fetch and explain the gate.
await load();
const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), 'info');
} else {
handleError(e);
}
@@ -953,6 +1085,7 @@
busy = true;
try {
applyMoveResult(await source.exchange(id, tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) {
handleError(e);
} finally {
@@ -974,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
@@ -1290,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 &&
@@ -1305,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>
@@ -1363,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>
@@ -1407,13 +1549,18 @@
{:else}
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else if view.game.hotseat}
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
<span aria-hidden="true"></span>
{:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<!-- 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). -->
{#if !(gameOver && view.game.vsAi)}
<!-- 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>
@@ -1484,9 +1631,12 @@
<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">{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}
<span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
@@ -1500,28 +1650,40 @@
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
<div class="rack-wrap">
<Rack
slots={rackSlots}
{variant}
{selected}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
{#if locked}
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
above stays visible; only this tray is gated. -->
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
{:else}
<Rack
slots={rackSlots}
{variant}
{selected}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
{/if}
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
{#if view?.game.vsAi}
{#if view?.game.hotseat}
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
(even on a locked seat: the host may skip a player who forgot their PIN). -->
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
</button>
{:else if view?.game.vsAi}
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
@@ -1597,6 +1759,55 @@
</Modal>
{/if}
<!-- Offline hotseat: unlock the current seat's rack. -->
{#if unlockOpen && view}
<PinPad
mode="verify"
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
verify={onUnlock}
onclose={() => (unlockOpen = false)}
onresult={() => (unlockOpen = false)}
/>
{/if}
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
{#if hostMenuStep === 'pin'}
<PinPad
mode="verify"
title={t('hotseat.host')}
verify={verifyHost}
onclose={() => (hostMenuStep = 'closed')}
onresult={() => (hostMenuStep = 'menu')}
/>
{/if}
{#if hostMenuStep === 'menu' && view}
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
{#if hostConfirm}
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
</div>
{:else}
<div class="host-menu">
<button class="host-act" onclick={startSkip}>
{t('hotseat.skip')}
</button>
<div class="host-sub">{t('hotseat.exclude')}</div>
{#each view.game.seats as s (s.seat)}
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
{s.displayName}
</button>
{/each}
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
</div>
{/if}
</Modal>
{/if}
{#if hostDone}
<div class="host-done" aria-hidden="true"></div>
{/if}
{#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts">
@@ -1666,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;
@@ -2096,4 +2311,67 @@
font-size: 0.62rem;
line-height: 1;
}
/* --- offline hotseat --- */
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
stays visible. */
.unlock {
width: 100%;
height: 100%;
min-height: 44px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.host-menu {
display: flex;
flex-direction: column;
gap: 8px;
}
.host-act,
.host-seat {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-weight: 600;
text-align: left;
}
.host-act.danger {
border-color: var(--danger);
color: var(--danger);
}
.host-sub {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
.host-q {
margin: 0 0 12px;
font-weight: 600;
}
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
.host-done {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
z-index: 50;
pointer-events: none;
animation: host-done-fade 1.1s ease forwards;
}
@keyframes host-done-fade {
0% { opacity: 0; transform: scale(0.7); }
25% { opacity: 1; transform: scale(1); }
75% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1); }
}
</style>
+12 -2
View File
@@ -74,8 +74,13 @@ walletBalance():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintUnlockLeftSeconds():number {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startStateView(builder:flatbuffers.Builder) {
builder.startObject(7);
builder.startObject(8);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
@@ -130,12 +135,16 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
builder.addFieldInt32(6, walletBalance, 0);
}
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
}
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset {
StateView.startStateView(builder);
StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat);
@@ -144,6 +153,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
StateView.addHintsRemaining(builder, hintsRemaining);
StateView.addAlphabet(builder, alphabetOffset);
StateView.addWalletBalance(builder, walletBalance);
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
return StateView.endStateView(builder);
}
}
+50 -11
View File
@@ -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
@@ -1310,4 +1342,11 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
closeStream();
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
};
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
// render the previous route under the new screen (the empty-hash lobby flashing under login).
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
navigate,
route: () => router.route.name,
};
}
+1
View File
@@ -461,6 +461,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
};
}
+2 -10
View File
@@ -11,9 +11,6 @@ export interface MoveDelta {
move?: MoveRecord;
game?: GameView;
bagLen: number;
/** For a vs_ai game, the refreshed idle-hint unlock instant (the robot's reply re-arms the gate),
* so the cached view stays current without a refetch; absent leaves the prior value. */
hintUnlockAtMs?: number;
}
/**
@@ -53,12 +50,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De
if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed
// The actor's own move changed their rack (a draw), which opponent_moved does not carry.
if (d.move.player === cached.view.seat) return { refetch: true };
const view: StateView = {
...cached.view,
game: d.game,
bagLen: d.bagLen,
hintUnlockAtMs: d.hintUnlockAtMs ?? cached.view.hintUnlockAtMs,
};
const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen };
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
}
@@ -87,7 +79,7 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u
*/
export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined {
if (e.kind === 'opponent_moved') {
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs }).cache;
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
}
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
+10 -1
View File
@@ -41,6 +41,14 @@ export const localSource = {
create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)),
// Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host
// (referee) overrides gated by the master PIN.
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) => {
@@ -54,7 +62,8 @@ export const localSource = {
unsub();
};
},
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list' | 'delete'>;
} satisfies GameLoopSource &
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
+9 -15
View File
@@ -31,25 +31,19 @@ describe('hintsLeft', () => {
});
});
describe('hintGateRemainingMs (the vs_ai idle-hint gate)', () => {
it('is 0 when the gate is open — no unlock time (the human first move, or a non-gated game)', () => {
expect(hintGateRemainingMs(undefined, 5000, 1000)).toBe(0);
expect(hintGateRemainingMs(0, 5000, 1000)).toBe(0);
describe('hintGateRemainingMs (the vs_ai idle-hint gate, monotonic)', () => {
it('is 0 when the gate is open — a null anchor (the human first move, or a non-gated game)', () => {
expect(hintGateRemainingMs(null, 60_000, 5000)).toBe(0);
});
it('is 0 once the unlock instant has passed (the gate is open)', () => {
expect(hintGateRemainingMs(1000, 1000, 1000)).toBe(0);
expect(hintGateRemainingMs(1000, 1500, 1000)).toBe(0);
it('is the window remaining minus the monotonic time elapsed since the anchor', () => {
// anchored at mono 1000 with 60s left; 20s of monotonic time later -> 40s left.
expect(hintGateRemainingMs(1000, 60_000, 1000 + 20_000)).toBe(40_000);
});
it('is the wall-clock time remaining while still gated', () => {
expect(hintGateRemainingMs(1000, 400, 1000)).toBe(600);
});
it('clamps to the window, so a clock set back cannot freeze the gate above the window', () => {
// now shoved far into the past (device clock moved back): the raw remaining would exceed the
// window, but the cap keeps it at the window so the wait stays bounded and self-heals.
expect(hintGateRemainingMs(1000, -100_000, 1000)).toBe(1000);
it('is 0 once the elapsed monotonic time reaches (or passes) the window', () => {
expect(hintGateRemainingMs(1000, 60_000, 1000 + 60_000)).toBe(0);
expect(hintGateRemainingMs(1000, 60_000, 1000 + 90_000)).toBe(0);
});
});
+10 -16
View File
@@ -30,23 +30,17 @@ export const HINT_GATE_MS = 30 * 60 * 1000;
/**
* hintGateRemainingMs returns how long (in milliseconds) until a vs_ai game's idle hint unlocks 0
* once it is available. hintUnlockAtMs is the wall-clock instant the hint opens (the robot's last
* move plus the idle window), persisted so the wait survives leaving and reopening the app; 0 or
* undefined means the gate is open (the human's first move, or a non-gated game).
*
* The result is CLAMPED to the window. A wall-clock timestamp is the only way to carry idle time
* across an app relaunch, but a device clock the player changes (or an auto-sync) could push the
* unlock far into the future and freeze the gate; capping the remaining at the window means the wait
* is never longer than intended and self-heals (the caller re-reads a sanitised value on load). A
* clock moved forward simply opens the hint early harmless for this solo anti-frustration aid.
* once it is available. It counts down from a MONOTONIC clock (performance.now()), so a client clock
* change cannot skew it: gateStartMono is performance.now() captured when the source's "seconds left"
* was received, gateLeftMs is that seconds-left in ms, and monoNow is performance.now() now; the
* remaining is gateLeftMs minus the monotonic time elapsed since the anchor. gateStartMono null means
* the gate is open (the human's first move, or a non-gated game). The duration comes from the source
* (the SERVER clock online, the device clock offline) and is re-fetched on every load, so the wait
* both persists across a relaunch and stays immune to a live clock change.
*/
export function hintGateRemainingMs(
hintUnlockAtMs: number | undefined,
nowMs: number,
gateMs: number = HINT_GATE_MS,
): number {
if (!hintUnlockAtMs) return 0;
return Math.min(gateMs, Math.max(0, hintUnlockAtMs - nowMs));
export function hintGateRemainingMs(gateStartMono: number | null, gateLeftMs: number, monoNow: number): number {
if (gateStartMono === null) return 0;
return Math.max(0, gateLeftMs - (monoNow - gateStartMono));
}
/** hintLockMinutes rounds a remaining-milliseconds gap up to whole minutes for the "available in N
+29
View File
@@ -389,6 +389,35 @@ export const en = {
'error.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.',
'pin.enter': 'Enter PIN',
'pin.create': 'Create a PIN',
'pin.repeat': 'Repeat the PIN',
'pin.enterCurrent': 'Enter current PIN',
'pin.mismatch': "PINs don't match",
'pin.wrong': 'Wrong PIN',
'pin.setNew': 'Set a new PIN',
'pin.remove': 'Remove PIN',
'hotseat.hostPin': 'Host password',
'hotseat.setPin': 'Password',
'hotseat.changePin': 'Change',
'hotseat.playerName': 'Player name',
'hotseat.addPlayer': 'Add player',
'hotseat.removePlayer': 'Remove player',
'hotseat.hostPlaysTitle': 'Are you playing too?',
'hotseat.hostPlaysYes': 'Yes, I play',
'hotseat.hostPlaysNo': 'No',
'hotseat.unlock': 'Unlock',
'hotseat.unlockTitle': 'Unlock: {name}',
'hotseat.turnOf': 'Turn: {name}',
'hotseat.host': 'Host',
'hotseat.skip': 'Skip the current turn',
'hotseat.exclude': 'Exclude a player:',
'hotseat.terminate': 'End the game',
'hotseat.askSkip': "Skip {name}'s turn?",
'hotseat.askExclude': 'Remove {name} from the game?',
'hotseat.askTerminate': 'End the game with no result?',
} as const;
export type MessageKey = keyof typeof en;
+29
View File
@@ -389,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
'error.game_active': 'Доступно только после завершения игры.',
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
'error.already_confirmed': 'Эта почта уже подтверждена.',
'pin.enter': 'Введите PIN',
'pin.create': 'Придумайте PIN',
'pin.repeat': 'Повторите PIN',
'pin.enterCurrent': 'Введите текущий PIN',
'pin.mismatch': 'PIN не совпадает',
'pin.wrong': 'Неверный PIN',
'pin.setNew': 'Задать новый PIN',
'pin.remove': 'Удалить пароль',
'hotseat.hostPin': 'Пароль ведущего',
'hotseat.setPin': 'Пароль',
'hotseat.changePin': 'Изменить',
'hotseat.playerName': 'Имя игрока',
'hotseat.addPlayer': 'Добавить игрока',
'hotseat.removePlayer': 'Удалить игрока',
'hotseat.hostPlaysTitle': 'Принимаете участие в игре?',
'hotseat.hostPlaysYes': 'Да, играю',
'hotseat.hostPlaysNo': 'Нет',
'hotseat.unlock': 'Разблокировать',
'hotseat.unlockTitle': 'Разблокировать: {name}',
'hotseat.turnOf': 'Ход: {name}',
'hotseat.host': 'Ведущий',
'hotseat.skip': 'Пропустить ход',
'hotseat.exclude': 'Исключить игрока:',
'hotseat.terminate': 'Отменить партию',
'hotseat.askSkip': 'Пропустить ход игрока {name}?',
'hotseat.askExclude': 'Удалить {name} из игры?',
'hotseat.askTerminate': 'Отменить партию без результата?',
};
+4
View File
@@ -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);
+16
View File
@@ -83,4 +83,20 @@ describe('local game serialize + replay', () => {
expect(rec.journal.length).toBe(g.moveCount);
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
});
it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => {
const { newLock } = await import('../pin');
const hostPin = await newLock('9999');
const seatPin = await newLock('1234');
const hotseatSeats: Seat[] = [
{ kind: 'human', name: 'Ann', pin: seatPin },
{ kind: 'human', name: 'Bob' },
];
const g = makeGame(5n);
const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 });
expect(rec.hotseat).toBe(true);
expect(rec.hostPin).toEqual(hostPin);
expect(rec.seats[0].pin).toEqual(seatPin);
expect(rec.seats[1].pin).toBeUndefined();
});
});
+14
View File
@@ -12,6 +12,7 @@
import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
import type { Dawg } from '../dict/dawg';
import type { Variant } from '../model';
import type { PinLock } from '../pin';
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
export interface Seat {
@@ -20,6 +21,9 @@ export interface Seat {
/** The player's real account id for a human seat, so the client identifies "you" and the correct
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
accountId?: string;
/** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden
* until the PIN is entered (a social lock for a shared device see lib/pin). */
pin?: PinLock;
}
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
@@ -36,6 +40,12 @@ export interface LocalGameRecord {
/** The dictionary-independent, alphabet-index-space move journal. */
journal: LocalMove[];
status: 'active' | 'finished';
/** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on
* older records predating the feature), which the source reads as false. */
hotseat?: boolean;
/** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a
* seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */
hostPin?: PinLock;
createdAtUnix: number;
updatedAtUnix: number;
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
@@ -49,6 +59,8 @@ export interface LocalGameRecord {
export interface RecordMeta {
id: string;
seats: Seat[];
hotseat?: boolean;
hostPin?: PinLock;
createdAtUnix: number;
updatedAtUnix: number;
hintUnlockAtMs: number;
@@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seats: meta.seats,
journal: game.history,
status: game.isOver ? 'finished' : 'active',
hotseat: meta.hotseat,
hostPin: meta.hostPin,
createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs,
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi } from 'vitest';
import type { StateView } from '../model';
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
// so games live in the source's in-memory cache — created once, then driven without reload).
vi.mock('../dict', () => ({
getDawg: async () => {
const { Dawg } = await import('../dict/dawg');
const { readFileSync } = await import('node:fs');
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
},
}));
import { LocalSource } from './source';
import type { Seat } from './serialize';
import { newLock } from '../pin';
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
const src = new LocalSource();
await src.create({
id,
variant: 'scrabble_en',
dictVersion: 'sample',
seed: 3n,
multipleWords: true,
seats,
hotseat: true,
hostPin: await newLock(hostDigits),
});
return src;
}
describe('LocalSource hotseat', () => {
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
const src = await hotseat('local:h1', [
{ kind: 'human', name: 'Ann' },
{ kind: 'human', name: 'Bob' },
]);
const st = await src.gameState('local:h1');
expect(st.game.vsAi).toBe(false);
expect(st.game.hotseat).toBe(true);
expect(st.game.toMove).toBe(0);
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
expect(st.rack.length).toBe(7);
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
});
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
const src = await hotseat('local:h2', [
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
{ kind: 'human', name: 'Bob' },
]);
const st = await src.gameState('local:h2');
expect(st.locked).toBe(true);
expect(st.rack).toEqual([]);
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
const opened = await src.unlockSeat('local:h2', '1234');
expect(opened.locked).toBe(false);
expect(opened.rack.length).toBe(7);
});
it('re-locks the seat when the turn advances and again when it comes back', async () => {
const src = await hotseat('local:h3', [
{ kind: 'human', name: 'A', pin: await newLock('1111') },
{ kind: 'human', name: 'B' },
{ kind: 'human', name: 'C' },
]);
await src.unlockSeat('local:h3', '1111');
await src.pass('local:h3'); // seat 0 -> 1
let st = await src.gameState('local:h3');
expect(st.game.toMove).toBe(1);
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
await src.pass('local:h3'); // 1 -> 2
await src.pass('local:h3'); // 2 -> 0
st = await src.gameState('local:h3');
expect(st.game.toMove).toBe(0);
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
});
it('lets the host skip the current turn, gated by the master PIN', async () => {
const src = await hotseat('local:h4', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
const st = await src.hostAction('local:h4', '4321', 'skip');
expect(st).not.toBeNull();
expect((st as StateView).game.toMove).toBe(1);
});
it('lets the host resign a seat (ending a two-player game)', async () => {
const src = await hotseat('local:h5', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
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' },
{ kind: 'human', name: 'B' },
], '4321');
const r = await src.hostAction('local:h6', '4321', 'terminate');
expect(r).toBeNull();
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' },
{ kind: 'human', name: 'B' },
], '4321');
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
});
it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => {
const src = await hotseat('local:h8', [
{ 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
});
});
+95 -15
View File
@@ -17,6 +17,7 @@ import { setAlphabet, hasAlphabet } from '../alphabet';
import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { GatewayError, type PlacedTile } from '../client';
import type {
EvalResult,
@@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number;
interface Live {
game: LocalGame;
record: LocalGameRecord;
/** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
* Ephemeral never persisted; cleared on every turn advance so each turn re-locks. */
unlockedSeat: number | null;
}
/**
@@ -97,7 +101,8 @@ export class LocalSource implements GameLoopSource {
private readonly live = new Map<string, Live>();
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
/** create starts a new local vs_ai game, persists it and returns its state. */
/** create starts a new local game a vs_ai game or an offline pass-and-play (hotseat) game when
* hotseat is set persists it and returns its state. hostPin is the hotseat master PIN. */
async create(opts: {
id: string;
variant: Variant;
@@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource {
seed: bigint;
multipleWords: boolean;
seats: LocalSeat[];
hotseat?: boolean;
hostPin?: PinLock;
}): Promise<StateView> {
const dawg = await getDawg(opts.variant, opts.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable');
@@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource {
const now = nowUnix();
const record = serializeGame(game, {
id: opts.id,
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
hotseat: opts.hotseat,
hostPin: opts.hostPin,
createdAtUnix: now,
updatedAtUnix: now,
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
});
const entry: Live = { game, record };
const entry: Live = { game, record, unlockedSeat: null };
this.live.set(opts.id, entry);
await saveLocalGame(record);
return this.stateView(entry);
@@ -185,10 +194,67 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const move = entry.game.resign();
entry.unlockedSeat = null;
await this.persist(entry);
return this.moveResult(entry, move, seat);
}
/**
* unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
* against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
* the seat unlocked for this turn only a subsequent move re-locks it. Throws 'wrong_pin' on a
* mismatch. Returns the freshly unlocked state (rack revealed).
*/
async unlockSeat(gameId: string, pin: string): Promise<StateView> {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const lock = entry.record.seats[seat]?.pin;
if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
entry.unlockedSeat = seat;
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);
return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
}
/**
* hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
* 'skip' pass the current seat's turn;
* 'resign' resign seat targetSeat (exclude a player / concede on their behalf);
* 'terminate' end the game with no result and delete it from the store.
* Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
* PIN.
*/
async hostAction(
gameId: string,
pin: string,
action: 'skip' | 'resign' | 'terminate',
targetSeat?: number,
): Promise<StateView | null> {
const entry = await this.load(gameId);
if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
if (action === 'terminate') {
await this.delete(gameId);
return null;
}
if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null;
await this.persist(entry);
return this.stateView(entry);
}
async hint(gameId: string): Promise<HintResult> {
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
// a wall-clock timestamp here could be defeated by changing the device clock. This just serves
@@ -246,7 +312,7 @@ export class LocalSource implements GameLoopSource {
if (!record) throw new GatewayError('game_not_found');
const dawg = await getDawg(record.variant, record.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable');
const entry: Live = { game: replayGame(record, dawg), record };
const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null };
this.live.set(gameId, entry);
return entry;
}
@@ -258,6 +324,7 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
const robotMoves = this.runRobots(entry);
await this.persist(entry);
const result = this.moveResult(entry, move, seat);
@@ -290,10 +357,9 @@ export class LocalSource implements GameLoopSource {
private emitRobot(entry: Live, moves: LocalMove[]): void {
const set = this.listeners.get(entry.record.id);
if (!set) return;
const hintUnlockAtMs = this.hintUnlock(entry);
for (const m of moves) {
const game = this.gameView(entry);
const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength, hintUnlockAtMs };
const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength };
for (const cb of set) cb(ev);
}
if (entry.game.isOver) {
@@ -307,6 +373,8 @@ export class LocalSource implements GameLoopSource {
entry.record = serializeGame(entry.game, {
id: entry.record.id,
seats: entry.record.seats,
hotseat: entry.record.hotseat,
hostPin: entry.record.hostPin,
createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs,
@@ -342,16 +410,25 @@ export class LocalSource implements GameLoopSource {
// --- shape builders --------------------------------------------------------
// The vs_ai idle-hint unlock instant, sanitised on read: capped at now + the window so a device
// clock set back cannot push it far ahead and freeze the gate (the client counts down from here).
// 0 while open (a human-first opening, no robot move yet).
private hintUnlock(entry: Live): number {
return entry.record.hintUnlockAtMs ? Math.min(entry.record.hintUnlockAtMs, Date.now() + HINT_GATE_MS) : 0;
// The vs_ai idle-hint seconds left, from the stored unlock instant (device wall clock, persisted
// for the offline record) minus now — capped at the window so a device clock set back cannot push
// it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a
// monotonic countdown to this; 0 while open (a human-first opening, no robot move yet).
private hintUnlockLeft(entry: Live): number {
if (!entry.record.hintUnlockAtMs) return 0;
const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now());
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
}
// stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
// a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
// (empty, locked=true) until unlockSeat is called this turn.
private stateView(entry: Live): StateView {
const seat = this.humanSeat(entry);
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
const hotseat = !!entry.record.hotseat;
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
// 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),
seat,
@@ -359,7 +436,8 @@ export class LocalSource implements GameLoopSource {
bagLen: entry.game.bagLength,
hintsRemaining: 1,
walletBalance: 0,
hintUnlockAtMs: this.hintUnlock(entry),
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
locked,
};
}
@@ -386,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,
@@ -399,7 +478,8 @@ export class LocalSource implements GameLoopSource {
moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix,
vsAi: true,
vsAi: !record.hotseat,
hotseat: !!record.hotseat,
unreadChat: false,
unreadMessages: false,
seats,
+16 -5
View File
@@ -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 {
@@ -47,6 +50,10 @@ export interface GameView {
lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean;
/** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by
* the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host
* menu (which replaces the hint control). A hotseat game is never vsAi. */
hotseat?: boolean;
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
* move result); the live-event GameView leaves it false (events bump unread instead). */
@@ -82,10 +89,14 @@ export interface StateView {
bagLen: number;
hintsRemaining: number;
walletBalance: number;
/** For a vs_ai game, the wall-clock ms at which the idle hint unlocks persisted so the wait
* survives a relaunch, and sanitised on read so a device clock set back cannot freeze it or
* 0/undefined while open (the human's first move, or a non-vs_ai game). See lib/hints. */
hintUnlockAtMs?: number;
/** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
* clock online, the device clock offline) 0/undefined while open (the human's first move, or a
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
* so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
hintUnlockLeftSeconds?: number;
/** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the
* seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */
locked?: boolean;
}
export interface MoveResult {
@@ -425,7 +436,7 @@ export interface GameList {
*/
export type PushEvent =
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number }
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number; hintUnlockAtMs?: number }
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string }
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { hashPin, newLock, newSalt, verifyPin } from './pin';
describe('hashPin', () => {
it('is deterministic for the same pin and salt', async () => {
const salt = 'fixedsalt';
expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt));
});
it('differs when the salt differs', async () => {
expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB'));
});
it('differs when the pin differs', async () => {
const salt = 'fixedsalt';
expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt));
});
});
describe('newSalt', () => {
it('returns a non-empty value', () => {
expect(newSalt().length).toBeGreaterThan(0);
});
it('returns a fresh value each call', () => {
expect(newSalt()).not.toBe(newSalt());
});
});
describe('newLock / verifyPin', () => {
it('produces a lock that verifies its own pin', async () => {
const lock = await newLock('1234');
expect(lock.hash.length).toBeGreaterThan(0);
expect(lock.salt.length).toBeGreaterThan(0);
expect(await verifyPin('1234', lock)).toBe(true);
});
it('rejects a wrong pin', async () => {
const lock = await newLock('1234');
expect(await verifyPin('0000', lock)).toBe(false);
});
it('salts each lock independently (same pin, different hashes)', async () => {
const a = await newLock('1234');
const b = await newLock('1234');
expect(a.salt).not.toBe(b.salt);
expect(a.hash).not.toBe(b.hash);
});
});
+45
View File
@@ -0,0 +1,45 @@
// Seat / host PIN locks for offline pass-and-play (hotseat) games.
//
// A PinLock is a salted SHA-256 digest of a 4-digit PIN. This is a SOCIAL lock for a
// shared device — not cryptographic protection. A 4-digit PIN has only 10^4
// combinations and the whole game runs on the client, so a determined peek reads the
// racks straight from memory regardless. The salt only keeps the PIN itself out of a
// casual devtools glance and defeats precomputed tables; it does not make the PIN
// brute-force resistant.
/** PinLock is a stored, salted SHA-256 digest of a PIN (see the file header caveat). */
export interface PinLock {
hash: string;
salt: string;
}
// b64url encodes bytes as base64url without padding (matches the idiom in vkid.ts).
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** newSalt returns a fresh random base64url salt for a PinLock. */
export function newSalt(): string {
const a = new Uint8Array(16);
crypto.getRandomValues(a);
return b64url(a);
}
/** hashPin returns the base64url SHA-256 digest binding salt to pin. */
export async function hashPin(pin: string, salt: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${pin}`));
return b64url(new Uint8Array(digest));
}
/** newLock builds a PinLock for pin with a fresh salt. */
export async function newLock(pin: string): Promise<PinLock> {
const salt = newSalt();
return { salt, hash: await hashPin(pin, salt) };
}
/** verifyPin reports whether pin matches the digest stored in lock. */
export async function verifyPin(pin: string, lock: PinLock): Promise<boolean> {
return (await hashPin(pin, lock.salt)) === lock.hash;
}
+45 -2
View File
@@ -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
View File
@@ -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 ? '🥉' : '🏅';
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { buildSeats, commitName, rosterReady, shuffleSeeded, type RosterRow } from './roster';
describe('commitName (keep-last-valid)', () => {
it('commits a valid, trimmed name', () => {
expect(commitName('Ann', '')).toBe('Ann');
expect(commitName(' Ann ', '')).toBe('Ann');
});
it('keeps the previous valid name when the input goes invalid', () => {
expect(commitName('', 'Ann')).toBe('Ann');
expect(commitName(' ', 'Ann')).toBe('Ann');
expect(commitName('a'.repeat(33), 'Ann')).toBe('Ann'); // over the 32-rune cap
expect(commitName('_bad', 'Ann')).toBe('Ann'); // leading separator
});
it('replaces the committed name with a newer valid one', () => {
expect(commitName('Bob', 'Ann')).toBe('Bob');
});
});
describe('rosterReady', () => {
const row = (committed: string): RosterRow => ({ name: committed, committed });
it('needs 2-4 rows, each with a committed name', () => {
expect(rosterReady([row('A'), row('B')])).toBe(true);
expect(rosterReady([row('A'), row('B'), row('C'), row('D')])).toBe(true);
});
it('rejects fewer than 2, more than 4, or an empty row', () => {
expect(rosterReady([row('A')])).toBe(false);
expect(rosterReady([row('A'), row('B'), row('C'), row('D'), row('E')])).toBe(false);
expect(rosterReady([row('A'), row('')])).toBe(false);
});
});
describe('buildSeats', () => {
it('maps committed rows to account-less human seats, carrying the PIN', () => {
const lock = { hash: 'h', salt: 's' };
const seats = buildSeats([
{ name: 'Ann', committed: 'Ann', pin: lock },
{ name: 'x', committed: 'Bob' },
]);
expect(seats).toEqual([
{ kind: 'human', name: 'Ann', pin: lock },
{ kind: 'human', name: 'Bob', pin: undefined },
]);
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);
});
});
+54
View File
@@ -0,0 +1,54 @@
// Pure helpers for the offline hotseat (pass-and-play) creation roster: keeping a last-valid name as
// a row is edited, and turning the finished roster into engine seats. Kept out of the component so it
// is unit-testable; the form itself is exercised by the e2e layer.
import { validDisplayName } from './profileValidation';
import type { PinLock } from './pin';
import type { Seat } from './localgame/serialize';
/** RosterRow is one editable player row: the live input, the last valid name kept when the input
* goes invalid, and the optional per-seat PIN lock. */
export interface RosterRow {
name: string;
committed: string;
pin?: PinLock;
}
/** commitName returns the new last-valid name: the trimmed input when it is a valid display name,
* otherwise prevCommitted unchanged so editing a set name into an invalid one keeps the old one. */
export function commitName(input: string, prevCommitted: string): string {
return validDisplayName(input) ? input.trim() : prevCommitted;
}
/** rosterReady reports whether the roster can start a game: 2-4 rows, each with a committed name. */
export function rosterReady(rows: RosterRow[]): boolean {
return rows.length >= 2 && rows.length <= 4 && rows.every((r) => r.committed !== '');
}
/** buildSeats turns committed roster rows into engine seats all human, account-less (local
* players sharing a device), carrying each row's optional PIN. */
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;
}
+7 -4
View File
@@ -17,12 +17,15 @@ if (typeof window !== 'undefined') {
});
}
/** navigate switches the hash route (and forces a re-parse if it is unchanged). */
/** navigate switches the hash route (and forces a re-parse if it is unchanged). The route rune is
* updated synchronously not left to the asynchronous `hashchange` so a caller that renders off
* the route in the same tick (notably bootstrap flipping `app.ready` right after `navigate('/login')`)
* never sees `router.route` lag the hash for a frame, which would briefly render the previous route
* (the empty-hash lobby) under the new screen. The later `hashchange` re-parses to the same value. */
export function navigate(path: string): void {
const target = '#' + path;
if (location.hash === target) {
router.route = parse(target);
} else {
router.route = parse(target);
if (location.hash !== target) {
location.hash = path;
}
}
+42 -11
View File
@@ -4,6 +4,7 @@
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -186,6 +187,9 @@
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
// and finished; pinTarget holds the game id while its PIN pad is open.
let pinTarget = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
@@ -196,13 +200,14 @@
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, finished: boolean): void {
function onRowUp(e: PointerEvent, revealable: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
// ignore swipes and stay plain tap-to-open.
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
@@ -240,6 +245,14 @@
}
}
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
// once.
function startDelete(g: GameView): void {
if (g.hotseat) pinTarget = g.id;
else void hide(g.id);
}
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
@@ -315,15 +328,15 @@
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)}
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
onclick={() => openGame(g)}
>
<span class="info">
@@ -340,12 +353,15 @@
>{/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}
{#if group.finished || g.hotseat}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
@@ -378,6 +394,21 @@
</Modal>
{/if}
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
{#if pinTarget}
<PinPad
mode="verify"
title={t('hotseat.hostPin')}
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
onclose={() => (pinTarget = null)}
onresult={() => {
const id = pinTarget;
pinTarget = null;
if (id) void hide(id);
}}
/>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
+19 -1
View File
@@ -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);
+333 -15
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Modal from '../components/Modal.svelte';
import PinPad, { type PinResult } from '../components/PinPad.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
@@ -9,6 +11,9 @@
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { navigate } from '../lib/router.svelte';
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, shuffleSeeded, type RosterRow } from '../lib/roster';
import type { AccountRef, Variant } from '../lib/model';
import {
availableVariants,
@@ -144,13 +149,143 @@
}
}
// --- offline hotseat (pass-and-play) ---
// A device-local 2-4 human game. The host (referee) sets a mandatory master PIN first — it gates
// the roster and, in-game, the host overrides; each player row optionally locks its seat with a PIN.
let hostPin = $state<PinLock | undefined>(undefined);
let rows = $state<RosterRow[]>([
{ name: '', committed: '' },
{ name: '', committed: '' },
]);
let askHostPlays = $state(false); // the "do you take a seat?" prompt shown once the host PIN is set
// The PIN-pad overlay target: what a completed PIN applies to.
type PadTarget =
| { kind: 'host-set' }
| { kind: 'host-change' }
| { kind: 'seat-set'; index: number }
| { 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';
function padVerify(p: PadTarget): ((pin: string) => Promise<boolean>) | undefined {
if (p.kind === 'host-change' || p.kind === 'row-delete') return (pin) => verifyPin(pin, hostPin!);
if (p.kind === 'seat-change') return (pin) => verifyPin(pin, rows[p.index].pin!);
return undefined; // set flows need no verifier
}
const padTitle = (p: PadTarget): string =>
p.kind === 'host-set' || p.kind === 'host-change' || p.kind === 'row-delete'
? t('hotseat.hostPin')
: t('hotseat.setPin');
function onPad(r: PinResult): void {
const target = pad;
pad = null;
if (!target) return;
switch (target.kind) {
case 'host-set':
if (r.kind === 'set') {
hostPin = r.lock;
askHostPlays = true;
}
break;
case 'host-change':
if (r.kind === 'set') hostPin = r.lock; // the host PIN is mandatory — no remove option
break;
case 'seat-set':
if (r.kind === 'set') rows[target.index].pin = r.lock;
break;
case 'seat-change':
if (r.kind === 'set') rows[target.index].pin = r.lock;
else if (r.kind === 'removed') rows[target.index].pin = undefined;
break;
case 'row-delete':
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;
// Seat the host at row 0, its name reused from the profile (a normal seat — its optional seat PIN
// is separate from the master PIN).
const committed = commitName(app.profile?.displayName ?? '', '');
rows[0] = { name: committed, committed };
}
function openSeatPin(i: number): void {
pad = rows[i].pin ? { kind: 'seat-change', index: i } : { kind: 'seat-set', index: i };
}
async function startHotseat(): Promise<void> {
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
starting = true;
try {
const version = app.profile?.dictVersions?.[inviteVariant];
if (!version) {
handleError(new Error('dict_unavailable'));
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, best-effort store)
// — so the game would vanish on reload. See lib/localgame/store.
await localSource.create({
id,
variant: inviteVariant,
dictVersion: version,
seed,
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
hotseat: true,
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
});
navigate(`/game/${id}`);
} catch (e) {
handleError(e);
} finally {
starting = false;
}
}
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
<!-- Offline mode offers only a local vs_ai game: the friends flow needs the network, so the
auto/friends selector is hidden and the auto (vs_ai) flow shows on its own. -->
{#if !guest && !offlineMode.active}
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
pass-and-play (hotseat) flow. -->
{#if !guest || offlineMode.active}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
@@ -190,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
@@ -198,6 +332,78 @@
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{: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="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}
@@ -245,16 +451,37 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{#if pad}
<PinPad
mode={padMode(pad)}
title={padTitle(pad)}
verify={padVerify(pad)}
allowRemove={pad.kind === 'seat-change'}
onclose={() => (pad = null)}
onresult={onPad}
/>
{/if}
{#if askHostPlays}
<Modal title={t('hotseat.hostPlaysTitle')} onclose={() => (askHostPlays = false)}>
<div class="hostplays">
<button class="ghost" onclick={() => setHostPlays(false)}>{t('hotseat.hostPlaysNo')}</button>
<button class="primary" onclick={() => setHostPlays(true)}>{t('hotseat.hostPlaysYes')}</button>
</div>
</Modal>
{/if}
</div>
</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 {
@@ -339,8 +566,6 @@
border-color: var(--accent);
}
.fg {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
@@ -363,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;
@@ -426,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;
@@ -450,4 +667,105 @@
font-size: 0.85rem;
line-height: 1.4;
}
/* --- offline hotseat roster --- */
.hostpin {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 11px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
}
.roster {
display: flex;
flex-direction: column;
gap: 8px;
}
.prow {
display: flex;
align-items: center;
gap: 8px;
}
.pname {
flex: 1;
min-width: 0;
padding: 10px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.pname:disabled {
opacity: 0.5;
}
.pname.invalid {
border-color: var(--danger);
}
.plink {
flex: 0 0 auto;
padding: 8px 10px;
border: none;
background: none;
color: var(--accent);
font-weight: 600;
white-space: nowrap;
}
.plink:disabled {
color: var(--text-muted);
opacity: 0.6;
}
.pkebab {
flex: 0 0 auto;
padding: 6px 8px;
border: none;
background: none;
color: var(--text-muted);
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;
border: 1px dashed var(--border);
background: none;
color: var(--accent);
border-radius: var(--radius-sm);
font-weight: 600;
}
.addrow:disabled {
opacity: 0.5;
color: var(--text-muted);
}
.hostplays {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.hostplays button {
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.hostplays .primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
</style>
+13 -5
View File
@@ -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);