Compare commits

...

80 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
Ilia Denisov 537a265409 Merge branch 'development' into feature/offline-hint-gate
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
# Conflicts:
#	ui/src/lib/localgame/source.ts
2026-07-06 23:23:12 +02:00
developer 8c991429c6 Merge pull request 'fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby' (#208) from fix/offline-alphabet-and-lobby 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 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-06 21:21:19 +00:00
Ilia Denisov 359af83c34 fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby
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 1m29s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Two pre-existing offline bugs, both exposed once offline mode became usable (cold boot +
toggling), owner-reported on the contour.

Bug 1 -- offline tiles render weight 0. The rack/board read tile values from the
lib/alphabet cache, populated only by the online wire codec (server-sent alphabet) or the
mock (so the e2e masked it). A local game never goes through the codec, so on a cold
offline boot with no prior online session the cache was empty and every value read 0
(scores stayed correct -- they come from the offline ruleset). Fix: the local source
seeds lib/alphabet from the static offline ruleset (letters + values) when it builds a
game view.

Bug 2 -- the lobby showed (and could open) the other mode's game after a toggle. Two
causes: (a) the lobby cache was not mode-tagged, so getLobby() instant-rendered the last
snapshot regardless of mode; (b) load() wrote the module list after an await with no
generation guard, so a slow online gamesList() finishing after a fast offline list() (or
vice versa) left the wrong mode's games on screen. Fix: tag the snapshot with offline and
gate getLobby() on it; add a load-sequence guard so only the latest load() writes.

- localgame/source.ts: ensureAlphabet from the ruleset in gameView (+ unit test).
- lobbycache.ts: LobbySnapshot.offline + getLobby(offline) mode check (+ tests).
- Lobby.svelte: setLobby tags the mode; load() carries a generation guard.

check 0 / unit 485 / e2e 198 / app entry 113.8/114.
2026-07-06 23:11:17 +02:00
Ilia Denisov ff486c80f8 feat(hint): persist the vs_ai idle-hint wait (wall-clock + read sanitiser)
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 1m28s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Per the owner's call, the idle-hint gate now PERSISTS across leaving and reopening the
app, instead of the session-scoped monotonic clock: the unlock is a wall-clock instant
(hintUnlockAtMs) stamped from the robot's reply, stored on the local record, carried on
the game view + the opponent_moved move delta, and read through a sanitiser that caps it
at now + the window. So:
- the wait survives a relaunch (a stuck turn is not forgotten);
- a device clock set BACK cannot freeze the gate (the cap bounds the remaining to the
  window and self-heals on the next read);
- a clock set FORWARD just opens the hint early -- accepted as harmless for a solo game.

- lib/hints.ts hintGateRemainingMs now takes the unlock instant + clamps to the window.
- localgame: re-add hintUnlockAtMs to the record/meta; stamp it off the robot's reply;
  sanitise on read (a shared hintUnlock helper feeds stateView + the opponent_moved event).
- gamedelta + PushEvent: the move delta carries hintUnlockAtMs so the view stays fresh.
- Game.svelte: hintUnlockAt derives from the view; the tick + lock + toast unchanged.
- offline.spec.ts: also assert the lock survives a reload (the wait persisted).
- Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated (persist + sanitiser, not monotonic-resets).
2026-07-06 22:34:50 +02:00
Ilia Denisov 42a6308261 feat(hint): idle-gated wallet-free vs_ai hint on a monotonic clock (B4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
A vs_ai hint is now unlimited and wallet-free, but idle-gated as an anti-frustration
aid: it unlocks only after the player has been stuck ~30 min on a turn (timed from the
robot's last move; the human's first move, before the robot has played, is exempt).
While gated the hint button carries a small lock badge and a tap shows the remaining
minutes ('Available in N min.'); the lock lifts live at the mark.

The gate is enforced CLIENT-SIDE against a MONOTONIC clock (performance.now()), never a
wall-clock timestamp: a device clock the player controls (or an auto-sync) must not be
able to open or freeze it. The wait is therefore session-scoped -- a reload restarts it
(there is no tamper-proof way to carry idle time across a relaunch without a wall clock).
An online vs_ai game will gate the same way but from the server's clock (a follow-up).

- lib/hints.ts: HINT_GATE_MS + pure hintGateRemainingMs (monotonic) + hintLockMinutes;
  removed the wall-clock hintRemainingMs. Unit-tested red->green.
- Game.svelte: the monotonic gate (hintGateStart/monoNow, armed on the turn change), a
  vs_ai hint button (plain: no confirm, no count; lock badge + gated tap -> toast), a
  live 10s tick.
- localgame: removed the wall-clock hint gate and the now-dead robotLastMoveAtUnix field
  from source.ts/serialize.ts (the client is authoritative); hint() just serves the top
  move.
- i18n game.hintLockedIn.
- offline.spec.ts: assert the first move is un-gated and the lock arms after the robot moves.
- Docs FUNCTIONAL(+_ru) + ARCHITECTURE; bundle budget 114->115 (game-screen feature).
2026-07-06 22:03:27 +02:00
developer 08792dad3d Merge pull request 'test(offline): mock e2e for a device-local vs_ai game (C8)' (#206) from feature/offline-mock-e2e into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m28s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
2026-07-06 19:07:40 +00:00
Ilia Denisov e9f4cb0178 test(offline): mock e2e for a device-local vs_ai game (C8)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
A Playwright spec drives the whole offline flow in the mock build: force the
installed-PWA display mode, enter offline via the Settings toggle (its readiness check
fetches the dawgs), assert the blue chrome + online-games-hidden + Stats-disabled, then
create and play a device-local English vs_ai game with a pinned bag seed (deterministic
rack NEWYMAO) -- play the opening WAY across the centre, watch the robot reply with a
real move, and reload to confirm the IndexedDB replay.

Enabling infra (all e2e-only; nothing enters the production build):
- mock/client.ts fetchDict serves the per-variant dawgs from the preview build's
  /e2edict/ (was: threw 'unsupported in mock').
- scripts/e2e-dict.mjs copies the real dawgs into dist-e2e from E2E_DICT_DIR (the ui CI
  job fetches the scrabble-dictionary release like the Go jobs; local default: the
  sibling scrabble-solver/dawg); playwright.config runs it between build and preview.
- localgame/id.ts setForcedSeed + gateway.ts window.__mock.setLocalSeed: a mock-only
  seam to pin a local game's bag seed (tree-shaken from prod).
- ci.yaml ui job: fetch the dawgs + pass E2E_DICT_DIR to the e2e step.
- docs/TESTING.md: the offline e2e + the mock-dawg wiring.

Verified: check 0 / unit 482 / e2e 198 (both engines) / app entry 113.8/114.
2026-07-06 20:54:03 +02:00
developer 8fbbb3c5ef Merge pull request 'feat(offline): gate the offline toggle on dictionary readiness' (#205) from feature/offline-toggle-readiness 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-06 18:12:01 +00:00
Ilia Denisov 30770a759b feat(offline): gate the offline toggle on dictionary readiness
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 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m44s
Flipping the Settings toggle to offline now checks that every enabled variant's
dictionary is on the device before entering offline mode: it fetches missing ones
cache-first and waits up to ~5 s (raceOfflineReady + the lazy dict/offlineready),
greying the toggle meanwhile. If they cannot be readied in time it stays online and
shows a 'needs internet' note, while the fetch keeps warming the cache in the
background so a later flip is instant. Leaving offline is never gated.

Prevents entering a half-baked offline mode (no dawg -> cannot create/play a local
game) when the background preload has not finished (poor connection, or an immediate
flip right after install).

- offline.ts: raceOfflineReady (pure, injected sleep; unit-tested red->green)
- dict/offlineready.ts: ensureOfflineDicts (cache-first preloadDicts, lazy chunk)
- offline.svelte.ts: requestOffline + TOGGLE_READY_BUDGET_MS
- Settings.svelte: checking/needsData state, disabled toggle, inline note
- i18n: settings.offlineChecking / settings.offlineNeedsData (en+ru)
- docs: FUNCTIONAL(+_ru) offline story + ARCHITECTURE offline paragraph
2026-07-06 20:03:12 +02:00
developer 05c445e4da Merge pull request 'docs(offline): connectivity auto-detect + kill switch user story' (#204) from feature/offline-autodetect-docs into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-06 17:47:55 +00:00
Ilia Denisov d43a740eb6 docs(offline): document connectivity auto-detect + transport kill switch
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) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
The offline auto-detect shipped in #202 (cold-start no-connection dialog / auto
offline) and #203 (mid-session flight-mode reactivity) but neither PR baked the
docs. Add the user story to FUNCTIONAL.md (+ _ru mirror) and the mechanism to the
ARCHITECTURE offline section: the auto vs deliberate distinction, the cold-start
navigator.onLine / bounded reachability-probe decision, the mid-session window
online/offline events backed by a navigator.onLine poll (the events are unreliable
on some platforms, notably iOS PWAs), and the reachability probe being the one call
exempt from the offline kill switch.
2026-07-06 19:41:09 +02:00
developer 350013acd9 Merge pull request 'feat(offline): mid-session flight-mode reactivity [PR2]' (#203) from feature/offline-flightmode 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 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
2026-07-06 17:31:17 +00:00
Ilia Denisov 3ad66f49c7 fix(offline): set the auto flag in setOfflineMode; remove temp diagnostic
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
The auto-offline -> online return was dead because setOfflineMode never set the
'auto' flag: I added the state + getter but forgot the assignment, so it stayed
false. So scheduleRecovery bailed immediately (no poll ran) and the online event's
'if (active && auto)' was false. The contour diagnostic confirmed offline.auto
stayed false after an auto-offline. Add 'auto = on && !persist'.

Also remove the temporary network diagnostic panel (netdiag + the lobby strip) --
it did its job of pinpointing this.
2026-07-06 19:20:59 +02:00
Ilia Denisov 09c5a5e72e chore(offline): TEMPORARY network diagnostic panel (remove before release)
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 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
A fixed strip in the lobby showing live navigator.onLine / offline.active /
offline.auto / connection.online plus a timestamped event log (offline/online
events, poll ticks, checkReachable results, mode changes) - to pinpoint where the
auto-offline -> online transition breaks on the contour. A 1s heartbeat logs
navigator.onLine flips even if the events never fire. Skipped in the mock; to be
reverted with the fix.
2026-07-06 19:03:33 +02:00
Ilia Denisov fffc6030ce fix(offline): poll navigator.onLine to return online (the online event is unreliable)
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 1m24s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The auto-offline -> online return still did not fire: the retry hung on the
'online' event, which an installed PWA often does not deliver. Replace it with a
lightweight poll while in auto-offline that reads navigator.onLine (a reliable
live flag, unlike the event) and only hits the network for a reachability check
when the interface is actually up - so flight mode ON costs no radio, and flight
mode OFF is detected within ~4s and returns online. The 'online' event, when it
does fire, just kicks an immediate check. Runs only in auto-offline (deliberate
offline is the player's choice); wired for both the mid-session and cold-start
auto-offline paths.
2026-07-06 18:50:10 +02:00
Ilia Denisov fc8143758a fix(offline): return online after flight-mode off; gate online-game actions offline
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Two mid-session issues found on the contour:
- Auto-offline did not return to online after the network came back: a single
  reachability check right after the 'online' event failed (the interface is up
  before the gateway is actually reachable again). Retry a few times with backoff
  (tryReturnOnline) — that is what actually gets the app back online.
- An online game viewed while offline (flight mode on) still enabled its network
  actions, so they hit the kill switch and raised 'something went wrong' toasts.
  Gate them: netReady = isLocalGame || (connection.online && !offlineMode.active)
  — a local game stays fully usable; an online game's make/exchange/hint/resign
  disable while offline and re-enable when back. Also suppress the 'offline' code
  in handleError (a blocked call in offline mode is expected, not a toast).
2026-07-06 18:35:52 +02:00
Ilia Denisov e0d28733ff feat(offline): mid-session flight-mode reactivity (auto-offline self-heals)
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
React to the network changing while the app is open (e.g. the player toggling
flight mode), via passive online/offline events - no polling, no battery cost:
- interface lost -> enter offline mode for the session (auto);
- interface back -> if the offline was auto, verify the gateway is really
  reachable (an interface being up does not guarantee it) and return online; a
  deliberate offline (the toggle or the cold-start dialog) is left as-is.

- offline.svelte: track `auto` (auto-detected vs the player's deliberate choice).
- connection.svelte: checkReachable is now a pure one-shot (the caller decides);
  the reachability watcher never probes in offline mode (events drive recovery).
- transport.ts: the reachability probe is exempt from the kill switch - it IS
  the mechanism that decides whether to return online, fired only deliberately.
- app.svelte.ts: initNetworkReactivity wires the events (web-only, skipped in
  the mock); called from bootstrap.

Online unaffected (skipped in the mock e2e): e2e 196. Mid-session reactivity is
contour-verified.
2026-07-06 18:15:12 +02:00
developer ccd65f61b8 Merge pull request 'feat(offline): auto-detect no network at cold start + go-offline dialog [PR1]' (#202) from feature/offline-autodetect 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 1m13s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
2026-07-06 16:11:17 +00:00
Ilia Denisov 5bc2ad3b6d feat(offline): auto-detect no network at cold start + 'go offline?' dialog
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
A sticky-online cold start with no network hung the splash on adoptSession's
retrying profile fetch. Now, for an offline-capable web install with a cached
profile:
- No network interface (navigator.onLine === false) -> enter offline mode for
  the session (no dialog; the next launch re-evaluates).
- Interface up but the gateway is unreachable within 3s (a single-attempt
  reachability probe, not the 6-retry loop) -> a 'No connection. Enable offline
  mode?' dialog: Enable -> sticky offline; Keep trying -> the normal online
  adopt (retries, 'Connecting...').

- connection.svelte: checkReachable(timeout) - a bounded single probe.
- offline.svelte: setOfflineMode(on, persist) - auto-offline is session-only, a
  deliberate choice (dialog/toggle) is sticky.
- app.svelte.ts: the cold-start auto-detect in bootstrap + the dialog resolver;
  App.svelte renders the boot dialog. i18n en/ru.
- App-entry bundle budget 113->114 (the boot path cannot be lazy-loaded).

Online cold-start unaffected (auto-detect gated to isStandalone, off in the mock
e2e): e2e 196. The offline paths are contour-verified.

Next: PR2 - mid-session flight-mode reactivity (online/offline events).
2026-07-06 17:49:53 +02:00
developer 2a4b0fb25e Merge pull request 'fix(offline): delete a finished local game from the device, not the network' (#201) from feature/offline-delete-game 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m38s
2026-07-06 15:06:17 +00:00
developer 2a7c632840 Merge pull request 'fix(pwa): network-first navigation so a new deploy loads online immediately' (#200) from feature/sw-fresh-online into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 1m24s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-06 15:05:37 +00:00
Ilia Denisov 3a72bc29ba fix(offline): delete a finished local game from the device, not the network
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The lobby's finished-game delete (hide) always called gateway.hideGame; for a
local (offline) game the transport kill switch refused it, so it toasted and the
game stayed. Route by id: a local game is removed from the device store + the
source cache (LocalSource.delete), an online game still hides on the backend.
2026-07-06 16:59:36 +02:00
Ilia Denisov 020742fad3 fix(pwa): network-first navigation so a new deploy loads online immediately
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
The C1 service worker served the precached shell for every navigation, even
online, so a new deploy only reached a client after the worker updated its
precache in the background (a one-launch lag): a cold online start showed the
old version until then, and only a reinstall forced it fresh.

- sw.ts: navigations are now network-first — fetch the fresh (no-cache) shell
  from the server on each online launch (it references the new hashed assets,
  fetched fresh), with a 3s timeout falling back to the precached shell when the
  network is unreachable or too slow. Only the tiny HTML is re-fetched; the
  immutable hashed assets stay cache-first and re-download only when their hash
  (the version) changes. Offline cold-launch still works via the fallback.
- webui.go: serve sw.js with Cache-Control: no-cache so the browser reliably
  detects a new deploy's worker (regression-tested).

Online launch is contour-verified (the mock e2e disables the SW); e2e 196.
No new deps — the network-first handler is hand-written (~12 lines).
2026-07-06 16:45:07 +02:00
developer fd225564a3 Merge pull request 'fix(offline): cold-boot offline from the persisted session + profile' (#199) from feature/offline-boot 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 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-06 14:25:11 +00:00
Ilia Denisov fdf14d5897 fix(offline): skip the NewGame friend fetch offline
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
NewGame's onMount fetched the friend list (gateway.friendsList) for a non-guest;
offline the transport kill switch refuses it, so it toasted on entering the New
Game screen (the local game still created fine). The friends section is hidden
offline anyway — skip the fetch when offline.
2026-07-06 16:12:50 +02:00
Ilia Denisov 1a456c4847 feat(offline): transport kill switch + gate the network-requiring UI
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.

Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
  unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
  the reachability probe — is refused before it leaves the device. Offline
  truly means offline regardless of any UI that slips through. The local
  (device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
  (About) are disabled offline, and the hub falls back to Settings (which holds
  the online/offline toggle); the lobby Stats tab was already disabled. In-game
  social/export are already hidden for a vs_ai game.

Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
2026-07-06 15:46:22 +02:00
Ilia Denisov 2a045a5b37 fix(offline): give the local human seat the real account id
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
In a local game the human seat's account id was a synthetic 'local:human:0',
so seatName's `accountId === session.userId` check never matched: the game
header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's
groupGames could not find the viewer's seat, so a human's turn read as
'Their turn' with the hourglass. Carry the real account id on the human seat
(create -> record -> GameView); the robot keeps its synthetic id.

Local games created before this fix keep the old display (no migration).
2026-07-06 15:36:51 +02:00
Ilia Denisov 19e7ea5da0 fix(offline): persist a plain profile snapshot; keep the sticky offline flag
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The offline cold-boot never engaged: it persisted app.profile — a Svelte
$state proxy — which is not structured-cloneable, so the IndexedDB write threw
and fell back to a localStorage entry that loadProfile (IDB-only on a
successful-empty read) never reads. loadProfile returned null, the boot
short-circuit missed, and it even cleared the sticky offline flag — so offline
mode stopped persisting across relaunches (owner-observed on the contour).

- Persist $state.snapshot(app.profile) (a plain object) at both persist sites,
  so the IndexedDB write succeeds and loadProfile round-trips.
- Drop the setOfflineMode(false) fallback: keep the deliberate offline flag on a
  cache miss (the mode is the player's choice; an online boot re-persists the
  profile so the next launch goes offline). A truly offline launch with no
  cached profile is unreachable (enabling offline needs a prior online session).
2026-07-06 15:20:15 +02:00
Ilia Denisov 54d701fd8a fix(offline): cold-boot offline from the persisted session + profile
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 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An installed PWA relaunched with the network off hung on the splash: C1's
precache served the shell, but bootstrap() adopted the session and fetched the
profile over the network, which hung. Persist the profile (on every online
adopt + refresh, cleared on logout) and short-circuit bootstrap when the
deliberate offline flag is sticky-on: with a cached session + profile, skip the
network and land straight in the offline lobby. Without a cached profile (never
online), drop the sticky flag and boot online — the first launch must be online.

- session.ts: saveProfile/loadProfile/clearProfile (mirror saveSession).
- offline.ts: shouldBootOffline decision (unit-tested).
- app.svelte.ts: persist in adoptSession + refreshProfile, clear on logout,
  the offline boot short-circuit in bootstrap.

Docs: ARCHITECTURE. Online cold-start unaffected (e2e 196); the offline boot is
contour-verified (the mock e2e cannot enter sticky-offline).
2026-07-06 14:54:09 +02:00
developer ec0c13bebc Merge pull request 'feat(offline): offline lobby — list + create + play local vs_ai games [C6]' (#198) from feature/offline-lobby 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-06 12:43:23 +00:00
Ilia Denisov 5643c8be10 fix(offline): AI comms hub opens straight to the Dictionary, not via Chat
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 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
For an honest-AI (vs_ai) game the comms hub relied on a post-mount $effect to
switch from the Chat tab to the Dictionary, so ChatScreen mounted for a beat
and fired its chat/state fetch over the network. Online that was a wasted
call; offline it threw and raised a 'something went wrong' toast on entering
the word-check form (the check itself already worked). Start the comms hub on
the Dictionary tab immediately for a vs_ai game, so ChatScreen never mounts.
2026-07-06 14:22:01 +02:00
Ilia Denisov 2f70ef1b85 fix(offline): route word-check through the game source; reload lobby on offline flip
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 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two offline bugs found on the contour:
- The word-check screen (CheckScreen) called gateway.gameState/checkWord
  directly, so in an offline (local) game it hit the network and errored
  ("something went wrong"). Route both through gameSource(id) — the local
  source answers from the device dawg. The complaint control (online-only,
  no offline backend) is hidden for a local game.
- The lobby did not react to the offline-mode toggle (which lives in Settings,
  so the lobby can stay mounted): an online game lingered until the next
  reload. Reload on an offlineMode flip so entering offline immediately shows
  only device-local games.

Cold offline launch hanging on the splash (boot still fetches the profile over
the network) is the separate C2 offline-boot follow-up (needs a persisted
profile + a boot short-circuit).
2026-07-06 14:04:23 +02:00
Ilia Denisov ef832b823d feat(offline): offline lobby lists + creates local vs_ai games
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
In offline mode the lobby now shows only the device-local games and its
New-vs-AI entry creates one through the in-browser engine — the visible
payoff of the offline mode.

- LocalSource.list() reconstructs a lobby GameView per stored local game by
  replay, exposed through the lazy gamesource proxy; unit-tested via an
  in-memory store.
- Lobby.load() branches on offlineMode: lists local games and skips every
  gateway call (no online games/invitations/incoming); the Stats tab is
  disabled offline.
- NewGame offline: find() creates a device-local vs_ai game via
  LocalSource.create using the profile's advertised dict version + a local
  seed; the friends flow and the random-opponent option are hidden, and the
  variant picker / Start are enabled offline (were gated on connection).
- id.ts: newLocalGameId + randomSeed (tested).

Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section.

Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance
gating, the flip-to-offline readiness wait, the offline mock e2e (needs
mock-dawg support), and the local-hint UI. The offline flow is verified on
the test contour — the mock e2e cannot enter offline mode (the toggle is
gated to an installed PWA).
2026-07-06 11:35:23 +02:00
developer 99f0a545be Merge pull request 'feat(offline): app-shell precache service worker (vite-plugin-pwa) [C1]' (#197) from feature/offline-pwa-precache into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 09:13:57 +00:00
Ilia Denisov 1a95a5f2cb feat(offline): app-shell precache service worker (vite-plugin-pwa)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Migrate the former install-only public/sw.js to a custom (injectManifest)
service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the
app shell + hashed assets (Workbox) so an installed web PWA cold-launches with
no network, and falls in-scope navigations back to the precached shell (the
hash router resolves the route client-side). This is the C1 prerequisite for a
usable offline mode: without it a cold offline launch cannot load the bundle.

- ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches +
  precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html,
  deny-listing the RPC path and /_gm. The Connect stream and API POSTs are
  never precached nor intercepted.
- vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own),
  injectRegister:false (registration stays manual + web-only in pwa.svelte.ts),
  disabled in the mock build (Playwright unperturbed); landing + polyfills
  excluded from the precache.
- Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged.
- New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}.

Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified
(the mock e2e harness disables the SW).
2026-07-06 11:06:59 +02:00
developer 8abd8b2ae6 Merge pull request 'feat(offline): advertise dict versions + background dictionary preload' (#196) from feature/offline-dict-preload 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 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m42s
2026-07-06 08:45:41 +00:00
Ilia Denisov 2f867b8e6c feat(offline): background-preload dictionaries for offline readiness
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
An installed PWA (standalone web + confirmed email) warms the dictionaries
for the player's enabled variants while online — on lobby entry and on a
variant-preference change — using the per-variant version the profile now
advertises, so a later switch to offline mode already has the data.

- dict/preload.ts: pure preloadDicts (retry + linear backoff, honours the
  session dictionary miss-breaker); node-tested.
- dict/preloadrun.ts: lazy browser orchestration (real getDawg), imported
  dynamically so the loader and move generator stay out of the main bundle.
- offline.svelte.ts: kickDictPreload, gated by the pure, tested
  offlinePreloadEligible, plus the reactive first-lobby preload warning.
- Header shows a poor-connection notice in the ad-banner slot on a
  first-lobby preload failure (offline.preloadWarning, en/ru).
- App-entry bundle budget 112->113 for the irreducible main-side wiring
  (documented in bundle-size.mjs); the heavy parts remain lazy chunks.

Docs: ARCHITECTURE offline-mode + dict-preload mechanism.
2026-07-06 10:39:55 +02:00
Ilia Denisov a692024b4e feat(profile): advertise per-variant dictionary versions for offline preload
The Profile now carries dict_versions (game variant -> current dictionary
version), populated from the dictionary registry at the profileResponse
choke point, so an installed PWA can preload the matching dawg per enabled
variant off the existing cold-start profile request instead of adding a
round-trip for a rare feature.

Wire path: FBS DictVersion table + Profile.dict_versions (additive,
backward-compatible trailing field) -> backend dto/registry -> gateway
ProfileResp + FBS encoder -> client codec decode into a per-variant map on
model.Profile. Empty in a degenerate no-dictionary deployment; the mock
serves v1.3.0 for all three variants. Codec decode covered by a
bite-tested round-trip unit test.
2026-07-06 10:39:29 +02:00
developer 8349e222fc Merge pull request 'feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)' (#195) from feature/offline-mode-state 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 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 07:48:49 +00:00
Ilia Denisov 68ecd881d9 feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The deliberate offline mode is now visible: an installed web-PWA user with a confirmed
email can switch to offline in Settings, and the header turns blue with an "Offline"
chip. (Network gating, the dict preload, the offline lobby + local-game creation, and the
service-worker precache follow in later Phase C steps.)

- offline.ts / offline.svelte.ts: a sticky, device-scoped reactive offline flag
  (offlineMode), distinct from connection.svelte's transient reachability signal, with the
  pure persistence + readiness helpers (offlineReady / missingDicts) unit-tested.
- Settings.svelte: an Online / Offline toggle, shown only for an installed web PWA with a
  confirmed email (the SW-launch + durable-account preconditions).
- Header.svelte: a blue-tinted nav (color-mix from the accent, so it tracks light/dark and
  a Telegram theme override) + an "Offline" chip while offline; the deliberate mode
  suppresses the transient "Connecting…" indicator.

Behaviour-preserving online (the toggle is hidden and offlineMode is false there; e2e 196
green). App entry stays within its size budget (111.6 / 112 KB gzip).
2026-07-06 09:44:05 +02:00
developer d80d28a402 Merge pull request 'feat(offline): wire the local game source into the game screen (Phase B3.2)' (#194) from feature/offline-game-seam-wire 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 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 07:31:05 +00:00
Ilia Denisov 1654131904 feat(offline): wire the local game source into the game screen (Phase B3.2)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The game screen now drives a local vs_ai game through the offline engine, dispatched by
game id — completing the playable local game (on top of the source, #193). Online play
is unchanged.

- gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the
  gateway (the same game-loop interface). The offline engine stays OUT of the app entry
  bundle — it is dynamically imported on first use (a separate chunk), so online-only
  users never pay for it (the app entry stays within its size budget).
- localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on.
- Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/
  evaluate/draft) go through gameSource(id) instead of the gateway directly; a local
  game's robot-reply events route through the same app event hub the network stream
  feeds, so the screen reacts to opponent_moved / game_over identically.

Behaviour-preserving for network games (gameSource returns the gateway for them). Local
verify green: check + test:unit + build + bundle-size gate + e2e (196 passed).
2026-07-06 09:24:55 +02:00
developer 9ecbe480db Merge pull request 'feat(offline): local game source (Phase B3.1)' (#193) from feature/offline-game-seam into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 07:13:26 +00:00
Ilia Denisov 32298595f2 feat(offline): local game source (Phase B3.1)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A GatewayClient-shaped facade over the offline engine, so the same game screen can drive
a local vs_ai game with no backend (the wiring into Game.svelte is B3.2).

- source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game
  id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human
  move then run the robot (decide(generateMoves)) synchronously, persisting both and
  delivering the robot's move through a per-game event emitter (no live stream). hint is
  gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates
  the UI's glyph space to the engine's index space with the static letters table.
- ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet —
  offline is now fully self-contained (no reliance on a warm server alphabet cache).
- engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay +
  dictionaryHas for the move preview / word check, and record the main-word coordinate +
  the words on a play (for the history MoveRecord).
- source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint
  gate, decoded history, and a whole game driven to completion.

Pure additive library code; no runtime behavior change (bundle unchanged).
2026-07-06 09:07:40 +02:00
developer 2847412b5b Merge pull request 'feat(offline): local game persistence + replay (Phase B2)' (#192) from feature/offline-localgame-store 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 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-06 06:34:57 +00:00
Ilia Denisov 4fa77bf82c feat(offline): local game persistence + replay (Phase B2)
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 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Store an offline game durably and reconstruct it — the offline counterpart of the
server's replay rehydration. Builds on the engine (#191); not wired into the UI yet
(Phase B3).

- serialize.ts: a LocalGameRecord (seed + rules + seat metadata + the alphabet-index
  move journal) and replayGame() — reconstruct a live LocalGame by seeding a fresh
  engine identically and replaying the journal. The board/bag/racks are not stored; they
  are deterministic from the seed and the replayed operations, so the record stays small.
  The journal is dictionary-independent (alphabet-index space, stable per variant).
- store.ts: an IndexedDB store for local games (save/get/list/delete), mirroring
  lib/dict/store.ts — its own database, best-effort, guarded when IndexedDB is absent.
- engine.ts: record the swapped tiles on an exchange (needed for exact replay) and expose
  the game's rule config.
- serialize.test.ts: a round-trip — reconstruct a mid-game and a finished game by replay
  and assert the state (board/racks/bag/scores/turn/log) is identical.

Pure additive library code; no runtime behavior change (bundle unchanged).
2026-07-06 08:22:47 +02:00
developer afa44d41b4 Merge pull request 'feat(offline): local game engine (Phase B1)' (#191) from feature/offline-localgame-engine into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-06 06:15:56 +00:00
Ilia Denisov e4cf143e9f feat(offline): local game engine (Phase B1)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The offline vs_ai game engine — a faithful TS port of backend/internal/engine that
drives a whole local game with no backend. Composes with the move generator (#188) and
robot strategy (#189) from Phase A; not yet wired into the UI (Phase B2/B3).

- ui/src/lib/localgame/ruleset.ts: static per-variant tile values / bag counts / blank
  count, mirrored from rules.go (offline scoring is self-contained; online uses the
  server alphabet). Pinned by ruleset.parity.test.ts against a Go fixture.
- bag.ts: the tile bag (fill from counts/blanks, draw-from-end, return+reshuffle) on a
  deterministic in-house PRNG — a game replays from its seed, not bit-identical to a
  server game (per plan).
- board.ts: the mutable board, satisfying the validator/generator read view + set().
- engine.ts: LocalGame — deal / play (reusing validate.ts) / pass / exchange / resign,
  scoreless(6) & out-of-tiles end detection, end-of-game rack penalties, winner; mirrors
  game.go. The end-game math is exported as pure functions, pinned against the Go engine
  (engine.parity.test.ts, 9 constructed positions).
- engine.test.ts: a full-loop smoke — two robots play a whole vs_ai game to completion
  via generateMoves + decide, and it is reproducible from the seed.
- backend: movegen now dumps the per-variant rulesets; a new in-package engine emitter
  (endfixture_test.go, env-gated) produces the end-game golden.

Pure additive library code; no runtime behavior change (unused at runtime, bundle unchanged).
2026-07-06 08:02:05 +02:00
developer 543cbe56b9 Merge pull request 'test(offline): real-dictionary move-generator conformance in CI' (#190) from feature/offline-realdict-conformance into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m36s
2026-07-06 00:20:57 +00:00
Ilia Denisov a9c8f1ecfe test(offline): real-dictionary move-generator conformance in CI
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
Phase A (A4): prove the ported move generator (#188) against the FULL shipped
dictionaries, not just the tiny samples — the deep graphs and complete 26/33-letter
alphabets the samples cannot reach.

- backend/cmd/movegen: add a -dawg-dir mode that emits per-variant golden move-gen
  vectors from the real dawgs (a bounded first-move + a blank case + a deep 7-tile
  mid-game position). Regenerated in CI to /tmp, never committed (like the
  dictgen/validategen vectors), so no dictionary version is pinned into the repo.
- ui/src/lib/dict/generate.realparity.test.ts: env-gated (DICT_DAWG_DIR +
  DICT_MOVEGEN_DIR) parity against that golden — 9 positions across scrabble_en /
  scrabble_ru / erudit_ru match the Go solver exactly. Skips cleanly when unset.
- .gitea/workflows/ci.yaml: the conformance job now generates the movegen golden
  and points the gated vitest at it (DICT_MOVEGEN_DIR).
2026-07-06 02:16:50 +02:00
developer d694ead7b6 Merge pull request 'feat(offline): robot move-choice strategy in TS (parity-pinned)' (#189) from feature/offline-robot-strategy into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
2026-07-05 23:59:06 +00:00
Ilia Denisov 8c5995c076 feat(offline): port robot move-choice strategy to TS (parity-pinned)
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m37s
Phase A (2/2) of PWA offline mode: the offline robot picks its move exactly as the
server does, so a local vs_ai game plays the same. Builds on the move generator
from #188; not wired into a game loop yet (Phase B).

- ui/src/lib/robot/strategy.ts: port of backend/internal/robot/strategy.go's
  move-choice slice — mix (FNV-1a, via BigInt for bit-exact uint64), playToWin
  (~40% play-to-win), deviates (the fading off-strategy wobble) and selectMove
  (pick the candidate whose resulting margin lands closest to the +/-[1,30] band,
  conservative tie-break), composed by decide(). The generator's ranked moves feed
  straight in. Think-time/sleep/nudge scheduling is server-only and not ported.
- backend/internal/robot/strategyfixture_test.go: an in-package, env-gated emitter
  (EMIT_STRATEGY_FIXTURES=1) writing golden fixtures from the real Go strategy — it
  reaches the unexported mix/playToWin/deviates/selectMove.
- strategy.parity.test.ts: 21 mix + 56 decision cases match Go exactly (play/
  exchange/pass, the deviate flip, tie-break, band overshoot).

Pure additive library code; no runtime behavior change (unused at runtime, so the
bundle is unchanged).
2026-07-06 01:54:20 +02:00
developer cedc9ffae1 Merge pull request 'feat(offline): DAWG cursor + move generator in TS (parity-pinned)' (#188) from feature/offline-movegen into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m32s
2026-07-05 23:42:58 +00:00
Ilia Denisov c334a9d7b7 feat(offline): port DAWG cursor + move generator to TS (parity-pinned)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
First engine-first step of PWA offline mode (Phase A): the client-side move
generator — the "robot brain" a local vs_ai game will run on-device — with no
runtime wiring yet (Phase B).

- dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port
  of dafsa traverse.go over the reader's existing bitstream.
- generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets +
  counts-rack + board transpose + moveKey ranking), reusing the cursor and
  validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the
  33-letter Russian alphabet (index 32) is exact under JS bit ops.
- validate.ts: export connected for the generator's connectivity filter.
- backend/cmd/movegen: dev tool building small sample dictionaries and emitting
  golden move-generation fixtures from the real Go solver (EN + RU).
- tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and
  generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank,
  single-word rule, Russian index-32 cross-set). The committed EN sample also
  unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI.

Pure additive library code; no runtime behavior change.
2026-07-06 01:35:11 +02:00
121 changed files with 23710 additions and 314 deletions
+14
View File
@@ -214,9 +214,21 @@ jobs:
run: pnpm exec playwright install chromium webkit run: pnpm exec playwright install chromium webkit
timeout-minutes: 5 timeout-minutes: 5
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: E2E smoke (mock) - name: E2E smoke (mock)
run: pnpm run test:e2e run: pnpm run test:e2e
timeout-minutes: 5 timeout-minutes: 5
env:
E2E_DICT_DIR: ${{ github.workspace }}/dawg
# conformance proves the client's local move preview (the ported dawg reader + # conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
@@ -252,6 +264,7 @@ jobs:
run: | run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -271,6 +284,7 @@ jobs:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/ run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes # gate is the single branch-protection required check. It always runs and passes
+417
View File
@@ -0,0 +1,417 @@
// Command movegen emits golden conformance fixtures for the client-side move
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
// committed so the TypeScript parity tests run without a Go toolchain.
//
// For each small sample dictionary (English and Russian — the latter reaches
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
//
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
// - sample_<tag>.words.json the stored words + their alphabet indexes
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
// for a handful of positions, plus the ruleset the TS
// side rebuilds to score identically
//
// Positions are built with only the solver's public API: an empty board, and
// two-ply positions reached by applying the solver's own top move (so no internal
// encoding is needed). Regenerate with:
//
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
package main
import (
"bytes"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"strings"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
dawg "github.com/iliadenisov/dafsa"
)
// sampleWordsEN is the English sample dictionary, in strictly increasing
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
var sampleWordsEN = []string{
"a", "an", "and", "ant",
"car", "care", "cared", "cares", "cars", "cat", "cats",
"do", "doe", "does", "dog", "dogs", "done", "dot",
}
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
// order. It deliberately includes words starting with я (index 32) so the ported
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
// knows the exact word set (as alphabet indexes) to expect from enumeration.
type sampleFixture struct {
Alphabet string `json:"alphabet"`
NumAdded int `json:"numAdded"`
Words []string `json:"words"`
Indexes [][]int `json:"indexes"`
}
// genFixture is the move-generation golden set for one sample dictionary.
type genFixture struct {
Ruleset genRuleset `json:"ruleset"`
Cases []genCase `json:"cases"`
}
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
type genRuleset struct {
Size int `json:"size"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
LetterMult [][]int `json:"letterMult"`
WordMult [][]int `json:"wordMult"`
}
// genTile is one placed tile (a board tile or a move placement).
type genTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
}
// genRack is a rack as a multiset of letter indexes plus a blank count.
type genRack struct {
Letters []int `json:"letters"`
Blanks int `json:"blanks"`
}
// genMove is one ranked generated play: its orientation, placed tiles and total score.
type genMove struct {
Dir int `json:"dir"`
Tiles []genTile `json:"tiles"`
Score int `json:"score"`
}
// genCase is one generation position: the tiles already on the board (empty when
// none), the rack, the mode/rule and the ranked moves the solver returns.
type genCase struct {
Name string `json:"name"`
Placed []genTile `json:"placed"`
Rack genRack `json:"rack"`
Mode int `json:"mode"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Moves []genMove `json:"moves"`
}
func main() {
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", *out, err)
}
if *dawgDir != "" {
buildReal(*dawgDir, *out)
return
}
emitRulesets()
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
})
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
})
}
// buildSample writes the dawg, the word fixture and the generation golden set for
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
// the first non-empty result is applied, then generation runs again on the new rack.
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
idx := rs.Alphabet
b := dawg.New(idx)
indexes := make([][]int, 0, len(words))
for _, w := range words {
if err := b.Add(w); err != nil {
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
}
enc, err := idx.Encode(w)
if err != nil {
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
}
ints := make([]int, len(enc))
for i, x := range enc {
ints[i] = int(x)
}
indexes = append(indexes, ints)
}
finder := b.Finish()
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
})
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
if _, err := finder.Save(dawgPath); err != nil {
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
}
s := scrabble.NewSolver(rs, finder)
for i := range cases {
runCase(s, rs, &cases[i], nil)
}
// A two-ply position from the first standard case that produced a move.
if two := twoPly(s, rs, cases); two != nil {
cases = append(cases, *two)
}
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
Ruleset: rulesetOf(rs), Cases: cases,
})
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
}
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
// kept small by the anchors around the already-placed word.
type realVariant struct {
file, variant, smallRack, blankRack, fullRack string
rs *rules.Ruleset
}
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
// the full alphabets and deep graphs the tiny samples cannot reach — one
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
func buildReal(dawgDir, out string) {
reals := []realVariant{
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
}
for _, v := range reals {
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
if err != nil {
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
}
s := scrabble.NewSolver(v.rs, finder)
cases := []genCase{
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
}
for i := range cases {
runCase(s, v.rs, &cases[i], nil)
}
// A deep 7-tile mid-game: place the top first move, then generate again. The
// anchors around the placed word bound the list while still exercising a full rack,
// deep left/right extension and wide cross-sets over the real graph.
full := encRack(v.rs, v.fullRack, 0)
b := board.New(v.rs.Rows, v.rs.Cols)
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
cases = append(cases, mid)
}
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
total := 0
for _, c := range cases {
total += len(c.Moves)
}
_ = finder.Close()
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
}
}
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
// index-based genRack the fixtures carry.
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
enc, err := rs.Alphabet.Encode(letters)
if err != nil {
log.Fatalf("movegen: encode rack %q: %v", letters, err)
}
idx := make([]int, len(enc))
for i, b := range enc {
idx[i] = int(b)
}
return genRack{Letters: idx, Blanks: blanks}
}
// runCase fills a case's Moves by generating on a board holding the given placed
// tiles (nil = empty board).
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
bd := board.New(rs.Rows, rs.Cols)
for _, t := range placed {
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
}
c.Placed = placed
rk := toRack(rs.Size(), c.Rack)
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
c.Moves = movesOf(moves)
}
// twoPly reaches a mid-game position by applying the top move of the first standard
// case that generated one, then generates again with a fresh rack of the same tiles.
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
for _, c := range cases {
if c.IgnoreCrossWords || len(c.Moves) == 0 {
continue
}
placed := c.Moves[0].Tiles
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
runCase(s, rs, &next, placed)
return &next
}
return nil
}
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
// to the solver module and cannot be imported.
func cellByte(letter int, blank bool) byte {
v := byte(letter+1) & 0x3f
if blank {
v |= 0x80
}
return v
}
func toRack(size int, r genRack) rack.Rack {
rk := rack.New(size)
for _, l := range r.Letters {
rk.Add(byte(l))
}
for i := 0; i < r.Blanks; i++ {
rk.AddBlank()
}
return rk
}
func rulesetOf(rs *rules.Ruleset) genRuleset {
lm := make([][]int, rs.Rows)
wm := make([][]int, rs.Rows)
for r := 0; r < rs.Rows; r++ {
lm[r] = make([]int, rs.Cols)
wm[r] = make([]int, rs.Cols)
for c := 0; c < rs.Cols; c++ {
p := rs.Premium(r, c)
lm[r][c] = p.LetterMult()
wm[r][c] = p.WordMult()
}
}
return genRuleset{
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
}
}
func movesOf(ms []scrabble.Move) []genMove {
out := make([]genMove, len(ms))
for i, m := range ms {
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
}
return out
}
func tilesOf(ps []scrabble.Placement) []genTile {
out := make([]genTile, len(ps))
for i, p := range ps {
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
}
return out
}
// emptyCase builds an empty-board case (Moves filled later by runCase).
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
}
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
func englishRack(letters string, blanks int) genRack {
idx := make([]int, 0, len(letters))
for _, ch := range letters {
idx = append(idx, int(ch-'a'))
}
return genRack{Letters: idx, Blanks: blanks}
}
// russianRack builds a rack from the Russian sample letters used above.
func russianRack(letters string, blanks int) genRack {
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
idx := make([]int, 0, len([]rune(letters)))
for _, ch := range letters {
i, ok := m[ch]
if !ok {
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
}
idx = append(idx, i)
}
return genRack{Letters: idx, Blanks: blanks}
}
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
func emitRulesets() {
type rsFix struct {
Size int `json:"size"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Blanks int `json:"blanks"`
Values []int `json:"values"`
Counts []int `json:"counts"`
Letters []string `json:"letters"`
}
out := map[string]rsFix{}
for _, v := range []struct {
name string
rs *rules.Ruleset
}{
{"scrabble_en", rules.English()},
{"scrabble_ru", rules.RussianScrabble()},
{"erudit_ru", rules.Erudit()},
} {
letters := make([]string, v.rs.Size())
for i := range letters {
ch, err := v.rs.Alphabet.Character(byte(i))
if err != nil {
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
}
letters[i] = strings.ToUpper(ch)
}
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
}
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatalf("movegen: mkdir %s: %v", dir, err)
}
writeJSON(filepath.Join(dir, "rulesets.json"), out)
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
}
func writeJSON(path string, v any) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("movegen: marshal %s: %v", path, err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
log.Fatalf("movegen: write %s: %v", path, err)
}
}
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
// Being in-package, this emitter constructs Game values directly and drives the unexported
// end-game math on chosen positions.
type endCaseIn struct {
Name string `json:"name"`
Variant string `json:"variant"`
Reason string `json:"reason"`
Hands [][]int `json:"hands"`
Scores []int `json:"scores"`
Resigned []bool `json:"resigned"`
ToMove int `json:"toMove"`
}
type endCaseOut struct {
endCaseIn
ScoresAfter []int `json:"scoresAfter"`
Winner int `json:"winner"`
}
func rulesetFor(variant string) *rules.Ruleset {
switch variant {
case "scrabble_ru":
return rules.RussianScrabble()
case "erudit_ru":
return rules.Erudit()
default:
return rules.English()
}
}
func reasonFor(s string) EndReason {
switch s {
case "out_of_tiles":
return EndOutOfTiles
case "scoreless":
return EndScoreless
case "resign":
return EndResign
case "aborted":
return EndAborted
}
return EndNotOver
}
func handsBytes(hands [][]int) [][]byte {
out := make([][]byte, len(hands))
for i, h := range hands {
b := make([]byte, len(h))
for j, x := range h {
b[j] = byte(x)
}
out[i] = b
}
return out
}
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
// EMIT_ENGINE_FIXTURES. Regenerate with:
//
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
func TestEmitEndgameFixtures(t *testing.T) {
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
}
cases := []endCaseIn{
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
}
out := make([]endCaseOut, 0, len(cases))
for _, c := range cases {
rs := rulesetFor(c.Variant)
scores := append([]int(nil), c.Scores...)
g := &Game{
rules: rs,
hands: handsBytes(c.Hands),
scores: scores,
resigned: c.Resigned,
toMove: c.ToMove,
}
reason := reasonFor(c.Reason)
g.over = true
g.reason = reason
g.applyEndAdjustment(reason)
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
}
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(dir, "endgame.json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
t.Logf("wrote %s (%d cases)", path, len(out))
}
+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) { func TestAllowedTimeout(t *testing.T) {
if !allowedTimeout(24 * time.Hour) { if !allowedTimeout(24 * time.Hour) {
t.Error("24h must be allowed") 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) return svc.store.MarkChangesApplied(ctx, variant.String(), version)
} }
// Hint reveals the top-scoring legal play for the requesting player on their // hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// turn, spending one hint from their per-game allowance and then their profile // before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as const hintIdleWindow = 30 * time.Minute
// appropriate.
// 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) { func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID) pre, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
@@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
if !pre.HintsAllowed { if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled 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) acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil { if err != nil {
return HintResult{}, err return HintResult{}, err
@@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: 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 }, nil
} }
+9
View File
@@ -65,6 +65,10 @@ var (
ErrNoHintsLeft = errors.New("game: no hints remaining") ErrNoHintsLeft = errors.New("game: no hints remaining")
// ErrNoHintAvailable is returned when the player has no legal play to reveal. // ErrNoHintAvailable is returned when the player has no legal play to reveal.
ErrNoHintAvailable = errors.New("game: no legal move to suggest") 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 // ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
// simultaneous quick games and tries to create another game (quick or by invitation). // simultaneous quick games and tries to create another game (quick or by invitation).
ErrGameLimitReached = errors.New("game: simultaneous game limit reached") 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 // 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. // it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int 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. // HistoryMove is one decoded journal row, independent of any dictionary.
@@ -0,0 +1,146 @@
package robot
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"scrabble/backend/internal/engine"
)
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
type mixCase struct {
Seed string `json:"seed"`
Salt string `json:"salt"`
Nums []int `json:"nums"`
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
}
type decisionCase struct {
Seed string `json:"seed"`
MoveCount int `json:"moveCount"`
MyScore int `json:"myScore"`
OppScore int `json:"oppScore"`
CandScores []int `json:"candScores"`
RackLen int `json:"rackLen"`
BagLen int `json:"bagLen"`
PlayToWin bool `json:"playToWin"`
Win bool `json:"win"` // the per-turn intent after the deviate flip
Kind string `json:"kind"`
Index int `json:"index"` // chosen candidate index for a play, else -1
}
type strategyFixture struct {
PlayToWinPercent int `json:"playToWinPercent"`
Mix []mixCase `json:"mix"`
Decisions []decisionCase `json:"decisions"`
}
// scenario is one selectMove situation exercised across several seeds.
type scenario struct {
moveCount, myScore, oppScore, rackLen, bagLen int
cands []int
}
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
// what the TS parity test consumes. Regenerate with:
//
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
func TestEmitStrategyFixtures(t *testing.T) {
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
}
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
mixCases := []mixCase{}
addMix := func(seed int64, salt string, nums ...int) {
mixCases = append(mixCases, mixCase{
Seed: strconv.FormatInt(seed, 10),
Salt: salt,
Nums: append([]int{}, nums...),
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
})
}
for _, sd := range seeds {
addMix(sd, "win")
addMix(sd, "deviate", 0)
addMix(sd, "deviate", 7)
}
scenarios := []scenario{
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
}
decisions := []decisionCase{}
for _, sd := range seeds {
for _, sc := range scenarios {
cands := make([]engine.MoveRecord, len(sc.cands))
for i, s := range sc.cands {
cands[i] = engine.MoveRecord{Score: s, Player: i}
}
rack := make([]string, sc.rackLen)
for i := range rack {
rack[i] = "a"
}
ptw := playToWin(sd)
win := ptw
if deviates(sd, sc.moveCount, sc.bagLen) {
win = !win
}
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
kind, index := "pass", -1
switch d.kind {
case decidePlay:
kind, index = "play", d.move.Player
case decideExchange:
kind = "exchange"
}
decisions = append(decisions, decisionCase{
Seed: strconv.FormatInt(sd, 10),
MoveCount: sc.moveCount,
MyScore: sc.myScore,
OppScore: sc.oppScore,
CandScores: append([]int{}, sc.cands...),
RackLen: sc.rackLen,
BagLen: sc.bagLen,
PlayToWin: ptw,
Win: win,
Kind: kind,
Index: index,
})
}
}
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
data, err := json.MarshalIndent(fx, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
path := filepath.Join(dir, "strategy.json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
}
+26
View File
@@ -8,6 +8,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
@@ -56,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
r := profileResponseFor(acc) r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc) r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
return r return r
} }
// currentDictVersions reports the current dictionary version of every game variant with a
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
// so an offline-capable client preloads the matching dawg per variant off the cold-start
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
func (s *Server) currentDictVersions() []dictVersion {
if s.registry == nil {
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
}
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
out := make([]dictVersion, 0, len(variants))
for _, v := range variants {
version, _, err := s.registry.Latest(v)
if err != nil {
continue
}
out = append(out, dictVersion{Variant: v.String(), Version: version})
}
if len(out) == 0 {
return nil
}
return out
}
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked // fillLinkedIdentities sets the profile's confirmed email address and platform-linked
// flags from the account's identities, so the client offers the right link / unlink / // flags from the account's identities, so the client offers the right link / unlink /
// change-email controls. A read failure leaves them zero (no controls), logged as a // change-email controls. A read failure leaves them zero (no controls), logged as a
+18
View File
@@ -63,6 +63,20 @@ type profileResponse struct {
Email string `json:"email"` Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"` TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"` VkLinked bool `json:"vk_linked"`
// DictVersions lists the current dictionary version per game variant (engine.Variant
// stable labels). An installed PWA preparing for offline play reads it to preload the
// right dawg per enabled variant off this cold-start response, without an extra request.
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
// for callers that build the DTO without a Server. See Server.profileResponse.
DictVersions []dictVersion `json:"dict_versions,omitempty"`
}
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
// client preloads the matching dawg per variant.
type dictVersion struct {
Variant string `json:"variant"`
Version string `json:"version"`
} }
// tileDTO is one placed (or to-place) tile. // tileDTO is one placed (or to-place) tile.
@@ -155,6 +169,9 @@ type stateDTO struct {
BagLen int `json:"bag_len"` BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"` 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"` Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
} }
@@ -307,6 +324,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance, WalletBalance: v.WalletBalance,
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) 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" return http.StatusConflict, "no_hint_available"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft): case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
return http.StatusConflict, "hint_unavailable" 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): case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play" return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken): case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
+68 -6
View File
@@ -1238,12 +1238,74 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
browser-language detection — crawlers render with arbitrary languages). The favicon set, browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by `og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**: `assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from `manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell **web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's **precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
installability requirement (a registered SW, needed for install on Android) and is the single no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type 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 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
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+48 -1
View File
@@ -215,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game — English) to look it up. Hints are governed per game —
whether they are allowed and how many each player starts with — and draw on a whether they are allowed and how many each player starts with — and draw on a
personal hint wallet once the per-game allowance is spent. The game ends when the personal hint wallet once the per-game allowance is spent. Against the robot
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
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 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, 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 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 24 hours): a missed turn auto-resigns, except while the player is inside their
@@ -256,6 +265,44 @@ the same occasional move against its plan that fades out by the endgame), and ch
add-friend are off. AI games are **practice** — they never count toward a player's add-friend are off. AI games are **practice** — they never count toward a player's
statistics. statistics.
### Offline mode
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 *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
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(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
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
the network — airplane mode — switches to offline automatically, and restoring it returns online by
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
dialog — is the player's choice: it is kept, and never undone automatically.
### Social: friends, block, chat, nudge ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played digits, valid for twelve hours), or send a **request to someone you have played
+49 -1
View File
@@ -222,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
одной статистике подсказок. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
@@ -261,6 +270,45 @@ e-mail) либо ввод фразы. Активные игры форфейтя
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+12 -1
View File
@@ -20,7 +20,18 @@ tests or touching CI.
derivation and the GCG share/copy/download choice, plus Playwright specs against the derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
production build.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG real self-played 35-move game) must rasterize to a plausible PNG
+4 -3
View File
@@ -8,9 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
in by the gateway image's node stage) and serves a **landing page** at `/` and the game in by the gateway image's node stage) and serves a **landing page** at `/` and the game
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model. **SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest` The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the with the `.webmanifest` MIME type registered in-process — the distroless image has no
install-only `sw.js`, both shipped unhashed from `ui/public/`. `/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
precaches the shell + assets so an installed PWA cold-launches offline).
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run; backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see in the deployed contour the front caddy owns `/_gm` (see
+10
View File
@@ -42,6 +42,15 @@ type ProfileResp struct {
Email string `json:"email"` Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"` TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_linked"` VkLinked bool `json:"vk_linked"`
// DictVersions is the current dictionary version per game variant, forwarded verbatim
// into the Profile payload so an offline-capable client preloads the matching dawg.
DictVersions []DictVersion `json:"dict_versions,omitempty"`
}
// DictVersion pairs a game variant's stable label with its current dictionary version.
type DictVersion struct {
Variant string `json:"variant"`
Version string `json:"version"`
} }
// BannerResp is the advertising-banner block of an eligible viewer's profile: the // BannerResp is the advertising-banner block of an eligible viewer's profile: the
@@ -178,6 +187,7 @@ type StateResp struct {
BagLen int `json:"bag_len"` BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"` WalletBalance int `json:"wallet_balance"`
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
} }
+30
View File
@@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
banner = encodeBanner(b, *p.Banner) banner = encodeBanner(b, *p.Banner)
} }
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
dictVersions := encodeDictVersions(b, p.DictVersions)
fb.ProfileStart(b) fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid) fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name) fb.ProfileAddDisplayName(b, name)
@@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddEmail(b, email) fb.ProfileAddEmail(b, email)
fb.ProfileAddTelegramLinked(b, p.TelegramLinked) fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
fb.ProfileAddVkLinked(b, p.VkLinked) fb.ProfileAddVkLinked(b, p.VkLinked)
if dictVersions != 0 {
fb.ProfileAddDictVersions(b, dictVersions)
}
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
@@ -118,6 +122,31 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
// omitted). Every child table and its strings are created before the vector is opened, per the
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
// before ProfileStart for the same reason.
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
if len(dvs) == 0 {
return 0
}
offsets := make([]flatbuffers.UOffsetT, len(dvs))
for i, dv := range dvs {
variant := b.CreateString(dv.Variant)
version := b.CreateString(dv.Version)
fb.DictVersionStart(b)
fb.DictVersionAddVariant(b, variant)
fb.DictVersionAddVersion(b, version)
offsets[i] = fb.DictVersionEnd(b)
}
fb.ProfileStartDictVersionsVector(b, len(offsets))
for i := len(offsets) - 1; i >= 0; i-- {
b.PrependUOffsetT(offsets[i])
}
return b.EndVector(len(offsets))
}
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the // optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
// optional field (the client then reads it as absent). // optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT { func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
@@ -276,6 +305,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
BagLen: s.BagLen, BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining, HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance, WalletBalance: s.WalletBalance,
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
Alphabet: alphabet, 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" { if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
t.Fatalf("handler: %v", err) t.Fatalf("handler: %v", err)
} }
st := fb.GetRootAsStateView(payload, 0) st := fb.GetRootAsStateView(payload, 0)
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 { 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", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance()) 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) game := st.Game(nil)
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
+5
View File
@@ -71,6 +71,11 @@ func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download. // app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
if strings.HasPrefix(name, "assets/") { if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else if name == "sw.js" {
// The service worker must be revalidated on every load so a new deploy's worker (and its
// fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on
// the old build. The worker's network-first navigation then serves the fresh shell online.
w.Header().Set("Cache-Control", "no-cache")
} }
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
}) })
+4
View File
@@ -94,6 +94,10 @@ func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
t.Errorf("Content-Type = %q, want text/javascript", ct) t.Errorf("Content-Type = %q, want text/javascript", ct)
} }
// Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up.
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("sw.js Cache-Control = %q, want no-cache", cc)
}
} }
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted // TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
+18
View File
@@ -223,6 +223,13 @@ table BannerInfo {
// suppresses out-of-app platform push, leaving only the in-app live stream. banner // suppresses out-of-app platform push, leaving only the in-app live stream. banner
// carries the advertising-banner block for an eligible viewer, absent otherwise // carries the advertising-banner block for an eligible viewer, absent otherwise
// (all added trailing — backward-compatible). // (all added trailing — backward-compatible).
// DictVersion is one variant's current dictionary version, carried on the profile so an offline
// client learns it from an existing cold-start request (no extra call for a rarely-used feature).
table DictVersion {
variant:string;
version:string;
}
table Profile { table Profile {
user_id:string; user_id:string;
display_name:string; display_name:string;
@@ -245,6 +252,10 @@ table Profile {
email:string; email:string;
telegram_linked:bool; telegram_linked:bool;
vk_linked:bool; vk_linked:bool;
// dict_versions carries each variant's current dictionary version so an offline client can
// preload the right dictionary and pin a new local game without a separate request (added
// trailing — backward-compatible).
dict_versions:[DictVersion];
} }
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
@@ -305,6 +316,13 @@ table StateView {
// the wallet is a single global figure the client keeps live across games (added trailing — // the wallet is a single global figure the client keeps live across games (added trailing —
// backward-compatible). // backward-compatible).
wallet_balance:int; 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). // GameActionRequest carries just a game id (pass / resign / hint / history).
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type DictVersion struct {
_tab flatbuffers.Table
}
func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &DictVersion{}
x.Init(buf, n+offset)
return x
}
func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &DictVersion{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *DictVersion) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *DictVersion) Variant() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *DictVersion) Version() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func DictVersionStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
}
func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0)
}
func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+27 -1
View File
@@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
return rcv._tab.MutateBoolSlot(34, n) return rcv._tab.MutateBoolSlot(34, n)
} }
func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *Profile) DictVersionsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func ProfileStart(builder *flatbuffers.Builder) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(16) builder.StartObject(17)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) { func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
builder.PrependBoolSlot(15, vkLinked, false) builder.PrependBoolSlot(15, vkLinked, false)
} }
func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0)
}
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
return rcv._tab.MutateInt32Slot(16, n) 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) { func StateViewStart(builder *flatbuffers.Builder) {
builder.StartObject(7) builder.StartObject(8)
} }
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) 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) { func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
builder.PrependInt32Slot(6, walletBalance, 0) builder.PrependInt32Slot(6, walletBalance, 0)
} }
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
}
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+2
View File
@@ -93,6 +93,7 @@ type StateView struct {
BagLen int BagLen int
HintsRemaining int HintsRemaining int
WalletBalance int WalletBalance int
HintUnlockLeftSeconds int
Alphabet []AlphabetEntry Alphabet []AlphabetEntry
} }
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddBagLen(b, int32(s.BagLen))
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance)) fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
if hasAlphabet { if hasAlphabet {
fb.StateViewAddAlphabet(b, alphabet) 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);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { test, expect, type Page } from './fixtures';
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
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;
});
}
// 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> {
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).
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
// all-distinct letters, so the glyph is unambiguous).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
}
test.describe('offline mode', () => {
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
// Online: a seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
await page.locator('button.tab').nth(2).click();
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click();
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
await expect(page.locator('.lock')).toHaveCount(0);
// The human plays WAY horizontally across the centre (7,5)-(7,7).
await placeTile(page, 'W', 7, 5);
await placeTile(page, 'A', 7, 6);
await placeTile(page, 'Y', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
// The play commits and the local robot replies with a real move, so the board carries more than
// WAY's three tiles.
await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 });
const filled = await page.locator('[data-cell].filled').count();
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
await expect(page.locator('.lock')).toBeVisible();
// Reload: the hash router restores the /game/<id> route and the local game replays from
// IndexedDB with every committed tile intact.
await page.reload();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
// the wait was not reset by relaunching.
await expect(page.locator('.lock')).toBeVisible();
});
});
+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');
});
});
+5 -1
View File
@@ -32,6 +32,10 @@
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.0.0" "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.0.0",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1"
} }
} }
+4 -1
View File
@@ -21,8 +21,11 @@ export default defineConfig({
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at // under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset // /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
// URLs, so the minified JS under test is identical to the contour's. // URLs, so the minified JS under test is identical to the contour's.
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
command: command:
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort', 'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
url: 'http://localhost:4173', url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
+3195 -21
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
// Install-only service worker.
//
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
// service worker with a fetch handler) so the web app can be installed to the home screen /
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
// the live event stream are never served stale.
//
// This is the single designated growth point for the planned offline mode (a Settings toggle,
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
// version guard.
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
self.addEventListener('install', () => {
// No cached content to migrate carefully, so activate the new worker immediately.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
await self.clients.claim();
})(),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
// can never break the live stream or serve a stale immutable asset.
if (req.mode !== 'navigate') return;
event.respondWith(
(async () => {
try {
const res = await fetch(req);
const cache = await caches.open(SHELL);
// One shell entry regardless of the deep-link path: the hash router resolves the route
// client-side, so any offline navigation can be served the same cached shell.
cache.put('shell', res.clone());
return res;
} catch {
const cache = await caches.open(SHELL);
const cached = await cache.match('shell');
return cached ?? Response.error();
}
})(),
);
});
+10 -4
View File
@@ -20,10 +20,16 @@ import { join } from 'node:path';
const DIST = 'dist'; const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview // Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection / // wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy // the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. // the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
const BUDGET = { app: 112, 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 // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+35
View File
@@ -0,0 +1,35 @@
// Copies the real per-variant dictionary DAWGs into the mock e2e preview output (dist-e2e/e2edict/)
// so the offline spec can create and play a real local vs_ai game (the mock's fetchDict serves these
// files — see lib/mock/client.ts). The dawgs are NEVER committed and never enter the production
// build; they live only in the throwaway dist-e2e/ that `vite preview` serves for Playwright.
//
// Source directory: E2E_DICT_DIR — in CI the fetched scrabble-dictionary release
// ($GITHUB_WORKSPACE/dawg); locally it defaults to the sibling scrabble-solver/dawg checkout. A
// missing source only warns (so the other, dawg-free specs still run); the offline spec then fails
// with an obvious HTTP 404 from fetchDict.
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
const srcDir = process.env.E2E_DICT_DIR ?? '../../scrabble-solver/dawg';
const outDir = 'dist-e2e/e2edict';
// The app's Variant enum value -> the release dawg file name (matches the movegen/parity mapping).
const dawgFor = {
scrabble_en: 'en_sowpods',
scrabble_ru: 'ru_scrabble',
erudit_ru: 'ru_erudit',
};
mkdirSync(outDir, { recursive: true });
let copied = 0;
for (const [variant, file] of Object.entries(dawgFor)) {
const src = join(srcDir, `${file}.dawg`);
if (!existsSync(src)) {
console.warn(`e2e-dict: missing ${src} — the offline spec will 404 (set E2E_DICT_DIR)`);
continue;
}
copyFileSync(src, join(outDir, `${variant}.dawg`));
copied++;
}
console.log(`e2e-dict: copied ${copied}/${Object.keys(dawgFor).length} dawgs from ${srcDir} -> ${outDir}`);
+59 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte'; import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte'; import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
@@ -136,6 +136,21 @@
<Coachmark /> <Coachmark />
<MaintenanceOverlay /> <MaintenanceOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
{#if app.offlinePrompt}
<div class="offprompt" role="dialog" aria-modal="true">
<div class="card">
<p class="msg">{t('offline.promptTitle')}</p>
<div class="acts">
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
</div>
</div>
</div>
{/if}
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash /> <Splash />
{/if} {/if}
@@ -145,6 +160,49 @@
{/if} {/if}
<style> <style>
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
.offprompt {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: var(--pad);
}
.offprompt .card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 20px;
max-width: 320px;
width: 100%;
text-align: center;
}
.offprompt .msg {
margin: 0 0 16px;
font-size: 1rem;
color: var(--text);
}
.offprompt .acts {
display: flex;
flex-direction: column;
gap: 8px;
}
.offprompt .opt {
padding: 11px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.95rem;
}
.offprompt .opt.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.splash { .splash {
height: 100%; height: 100%;
display: grid; display: grid;
+9 -1
View File
@@ -162,7 +162,15 @@ html.app-shell {
} }
html.app-shell body { html.app-shell body {
position: fixed; position: fixed;
inset: 0; /* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
top: var(--vv-top, 0px);
left: 0;
right: 0;
height: var(--vvh, 100%);
overflow: hidden; overflow: hidden;
} }
+48 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte'; import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
@@ -27,7 +28,7 @@
} }
</script> </script>
<header class="nav" class:grow> <header class="nav" class:grow class:offline={offlineMode.active}>
<div class="bar"> <div class="bar">
{#if showBack} {#if showBack}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back"> <button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
@@ -36,15 +37,20 @@
{:else} {:else}
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#if connection.online || offlineMode.active}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1> <h1 onclick={onTitleTap}>{title}</h1>
{:else} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/if}
<!-- A right-hand spacer balances the back button so the title stays centred. --> <!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
{#if offlineMode.active}
<span class="chip">{t('settings.offline')}</span>
{:else}
<span class="spacer"></span> <span class="spacer"></span>
{/if}
</div> </div>
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the <!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
same place on every screen — and in the game (grown nav) the spare height falls below same place on every screen — and in the game (grown nav) the spare height falls below
@@ -52,7 +58,11 @@
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears, dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
per this same condition, once onboarding closes. --> per this same condition, once onboarding closes. -->
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive} {#if dictPreloadWarning.active}
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
<AdBanner <AdBanner
campaigns={app.profile.banner.campaigns} campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings} timings={app.profile.banner.timings}
@@ -74,6 +84,12 @@
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
.nav.offline {
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.nav.grow { .nav.grow {
/* Grow into spare height (banner under the title, the board pinned to the bottom), but /* Grow into spare height (banner under the title, the board pinned to the bottom), but
never shrink: on a short viewport the banner keeps its height and the board's own never shrink: on a short viewport the banner keeps its height and the board's own
@@ -114,6 +130,34 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
.chip {
min-width: 40px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
font-size: 0.72rem;
font-weight: 600;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 999px;
white-space: nowrap;
}
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
the banner region so the bar does not jump when it appears. */
.preload-warn {
margin: 0;
padding: 7px var(--pad);
font-size: 0.78rem;
line-height: 1.3;
text-align: center;
color: var(--text-muted);
background: var(--surface-2);
border-top: 1px solid var(--border);
}
.back { .back {
background: none; background: none;
border: none; border: none;
+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>
+10 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource';
import { handleError, showToast } from '../lib/app.svelte'; import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet'; import { alphabetLetters } from '../lib/alphabet';
@@ -20,8 +21,10 @@
onMount(async () => { onMount(async () => {
try { try {
// Include the alphabet so input sanitising + the check accept the variant's letters. // Include the alphabet so input sanitising + the check accept the variant's letters. Routed
const st = await gateway.gameState(id, true); // through gameSource so an offline (local) game resolves its state from the device, not the
// network.
const st = await gameSource(id).gameState(id, true);
variant = st.game.variant; variant = st.game.variant;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -41,7 +44,7 @@
cooling = true; cooling = true;
setTimeout(() => (cooling = false), 5000); setTimeout(() => (cooling = false), 5000);
try { try {
const r = await gateway.checkWord(id, w, variant); const r = await gameSource(id).checkWord(id, w, variant);
checked.set(w, r.legal); checked.set(w, r.legal);
result = { word: w, legal: r.legal }; result = { word: w, legal: r.legal };
} catch (e) { } catch (e) {
@@ -77,7 +80,11 @@
: t('game.wordIllegal', { word: result.word })} : t('game.wordIllegal', { word: result.word })}
</p> </p>
<div class="actions"> <div class="actions">
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
receive them, so the control is dropped there. -->
{#if !isLocalGameId(id)}
<button class="complain" onclick={complain}>{t('game.complain')}</button> <button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
{#if result.legal} {#if result.legal}
<a <a
class="lookup" class="lookup"
+12 -6
View File
@@ -16,15 +16,21 @@
// The game is rendered (and cached) before its comms open, so the cache tells us whether // The game is rendered (and cached) before its comms open, so the cache tells us whether
// it is still active without another fetch; an unknown game keeps the Dictionary offered. // it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// An honest-AI game has no chat at all, so its hub is Dictionary-only. // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); // Its hub is Dictionary-only.
const chatless = $derived(
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
);
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary // a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
// deep-link falls back to Chat). // deep-link falls back to Chat).
// 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 // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab); let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
$effect(() => { $effect(() => {
if (vsAi) tab = 'dictionary'; if (chatless) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat'; else if (tab === 'dictionary' && !active) tab = 'chat';
}); });
</script> </script>
@@ -43,7 +49,7 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
{#if !vsAi} {#if !chatless}
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button> </button>
+374 -31
View File
@@ -4,24 +4,28 @@
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte'; import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import DictWarmup from '../components/DictWarmup.svelte'; import DictWarmup from '../components/DictWarmup.svelte';
import Board from './Board.svelte'; import Board from './Board.svelte';
import Rack from './Rack.svelte'; import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics'; import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board'; import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { seatMedal } from '../lib/result';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
@@ -47,6 +51,18 @@
let { id }: { id: string } = $props(); let { id }: { id: string } = $props();
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
const source = $derived(gameSource(id));
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
// toasts a blocked call would otherwise raise).
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null;
let view = $state<StateView | null>(null); let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]); let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([])); let placement = $state<Placement>(newPlacement([]));
@@ -149,10 +165,42 @@
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished'); 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 // 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 // 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). // hint was spent in another game (see lib/hints).
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0)); const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
let gateLeftMs = $state(0);
let monoNow = $state(performance.now());
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
const hintGated = $derived(hintRemaining > 0);
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
// load from the view, and on the robot's move to the full window (the robot just moved).
function armHintGate(leftSeconds: number): void {
if (leftSeconds > 0) {
hintGateStart = performance.now();
gateLeftMs = leftSeconds * 1000;
} else {
hintGateStart = null;
gateLeftMs = 0;
}
}
$effect(() => {
if (!view?.game.vsAi) return;
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
return () => clearInterval(iv);
});
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange // RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied // gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
// exchange regardless (engine rejects when bag.Len() < rules.RackSize). // exchange regardless (engine rejects when bag.Len() < rules.RackSize).
@@ -190,11 +238,13 @@
// Fetch the saved draft alongside state and history (best-effort) so the composition is // Fetch the saved draft alongside state and history (best-effort) so the composition is
// applied in the same tick the board appears — never as a second, visible rack→board step. // applied in the same tick the board appears — never as a second, visible rack→board step.
const [st, hist, draft] = await Promise.all([ const [st, hist, draft] = await Promise.all([
gateway.gameState(id, includeAlphabet), source.gameState(id, includeAlphabet),
gateway.gameHistory(id), source.gameHistory(id),
gateway.draftGet(id).catch(() => ''), source.draftGet(id).catch(() => ''),
]); ]);
view = st; 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); syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it). // Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages); seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
@@ -220,7 +270,7 @@
setCachedDraft(id, json); setCachedDraft(id, json);
if (draftSaveTimer) clearTimeout(draftSaveTimer); if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => { draftSaveTimer = setTimeout(() => {
void gateway.draftSave(id, json).catch(() => {}); void source.draftSave(id, json).catch(() => {});
}, 500); }, 500);
} }
// applyDraft restores the player's saved composition over a freshly loaded state: the rack // applyDraft restores the player's saved composition over a freshly loaded state: the rack
@@ -247,6 +297,9 @@
if (cached) { if (cached) {
view = cached.view; view = cached.view;
moves = cached.moves; 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 // 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. // already on the board (no full-rack-then-jump). load() then refreshes in the background.
applyDraft(cached.view, cached.draft ?? ''); applyDraft(cached.view, cached.draft ?? '');
@@ -264,6 +317,12 @@
dict = m; dict = m;
}); });
} }
// A local (offline) game has no live stream: route the source's robot-reply events through the
// same app event hub the network stream feeds, so the event effect above reacts to
// opponent_moved / game_over identically.
if (isLocalGameId(id)) {
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
}
}); });
// Warm the game's dictionary for the local move preview once, when both the game and the // Warm the game's dictionary for the local move preview once, when both the game and the
@@ -309,7 +368,11 @@
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch. // the move as a delta with no fetch.
if (placement.pending.length > 0) void load(); if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); 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) { } else if (e.kind === 'your_turn' && e.gameId === id) {
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch // 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). // only if we missed the move (our cached count trails the event's).
@@ -611,8 +674,13 @@
// Flush a pending draft save so leaving mid-composition still persists it. // Flush a pending draft save so leaving mid-composition still persists it.
if (draftSaveTimer) { if (draftSaveTimer) {
clearTimeout(draftSaveTimer); clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); 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) { function onCell(row: number, col: number) {
@@ -717,8 +785,8 @@
// rapid placements do not pile up requests on a slow link. // rapid placements do not pile up requests on a slow link.
evalCtrl?.abort(); evalCtrl?.abort();
evalCtrl = null; evalCtrl = null;
// Off-turn the composition is position-only: no score preview or evaluate. // Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
if (!isMyTurn) return; if (!canMove) return;
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
// Instant on-device preview when the game's dictionary is warm; the network otherwise. // Instant on-device preview when the game's dictionary is warm; the network otherwise.
@@ -740,7 +808,7 @@
evalCtrl = ctrl; evalCtrl = ctrl;
previewTimer = setTimeout(async () => { previewTimer = setTimeout(async () => {
try { try {
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal); preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
notePreviewNetwork(); notePreviewNetwork();
} catch { } catch {
/* best-effort (or aborted) */ /* best-effort (or aborted) */
@@ -776,12 +844,106 @@
refreshRecent(); 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() { async function commit() {
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
haptic('success'); haptic('success');
zoomed = false; zoomed = false;
} catch (e) { } catch (e) {
@@ -800,7 +962,8 @@
async function doPass() { async function doPass() {
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.pass(id)); applyMoveResult(await source.pass(id));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -821,7 +984,7 @@
resignOpen = false; resignOpen = false;
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.resign(id)); applyMoveResult(await source.resign(id));
// Reveal the final board once the game is resigned: close the move-history drawer // Reveal the final board once the game is resigned: close the move-history drawer
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified. // (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
historyOpen = false; historyOpen = false;
@@ -833,8 +996,17 @@
} }
} }
async function doHint() { async function doHint() {
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
if (view?.game.vsAi) {
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
if (remaining > 0) {
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
return;
}
}
try { try {
const h = await gateway.hint(id); const h = await source.hint(id);
if (h.move.tiles.length && view) { if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack); placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left: // Scroll the (zoomed) board to the hint's placement rather than the top-left:
@@ -869,6 +1041,12 @@
// The backend does not spend a hint when there is no move. // The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') { if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info'); 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 { } else {
handleError(e); handleError(e);
} }
@@ -906,7 +1084,8 @@
exchangeOpen = false; exchangeOpen = false;
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.exchange(id, tiles, variant)); applyMoveResult(await source.exchange(id, tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -928,7 +1107,15 @@
if (!view) return ''; if (!view) return '';
const me = view.game.seats[view.seat]; const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won'); if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
const myScore = me?.score ?? 0;
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
return t('game.lost');
}
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
// all-level finish is a draw.
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
} }
// The finished-game export offers two formats — the GCG file and the server-rendered PNG // The finished-game export offers two formats — the GCG file and the server-rendered PNG
@@ -1244,8 +1431,9 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player. // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
if (view?.game.vsAi) return false; // a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false;
return ( return (
!!s.accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
@@ -1259,7 +1447,7 @@
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion. // may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
// An already-blocked opponent hides it (both controls go, and the name is struck). // An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean { function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi) return false; if (view?.game.vsAi || isLocalGameId(id)) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s); return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
} }
</script> </script>
@@ -1317,7 +1505,7 @@
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if} {#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div> <div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
<div class="sc" class:blockprompt={blockConfirm[s.seat]}> <div class="sc" class:blockprompt={blockConfirm[s.seat]}>
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if} {#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div> </div>
@@ -1361,13 +1549,18 @@
{:else} {:else}
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button> <button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
{/if} {/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} {:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if} {#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the <!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). --> chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
{#if !(gameOver && view.game.vsAi)} — 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')}> <button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key} {#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button> </button>
@@ -1438,9 +1631,12 @@
<div class="status"> <div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span> <span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver} {#if gameOver}
<strong class="over">{resultText()}</strong> <!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
game keeps the "you won/lost" text (one human). -->
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
{:else if placement.pending.length === 0} {:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span> <span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
@@ -1454,6 +1650,11 @@
a finished game shows the final rack greyed out and the controls disabled. --> 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-row" class:inert={gameOver} data-coach="game-rack">
<div class="rack-wrap"> <div class="rack-wrap">
{#if locked}
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
above stays visible; only this tray is gated. -->
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
{:else}
<Rack <Rack
slots={rackSlots} slots={rackSlots}
{variant} {variant}
@@ -1464,26 +1665,43 @@
dropIndex={reorderTo} dropIndex={reorderTo}
ondown={onRackDown} ondown={onRackDown}
/> />
{/if}
</div> </div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} {#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !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} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet controlButtons()} {#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !connection.online} 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> <span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button> </button>
{#if view?.game.hotseat}
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
(even on a locked seat: the host may skip a player who forgot their PIN). -->
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
</button>
{:else if view?.game.vsAi}
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</button>
{:else}
<TapConfirm <TapConfirm
triggerClass="tab" triggerClass="tab"
label={t('game.hint')} label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0} disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint} onconfirm={doHint}
> >
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span> <span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span> <span class="lbl">{t('game.hint')}</span>
</TapConfirm> </TapConfirm>
{/if}
{#if placement.pending.length > 0} {#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}> <button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span> <span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
@@ -1536,11 +1754,60 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}> <Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row"> <div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button> <button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button> <button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
</div> </div>
</Modal> </Modal>
{/if} {/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} {#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}> <Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts"> <div class="export-opts">
@@ -1548,7 +1815,7 @@
<button <button
class="confirm" class="confirm"
onclick={() => { exportOpen = false; void exportArtifact('png'); }} onclick={() => { exportOpen = false; void exportArtifact('png'); }}
disabled={!connection.online} disabled={!netReady}
> >
{t('game.exportImageOpt')} {t('game.exportImageOpt')}
</button> </button>
@@ -1556,7 +1823,7 @@
<button <button
class={exportImageAvailable ? 'export-alt' : 'confirm'} class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }} onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
disabled={!connection.online} disabled={!netReady}
> >
{t('game.exportGcgOpt')} {t('game.exportGcgOpt')}
</button> </button>
@@ -1610,6 +1877,10 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
.medal {
margin-right: 3px;
}
.sc { .sc {
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -2031,4 +2302,76 @@
.scoreboard.flat { .scoreboard.flat {
cursor: default; cursor: default;
} }
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
.sq .lock {
position: absolute;
top: -4px;
right: -4px;
font-size: 0.62rem;
line-height: 1;
}
/* --- offline hotseat --- */
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
stays visible. */
.unlock {
width: 100%;
height: 100%;
min-height: 44px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.host-menu {
display: flex;
flex-direction: column;
gap: 8px;
}
.host-act,
.host-seat {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-weight: 600;
text-align: left;
}
.host-act.danger {
border-color: var(--danger);
color: var(--danger);
}
.host-sub {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
.host-q {
margin: 0 0 12px;
font-weight: 600;
}
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
.host-done {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
z-index: 50;
pointer-events: none;
animation: host-done-fade 1.1s ease forwards;
}
@keyframes host-done-fade {
0% { opacity: 0; transform: scale(0.7); }
25% { opacity: 1; transform: scale(1); }
75% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1); }
}
</style> </style>
+1
View File
@@ -17,6 +17,7 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js'; export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { DictVersion } from './scrabblefb/dict-version.js';
export { DraftRequest } from './scrabblefb/draft-request.js'; export { DraftRequest } from './scrabblefb/draft-request.js';
export { DraftView } from './scrabblefb/draft-view.js'; export { DraftView } from './scrabblefb/draft-view.js';
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js'; export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
+60
View File
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class DictVersion {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):DictVersion {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
version():string|null
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
version(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startDictVersion(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, variantOffset, 0);
}
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, versionOffset, 0);
}
static endDictVersion(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createDictVersion(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset {
DictVersion.startDictVersion(builder);
DictVersion.addVariant(builder, variantOffset);
DictVersion.addVersion(builder, versionOffset);
return DictVersion.endDictVersion(builder);
}
}
+28 -1
View File
@@ -3,6 +3,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { BannerInfo } from '../scrabblefb/banner-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.js';
export class Profile { export class Profile {
@@ -124,8 +125,18 @@ vkLinked():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
} }
dictVersions(index: number, obj?:DictVersion):DictVersion|null {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? (obj || new DictVersion()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
dictVersionsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startProfile(builder:flatbuffers.Builder) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(16); builder.startObject(17);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -204,6 +215,22 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
builder.addFieldInt8(15, +vkLinked, +false); builder.addFieldInt8(15, +vkLinked, +false);
} }
static addDictVersions(builder:flatbuffers.Builder, dictVersionsOffset:flatbuffers.Offset) {
builder.addFieldOffset(16, dictVersionsOffset, 0);
}
static createDictVersionsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
+12 -2
View File
@@ -74,8 +74,13 @@ walletBalance():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; 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) { static startStateView(builder:flatbuffers.Builder) {
builder.startObject(7); builder.startObject(8);
} }
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
@@ -130,12 +135,16 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
builder.addFieldInt32(6, walletBalance, 0); builder.addFieldInt32(6, walletBalance, 0);
} }
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
}
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startStateView(builder);
StateView.addGame(builder, gameOffset); StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat); StateView.addSeat(builder, seat);
@@ -144,6 +153,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
StateView.addHintsRemaining(builder, hintsRemaining); StateView.addHintsRemaining(builder, hintsRemaining);
StateView.addAlphabet(builder, alphabetOffset); StateView.addAlphabet(builder, alphabetOffset);
StateView.addWalletBalance(builder, walletBalance); StateView.addWalletBalance(builder, walletBalance);
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
return StateView.endStateView(builder); return StateView.endStateView(builder);
} }
} }
+188 -13
View File
@@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink';
import { import {
clearOnboarding, clearOnboarding,
clearSession, clearSession,
clearProfile,
loadProfile,
saveProfile,
loadOnboarding, loadOnboarding,
loadPrefs, loadPrefs,
loadSession, loadSession,
@@ -52,7 +55,9 @@ import {
savePrefs, savePrefs,
} from './session'; } from './session';
import type { CoachSeries } from './coachmark'; import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { isConnectionCode } from './retry'; import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta'; import { advanceCached } from './gamedelta';
@@ -73,6 +78,10 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen * backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login a Mini App has no manual sign-in to fall back to. */ * instead of the web login a Mini App has no manual sign-in to fall back to. */
bootError: boolean; bootError: boolean;
/** True while the cold-start "no connection go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App /** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact * launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) a shareable probe for why Telegram * launch-error screen (screens/TelegramLaunchError) a shareable probe for why Telegram
@@ -143,6 +152,7 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
offlinePrompt: false,
launchError: null, launchError: null,
debugOpen: false, debugOpen: false,
lobbyReady: false, lobbyReady: false,
@@ -294,7 +304,9 @@ export function handleError(err: unknown): void {
void enterBlocked(); void enterBlocked();
return; return;
} }
if (isConnectionCode(code) || !connection.online) return; // A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
// to surface — the offline chrome already signals the state; never a red toast.
if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
haptic('error'); haptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
} }
@@ -460,6 +472,7 @@ export async function refreshProfile(): Promise<void> {
if (!app.session) return; if (!app.session) return;
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
} catch { } catch {
// Best-effort; the banner just stays as it was until the next fetch. // Best-effort; the banner just stays as it was until the next fetch.
} }
@@ -517,6 +530,11 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s); await saveSession(s);
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// Persist the profile so a later offline cold start can launch from it (its variant
// preferences, dictionary versions and display name) without a network fetch. Snapshot to a
// plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
void saveProfile($state.snapshot(app.profile));
// The live interface language follows the device — the explicit local choice (saved in // The live interface language follows the device — the explicit local choice (saved in
// prefs) or the system guess made at bootstrap — and is no longer overridden from the // prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here: the Telegram bot a user signs in through must not dictate the UI, so a // account here: the Telegram bot a user signs in through must not dictate the UI, so a
@@ -540,6 +558,74 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications(); void refreshNotifications();
} }
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/**
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
* losing the network interface enters offline mode automatically (session-only); regaining it
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
* to online but only if the offline was auto. A deliberate offline (the Settings toggle or the
* cold-start dialog) is left as the player's choice. These are passive OS events no polling, no
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
*/
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleRecovery(delayMs: number): void {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
recoveryTimer = null;
}
if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return;
recoveryTimer = setTimeout(async () => {
recoveryTimer = null;
if (!offlineMode.active || !offlineMode.auto) return;
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
return;
}
scheduleRecovery(4000);
}, delayMs);
}
export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => {
if (!offlineMode.active) {
setOfflineMode(true, false); // auto (session)
scheduleRecovery(4000); // poll for the network to return
}
});
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
// the reliable fallback for platforms where it does not fire.
window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
});
}
/** /**
* applyLinkResult applies a completed account link or merge: it adopts a * applyLinkResult applies a completed account link or merge: it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active * switched session (a guest initiator whose durable counterpart won, so the active
@@ -639,17 +725,37 @@ function syncTelegramSafeArea(): void {
root.classList.toggle('tg-fullscreen', top > 0); root.classList.toggle('tg-fullscreen', top > 0);
} }
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
const KEYBOARD_MIN_PX = 120;
/** /**
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
* without the page scrolling, so the layout no longer jumps when the keyboard appears. * Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
* misaligned on iOS empty space below the content, worst on a repeat focus; tracking offsetTop too
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
*/ */
function syncViewportHeight(): void { let keyboardOpen = false;
function syncViewport(): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null; const win = typeof window !== 'undefined' ? window : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; const vv = win ? win.visualViewport : null;
const h = vv ? vv.height : win ? win.innerHeight : 0;
const top = vv ? vv.offsetTop : 0;
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
document.documentElement.style.setProperty('--vv-top', `${top}px`);
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
if (open && !keyboardOpen) {
const el = document.activeElement;
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
}
}
keyboardOpen = open;
} }
/** /**
@@ -709,11 +815,23 @@ export async function bootstrap(): Promise<void> {
setLocale(guess); setLocale(guess);
} }
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). // Track the visual viewport (height + offset) so screens fit and follow the visible area above an
syncViewportHeight(); // open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
// the visual viewport.
syncViewport();
if (typeof window !== 'undefined' && window.visualViewport) { if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight); window.visualViewport.addEventListener('resize', syncViewport);
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewport);
}
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
// fires first (this listener is on the document, so it runs after the field's own handler).
if (typeof document !== 'undefined') {
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
});
} }
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
@@ -789,12 +907,61 @@ export async function bootstrap(): Promise<void> {
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only // worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte. // and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker(); registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
initNetworkReactivity();
// Deliberate offline mode carried over from a prior session: with no network the session adoption
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
const saved = await loadSession(); const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against). // from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink(); const vkcb = pendingVKLink();
if (saved) { if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
}
}
await adoptSession(saved); await adoptSession(saved);
if (vkcb) { if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which // The full-page redirect lost the in-app route, so hand the callback to Profile, which
@@ -1023,6 +1190,7 @@ export async function logout(): Promise<void> {
app.splashDone = false; app.splashDone = false;
gateway.setToken(null); gateway.setToken(null);
await clearSession(); await clearSession();
await clearProfile();
app.session = null; app.session = null;
app.profile = null; app.profile = null;
navigate('/login'); navigate('/login');
@@ -1174,4 +1342,11 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
closeStream(); closeStream();
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b }; 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,
};
} }
+22
View File
@@ -712,6 +712,28 @@ describe('codec', () => {
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']); expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
}); });
it('decodes the profile dictionary versions into a per-variant map', () => {
const b = new Builder(128);
const uid = b.createString('u-1');
const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addDictVersions(b, vec);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
});
it('decodes an absent dict_versions vector to an empty map', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
});
it('encodes the update-profile variant preferences', () => { it('encodes the update-profile variant preferences', () => {
const buf = encodeUpdateProfile({ const buf = encodeUpdateProfile({
displayName: 'Kaya', displayName: 'Kaya',
+17
View File
@@ -364,9 +364,25 @@ export function decodeProfile(buf: Uint8Array): Profile {
email: s(p.email()), email: s(p.email()),
telegramLinked: p.telegramLinked(), telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(), vkLinked: p.vkLinked(),
dictVersions: decodeDictVersions(p),
}; };
} }
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
// variant-keyed map, so the offline preloader can look up the version for each enabled
// variant. An entry with an unknown variant or empty version is skipped.
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
const out: Partial<Record<Variant, string>> = {};
for (let i = 0; i < p.dictVersionsLength(); i++) {
const dv = p.dictVersions(i);
if (!dv) continue;
const variant = s(dv.variant()) as Variant;
const version = s(dv.version());
if (variant && version) out[variant] = version;
}
return out;
}
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants // decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings). // the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] { function decodeVariantPreferences(p: fb.Profile): Variant[] {
@@ -445,6 +461,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
bagLen: v.bagLen(), bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(), hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(), walletBalance: v.walletBalance(),
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
}; };
} }
+22 -1
View File
@@ -9,6 +9,7 @@
// other traffic is in flight. // other traffic is in flight.
import { backoffMs } from './retry'; import { backoffMs } from './retry';
import { offlineMode } from './offline.svelte';
let online = $state(true); let online = $state(true);
let watchTimer: ReturnType<typeof setTimeout> | null = null; let watchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -27,6 +28,24 @@ export function registerProbe(fn: () => Promise<void>): void {
probe = fn; probe = fn;
} }
/**
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered a single attempt (no retry loop), for the cold-start network decision while
* updating the online signal. It reports false when no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probe) return false;
try {
await Promise.race([
probe(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
}
/** reportOnline marks the gateway reachable and stops the watcher. */ /** reportOnline marks the gateway reachable and stops the watcher. */
export function reportOnline(): void { export function reportOnline(): void {
online = true; online = true;
@@ -51,7 +70,9 @@ function scheduleProbe(attempt: number): void {
watchTimer = setTimeout( watchTimer = setTimeout(
() => { () => {
watchTimer = null; watchTimer = null;
if (online || !probe) return; // Never probe the network in offline mode (the kill switch); the online/offline events drive
// recovery there instead.
if (online || !probe || offlineMode.active) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6))); probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
}, },
backoffMs(attempt), backoffMs(attempt),
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from './dawg';
// The step-by-step DAWG cursor (root/final/next/arcs) is the primitive the move
// generator walks. These fast unit tests pin it against a small committed sample
// dictionary (backend/cmd/movegen); the full parity vs the Go solver lands with
// the generator's conformance fixtures.
const bytes = new Uint8Array(readFileSync(new URL('./testdata/sample_en.dawg', import.meta.url)));
const fixture = JSON.parse(
readFileSync(new URL('./testdata/sample_en.words.json', import.meta.url), 'utf8'),
) as { numAdded: number; words: string[]; indexes: number[][] };
const key = (w: number[]): string => w.join(',');
// enumerateWords walks the whole automaton depth-first, collecting the index path
// at every accepting node — i.e. every stored word.
function enumerateWords(d: Dawg): number[][] {
const out: number[][] = [];
const path: number[] = [];
const visit = (node: number): void => {
d.arcs(node, (label, dest, final) => {
path.push(label);
if (final) out.push(path.slice());
visit(dest);
path.pop();
return true;
});
};
if (d.final(d.root())) out.push([]);
visit(d.root());
return out;
}
describe('dawg cursor', () => {
it('parses the sample fixture', () => {
const d = new Dawg(bytes);
expect(d.numAdded).toBe(fixture.numAdded);
});
it('root is not an accepting state (the sample has no empty word)', () => {
const d = new Dawg(bytes);
expect(d.final(d.root())).toBe(false);
});
it('enumerates exactly the stored words', () => {
const d = new Dawg(bytes);
const got = enumerateWords(d).map(key).sort();
const want = fixture.indexes.map(key).sort();
expect(got).toEqual(want);
});
it('next walks a stored word to an accepting node and rejects a non-edge', () => {
const d = new Dawg(bytes);
let node = d.root();
for (const ch of [2, 0, 17, 4, 3]) {
// "cared"
node = d.next(node, ch);
expect(node).toBeGreaterThanOrEqual(0);
}
expect(d.final(node)).toBe(true);
// "care" (index [2,0,17,4]) is an internal accepting node on the way to "cared".
const care = [2, 0, 17, 4].reduce((n, ch) => d.next(n, ch), d.root());
expect(d.final(care)).toBe(true);
// No stored word starts with 'z' (index 25).
expect(d.next(d.root(), 25)).toBe(-1);
});
});
+79
View File
@@ -95,6 +95,85 @@ export class Dawg {
return this.indexOf(word) >= 0; return this.indexOf(word) >= 0;
} }
// --- Step-by-step traversal (the move generator's primitive) ---------------
//
// A `Node` is a bit offset into the graph; 0 denotes the root (which resolves
// to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/
// Next/Arcs) over the same bitstream this reader already decodes, so the ported
// generator can drive the automaton one transition at a time. Single-threaded
// JS shares this reader's position across calls; every method re-seeks to its
// node on entry, and arcs brackets the callback with a save/restore, so nested
// use during a walk is safe. Mirrors dafsa (*Cursor).
/** root returns the start state of the automaton. */
root(): number {
return 0;
}
/** final reports whether node is an accepting state (a stored word ends there). */
final(node: number): boolean {
if (this.numEdges <= 0) {
return this.hasEmptyWord && node === 0;
}
this.p = node === 0 ? this.firstNodeOffset : node;
return this.readBits(1) === 1;
}
/**
* next follows the edge labelled ch (an alphabet index) from node, returning the
* destination node, or -1 when no such edge exists.
*/
next(node: number, ch: number): number {
return this.getEdge(node, ch) ? this.eNode : -1;
}
/**
* arcs calls fn for each out-edge of node in ascending label order, passing the
* edge's label, its destination node and whether that destination is accepting.
* It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs.
*/
arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void {
if (this.numEdges <= 0) {
return;
}
this.p = node === 0 ? this.firstNodeOffset : node;
this.readBits(1); // node final flag — not needed here
const fallthrough = this.readBits(1);
if (fallthrough === 1) {
const label = this.readBits(this.cbits);
// The reader now sits at the destination node, whose first bit is its final flag.
const dest = this.p;
const final = this.readBits(1) === 1;
fn(label, dest, final);
return;
}
const nskiplen = bitsLen(this.wbits);
let nskip = 0;
let numEdges = 1;
if (this.readBits(1) !== 1) {
// not a single edge
numEdges = this.readUnsigned();
nskip = this.readBits(nskiplen);
}
for (let i = 0; i < numEdges; i++) {
const label = this.readBits(this.cbits);
if (i > 0) {
this.readBits(nskip); // per-edge skip count, unused for traversal
}
const dest = this.readBits(this.abits);
const resume = this.p;
this.p = dest;
const final = this.readBits(1) === 1;
if (!fn(label, dest, final)) {
return;
}
this.p = resume;
}
}
// getEdge resolves the outgoing edge for ch from the node at the given bit // getEdge resolves the outgoing edge for ch from the node at the given bit
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors // offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
// dafsa (*dawg).getEdge. // dafsa (*dawg).getEdge.
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from './dawg';
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
import type { Ruleset } from './validate';
// Conformance gate for the ported move generator: for each committed position it must
// return exactly the ranked play list the real Go solver returns (backend/cmd/movegen).
// The Russian sample reaches alphabet index 32, exercising the 33-letter cross-set.
interface Tile {
row: number;
col: number;
letter: number;
blank: boolean;
}
interface GenMove {
dir: number;
tiles: Tile[];
score: number;
}
interface Fixture {
ruleset: {
size: number;
cols: number;
center: number;
rackSize: number;
bingo: number;
values: number[];
letterMult: number[][];
wordMult: number[][];
};
cases: {
name: string;
placed: Tile[] | null;
rack: { letters: number[]; blanks: number };
mode: number;
ignoreCrossWords: boolean;
moves: GenMove[];
}[];
}
function load(tag: string): { dawg: Dawg; fx: Fixture } {
const bytes = new Uint8Array(readFileSync(new URL(`./testdata/sample_${tag}.dawg`, import.meta.url)));
const fx = JSON.parse(
readFileSync(new URL(`./testdata/sample_${tag}.gen.json`, import.meta.url), 'utf8'),
) as Fixture;
return { dawg: new Dawg(bytes), fx };
}
function buildBoard(placed: Tile[], cols: number): GenBoard {
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
return {
rows: cols,
cols,
inBounds,
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
cellAt: (r, c) => grid[r * cols + c]!,
isEmpty: () => placed.length === 0,
};
}
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
const r = fx.ruleset;
return {
cols: r.cols,
center: r.center,
rackSize: r.rackSize,
bingo: r.bingo,
values: r.values,
letterMult: (row, col) => r.letterMult[row][col],
wordMult: (row, col) => r.wordMult[row][col],
ignoreCrossWords,
};
}
// sig is an order-stable signature of a move: orientation, score and its placed tiles
// sorted by square — so two lists compare equal iff they rank the same plays the same way.
function sig(m: GenMove): string {
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
}
for (const tag of ['en', 'ru']) {
describe(`move generator parity vs Go solver (${tag})`, () => {
const { dawg, fx } = load(tag);
for (const c of fx.cases) {
it(c.name, () => {
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
const rs = rulesetFor(fx, c.ignoreCrossWords);
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
expect(got.map(sig)).toEqual(c.moves.map(sig));
});
}
});
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { Dawg } from './dawg';
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
import type { Ruleset } from './validate';
// Full-dictionary conformance for the ported move generator: for positions over the real
// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed
// samples cannot reach) it must return exactly the ranked list the Go solver does. The
// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir <dawg> -out <dir>` and
// the CI conformance job wires the two directories in; the suite skips when they are unset.
const dawgDir = process.env.DICT_DAWG_DIR;
const goldDir = process.env.DICT_MOVEGEN_DIR;
const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir);
// variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool).
const variants = [
{ variant: 'scrabble_en', dawg: 'en_sowpods' },
{ variant: 'scrabble_ru', dawg: 'ru_scrabble' },
{ variant: 'erudit_ru', dawg: 'ru_erudit' },
];
interface Tile {
row: number;
col: number;
letter: number;
blank: boolean;
}
interface GenMove {
dir: number;
tiles: Tile[];
score: number;
}
interface Fixture {
ruleset: {
size: number;
cols: number;
center: number;
rackSize: number;
bingo: number;
values: number[];
letterMult: number[][];
wordMult: number[][];
};
cases: {
name: string;
placed: Tile[] | null;
rack: { letters: number[]; blanks: number };
mode: number;
ignoreCrossWords: boolean;
moves: GenMove[];
}[];
}
function buildBoard(placed: Tile[], cols: number): GenBoard {
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
return {
rows: cols,
cols,
inBounds,
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
cellAt: (r, c) => grid[r * cols + c]!,
isEmpty: () => placed.length === 0,
};
}
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
const r = fx.ruleset;
return {
cols: r.cols,
center: r.center,
rackSize: r.rackSize,
bingo: r.bingo,
values: r.values,
letterMult: (row, col) => r.letterMult[row][col],
wordMult: (row, col) => r.wordMult[row][col],
ignoreCrossWords,
};
}
function sig(m: GenMove): string {
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
}
describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => {
for (const v of variants) {
describe(v.variant, () => {
// Guard the collection-time reads: the block is skipped when the dirs are unset, but
// the describe callback still runs to register tests, so touch the filesystem only
// when ready (otherwise join(undefined, …) would throw during a normal unit run).
if (!ready) return;
const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture;
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`))));
for (const c of fx.cases) {
it(
c.name,
() => {
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
const rs = rulesetFor(fx, c.ignoreCrossWords);
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
expect(got.map(sig)).toEqual(c.moves.map(sig));
},
60_000,
);
}
});
}
});
+418
View File
@@ -0,0 +1,418 @@
// Local move generator: every legal play for a rack on a board, ranked by
// descending score. Ported from the scrabble-solver engine — the Appel-Jacobson
// two-phase algorithm (LeftPart then ExtendRight) over a plain left-to-right DAWG
// (scrabble/gen_dawg.go, gen.go, crossset.go, solver.go, key.go). It walks the DAWG
// with the cursor from dawg.ts and scores each play with evaluate() from validate.ts,
// so the whole robot brain runs on-device. Faithfulness to the Go solver is pinned by
// generate.parity.test.ts against golden fixtures (backend/cmd/movegen).
//
// Everything works in alphabet-index space, mirroring the Go engine. A letterSet is
// a per-square cross-set (the letters that form a legal perpendicular word there);
// it is a boolean membership array rather than a uint64, so alphabet indexes past
// JS's 31-bit shift boundary (Russian has 33 letters) are handled exactly.
import { Dawg } from './dawg';
import {
evaluate,
connected,
Horizontal,
Vertical,
type Board,
type Ruleset,
type Move,
type Placement,
type Direction,
} from './validate';
/** Both generates across plays (on the board) and down plays (on its transpose). */
export const Both = 0;
/** OnlyHorizontal generates across plays only. */
export const OnlyHorizontal = 1;
/** OnlyVertical generates down plays only (Эрудит plays a single orientation per turn). */
export const OnlyVertical = 2;
export type Mode = typeof Both | typeof OnlyHorizontal | typeof OnlyVertical;
function modeIncludes(mode: Mode, dir: Direction): boolean {
if (mode === Both) return true;
if (mode === OnlyHorizontal) return dir === Horizontal;
return dir === Vertical;
}
/**
* GenBoard is the board view the generator needs: the validator's read view plus the
* dimensions it iterates over. The whole board is square, so a transposed view (below)
* satisfies the same shape.
*/
export interface GenBoard extends Board {
rows: number;
cols: number;
}
/**
* GenRack is a rack as per-letter tile counts plus a blank slot, mirroring
* scrabble-solver/rack. The generator mutates a single rack in place removing a
* tile, recursing, putting it back so the operations are O(1) and allocation-free.
*/
export class GenRack {
private readonly counts: Int32Array;
/** Construct an empty rack for an alphabet of the given size (a trailing blank slot). */
constructor(size: number) {
this.counts = new Int32Array(size + 1);
}
/** from builds a rack from a multiset of letter indexes plus a blank count. */
static from(size: number, letters: readonly number[], blanks: number): GenRack {
const r = new GenRack(size);
for (const l of letters) r.counts[l]++;
r.counts[size] += blanks;
return r;
}
/** has reports whether at least one tile of the letter index is on the rack. */
has(letter: number): boolean {
return this.counts[letter] > 0;
}
/** blanks returns how many blank tiles are on the rack. */
blanks(): number {
return this.counts[this.counts.length - 1];
}
remove(letter: number): void {
this.counts[letter]--;
}
add(letter: number): void {
this.counts[letter]++;
}
removeBlank(): void {
this.counts[this.counts.length - 1]--;
}
addBlank(): void {
this.counts[this.counts.length - 1]++;
}
/** clone returns an independent copy. */
clone(): GenRack {
const c = new GenRack(this.counts.length - 1);
c.counts.set(this.counts);
return c;
}
}
// --- cross-sets ------------------------------------------------------------------
//
// A LetterSet is membership over alphabet letter indexes. Mirrors scrabble.letterSet
// (a uint64) but as a Uint8Array so index 32 (Russian) is exact under JS bit ops.
type LetterSet = Uint8Array;
function fullSet(size: number): LetterSet {
return new Uint8Array(size).fill(1);
}
// walk follows word (alphabet indexes) left to right from the root; -1 if it derails.
function walk(dawg: Dawg, word: readonly number[]): number {
let n = dawg.root();
for (const l of word) {
n = dawg.next(n, l);
if (n < 0) return -1;
}
return n;
}
// completers returns the letters X (< size) whose arc from state leads directly to an
// accepting node — the deterministic cross-set primitive. Mirrors scrabble.completers.
function completers(dawg: Dawg, state: number, size: number): LetterSet {
const set = new Uint8Array(size);
dawg.arcs(state, (label, _dest, final) => {
if (final && label < size) set[label] = 1;
return true;
});
return set;
}
// dawgCrossSet returns the letters X for which above·X·below is a stored word. Mirrors
// scrabble.dawgCrossSet: a right extension (no tiles below) just completes the prefix
// above; a left extension (tiles below) probes each X. above/below are letter indexes.
function dawgCrossSet(dawg: Dawg, above: number[], below: number[], size: number): LetterSet {
if (above.length === 0 && below.length === 0) return fullSet(size);
if (below.length === 0) {
const node = walk(dawg, above);
if (node < 0) return new Uint8Array(size);
return completers(dawg, node, size);
}
let node = dawg.root();
if (above.length > 0) {
node = walk(dawg, above);
if (node < 0) return new Uint8Array(size);
}
const set = new Uint8Array(size);
for (let x = 0; x < size; x++) {
let m = dawg.next(node, x);
if (m < 0) continue;
let ok = true;
for (const l of below) {
m = dawg.next(m, l);
if (m < 0) {
ok = false;
break;
}
}
if (ok && dawg.final(m)) set[x] = 1;
}
return set;
}
// columnContext returns the contiguous run of filled cells immediately above and below
// the empty square (r, c), each top to bottom, as letter indexes — the tiles a
// perpendicular word through (r, c) would include. Mirrors scrabble.columnContext.
function columnContext(b: GenBoard, r: number, c: number): { above: number[]; below: number[] } {
const above: number[] = [];
let start = r;
while (start - 1 >= 0 && b.filled(start - 1, c)) start--;
for (let rr = start; rr < r; rr++) above.push(b.cellAt(rr, c).letter);
const below: number[] = [];
let end = r;
while (end + 1 < b.rows && b.filled(end + 1, c)) end++;
for (let rr = r + 1; rr <= end; rr++) below.push(b.cellAt(rr, c).letter);
return { above, below };
}
// transpose returns a view of b with rows and columns swapped, so down-play generation
// runs as across generation. Mirrors board.Transpose (as a lazy view).
function transpose(b: GenBoard): GenBoard {
return {
rows: b.cols,
cols: b.rows,
inBounds: (r, c) => b.inBounds(c, r),
filled: (r, c) => b.filled(c, r),
cellAt: (r, c) => b.cellAt(c, r),
isEmpty: () => b.isEmpty(),
};
}
// A tentatively placed left-part tile; its column is fixed only at record time.
interface TileInfo {
letter: number;
blank: boolean;
}
// AcrossGen carries one across-generation pass over a board. Mirrors scrabble.acrossGen.
class AcrossGen {
private row = 0;
private readonly left: TileInfo[] = [];
private readonly right: Placement[] = [];
constructor(
private readonly dawg: Dawg,
private readonly bd: GenBoard,
private readonly rk: GenRack,
private readonly cross: (r: number, c: number) => LetterSet,
private readonly emit: (placements: Placement[]) => void,
) {}
generateRow(row: number, firstMove: boolean, centerRow: number, centerCol: number): void {
this.row = row;
let limit = 0;
for (let col = 0; col < this.bd.cols; col++) {
if (this.bd.filled(row, col)) {
limit = 0;
continue;
}
const anchor = firstMove ? row === centerRow && col === centerCol : this.hasFilledNeighbor(row, col);
if (!anchor) {
limit++;
continue;
}
this.left.length = 0;
this.right.length = 0;
if (col > 0 && this.bd.filled(row, col - 1)) {
const pre = this.walkPrefix(row, col);
if (pre.ok) this.extendRight(pre.node, col, col);
} else {
this.leftPart(this.dawg.root(), col, limit);
}
limit = 0;
}
}
private hasFilledNeighbor(r: number, c: number): boolean {
return this.bd.filled(r - 1, c) || this.bd.filled(r + 1, c) || this.bd.filled(r, c - 1) || this.bd.filled(r, c + 1);
}
// walkPrefix walks the DAWG through the filled run ending at col-1, returning the
// node reached and whether that prefix exists. Mirrors scrabble.acrossGen.walkPrefix.
private walkPrefix(row: number, col: number): { node: number; ok: boolean } {
let start = col - 1;
while (start - 1 >= 0 && this.bd.filled(row, start - 1)) start--;
let node = this.dawg.root();
for (let c = start; c < col; c++) {
node = this.dawg.next(node, this.bd.cellAt(row, c).letter);
if (node < 0) return { node, ok: false };
}
return { node, ok: true };
}
// leftPart places left-part tiles from the rack (up to limit), calling extendRight
// after each prefix. Mirrors scrabble.acrossGen.leftPart.
private leftPart(node: number, anchorCol: number, limit: number): void {
this.extendRight(node, anchorCol, anchorCol);
if (limit === 0) return;
this.dawg.arcs(node, (label, dest) => {
if (this.rk.has(label)) {
this.rk.remove(label);
this.left.push({ letter: label, blank: false });
this.leftPart(dest, anchorCol, limit - 1);
this.left.pop();
this.rk.add(label);
}
if (this.rk.blanks() > 0) {
this.rk.removeBlank();
this.left.push({ letter: label, blank: true });
this.leftPart(dest, anchorCol, limit - 1);
this.left.pop();
this.rk.addBlank();
}
return true;
});
}
// extendRight extends the word rightward from col, placing rack tiles on empty
// squares (constrained by cross-sets) and following board tiles. A word is recorded
// only past the anchor. Mirrors scrabble.acrossGen.extendRight.
private extendRight(node: number, col: number, anchorCol: number): void {
if (col >= this.bd.cols) {
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
return;
}
if (this.bd.filled(this.row, col)) {
const dest = this.dawg.next(node, this.bd.cellAt(this.row, col).letter);
if (dest >= 0) this.extendRight(dest, col + 1, anchorCol);
return;
}
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
const cross = this.cross(this.row, col);
this.dawg.arcs(node, (label, dest) => {
if (cross[label] !== 1) return true;
if (this.rk.has(label)) {
this.rk.remove(label);
this.right.push({ row: this.row, col, letter: label, blank: false });
this.extendRight(dest, col + 1, anchorCol);
this.right.pop();
this.rk.add(label);
}
if (this.rk.blanks() > 0) {
this.rk.removeBlank();
this.right.push({ row: this.row, col, letter: label, blank: true });
this.extendRight(dest, col + 1, anchorCol);
this.right.pop();
this.rk.addBlank();
}
return true;
});
}
// record assembles the play (left part at fixed columns, then the right part) and
// reports it, skipping plays that lay no new tile. Mirrors scrabble.acrossGen.record.
private record(anchorCol: number): void {
if (this.left.length + this.right.length === 0) return;
const placements: Placement[] = [];
const leftStart = anchorCol - this.left.length;
for (let i = 0; i < this.left.length; i++) {
placements.push({ row: this.row, col: leftStart + i, letter: this.left[i].letter, blank: this.left[i].blank });
}
for (const p of this.right) placements.push(p);
this.emit(placements);
}
}
// runAcross generates all across plays on bd and reports each via emit in bd's
// coordinates. Cross-sets are computed lazily (vertical words on bd) and cached.
// Mirrors DAWGGenerator.runAcross.
function runAcross(
dawg: Dawg,
bd: GenBoard,
rk: GenRack,
size: number,
rs: Ruleset,
centerRow: number,
centerCol: number,
emit: (placements: Placement[]) => void,
): void {
let crossFn: (r: number, c: number) => LetterSet;
if (rs.ignoreCrossWords) {
const full = fullSet(size);
crossFn = () => full;
} else {
const cache = new Array<LetterSet | undefined>(bd.rows * bd.cols);
crossFn = (r, c) => {
const i = r * bd.cols + c;
let s = cache[i];
if (!s) {
const { above, below } = columnContext(bd, r, c);
s = dawgCrossSet(dawg, above, below, size);
cache[i] = s;
}
return s;
};
}
const ag = new AcrossGen(dawg, bd, rk, crossFn, emit);
const firstMove = bd.isEmpty();
for (let row = 0; row < bd.rows; row++) {
ag.generateRow(row, firstMove, centerRow, centerCol);
}
}
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
// used to de-duplicate and rank generated moves. Mirrors scrabble.moveKey.
export function moveKey(dir: Direction, placements: readonly Placement[]): string {
const ps = placements.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
let s = String(dir);
for (const p of ps) s += `;${p.row},${p.col},${p.letter}${p.blank ? '*' : ''}`;
return s;
}
/**
* generateMoves returns every legal play for rack on board in the mode's orientations,
* ranked by descending score (ties broken by the canonical move key). It walks the DAWG
* with the cursor and scores each play with evaluate(); the alphabet size is taken from
* the ruleset's value table. Mirrors (*Solver).GenerateMovesOpts (the ruleset carries
* ignoreCrossWords for the single-word rule).
*/
export function generateMoves(dawg: Dawg, board: GenBoard, rack: GenRack, rs: Ruleset, mode: Mode = Both): Move[] {
const size = rs.values.length;
const rk = rack.clone(); // generation mutates the rack in place and restores it
const centerRow = Math.floor(rs.center / rs.cols);
const centerCol = rs.center % rs.cols;
const moves: Move[] = [];
const seen = new Set<string>();
const emit = (dir: Direction, placements: Placement[]): void => {
const key = moveKey(dir, placements);
if (seen.has(key)) return;
const res = evaluate(board, rs, dir, placements);
if (res.err || !res.move) return;
seen.add(key);
moves.push(res.move);
};
if (modeIncludes(mode, Horizontal)) {
runAcross(dawg, board, rk, size, rs, centerRow, centerCol, (p) => emit(Horizontal, p));
}
if (modeIncludes(mode, Vertical)) {
const tb = transpose(board);
runAcross(dawg, tb, rk, size, rs, centerCol, centerRow, (p) => {
const rp = p.map((pl) => ({ row: pl.col, col: pl.row, letter: pl.letter, blank: pl.blank }));
emit(Vertical, rp);
});
}
const kept = moves.filter((m) => connected(board, rs, m));
kept.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
const ka = moveKey(a.dir, a.tiles);
const kb = moveKey(b.dir, b.tiles);
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
return kept;
}
+27
View File
@@ -0,0 +1,27 @@
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
// fetch running on a timeout so a later flip is instant.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { raceOfflineReady } from '../offline';
/**
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
* available the offline toggle's readiness gate. With no enabled variant there is nothing to play
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
* on-device cache in the background so a later flip to offline succeeds immediately.
*/
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
if (prof.variantPreferences.length === 0) return false;
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
retries: 1,
});
return raceOfflineReady(run, budgetMs);
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { preloadDicts } from './preload';
import type { Variant } from '../model';
import type { Dawg } from './dawg';
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
const DAWG = {} as Dawg;
const noSleep = (): Promise<void> => Promise.resolve();
describe('preloadDicts', () => {
it('fetches every enabled variant that has a known version', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
getDawg: async (v: Variant, ver: string) => {
calls.push(`${v}@${ver}`);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
expect(res.failed).toEqual([]);
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
});
it('marks a variant with no known version as failed without fetching it', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
getDawg: async (v: Variant) => {
calls.push(v);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(res.failed).toEqual(['scrabble_ru']);
expect(calls).toEqual(['scrabble_en']);
});
it('retries a transient failure with linear backoff, then succeeds', async () => {
let attempts = 0;
const waits: number[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => (++attempts >= 3 ? DAWG : null),
disabled: () => false,
sleep: async (ms: number) => void waits.push(ms),
retries: 3,
backoffMs: 100,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(attempts).toBe(3);
expect(waits).toEqual([100, 200]);
});
it('gives up a persistent failure after the retry budget', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => false,
sleep: noSleep,
retries: 2,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(res.ok).toEqual([]);
expect(attempts).toBe(3);
});
it('stops retrying once the session miss-breaker trips', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => true,
sleep: noSleep,
retries: 5,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(attempts).toBe(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
// warms the dictionaries for the player's enabled variants while online, so a later switch to
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
// generator is pulled into the main bundle.
import type { Variant } from '../model';
import type { Dawg } from './dawg';
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
export interface PreloadDeps {
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
* or null on any miss mirrors the in-game loader. */
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
* misses this session); when it has, a network fetch will not recover, so retries stop. */
disabled: () => boolean;
/** sleep waits between retries; defaults to a real timer. */
sleep?: (ms: number) => Promise<void>;
/** retries is the number of extra attempts after the first (default 2). */
retries?: number;
/** backoffMs is the base linear backoff between attempts (default 800). */
backoffMs?: number;
}
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
* missing (failed) after the preload the caller surfaces a warning when failed is non-empty. */
export interface PreloadResult {
ok: Variant[];
failed: Variant[];
}
/**
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
* version, retrying transient misses with linear backoff. A variant with no known version, or one
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
* recover this session a cached dictionary is still served by getDawg regardless.
*/
export async function preloadDicts(
versions: Partial<Record<Variant, string>>,
enabled: readonly Variant[],
deps: PreloadDeps,
): Promise<PreloadResult> {
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
const retries = deps.retries ?? 2;
const backoffMs = deps.backoffMs ?? 800;
const ok: Variant[] = [];
const failed: Variant[] = [];
for (const variant of enabled) {
const version = versions[variant];
if (!version) {
failed.push(variant);
continue;
}
let dawg: Dawg | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
dawg = await deps.getDawg(variant, version);
if (dawg || deps.disabled()) break;
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
}
(dawg ? ok : failed).push(variant);
}
return { ok, failed };
}
+24
View File
@@ -0,0 +1,24 @@
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { setDictPreloadWarning } from '../offline.svelte';
/**
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
* still missing afterwards, and clears it on a run where every variant is available.
*/
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
});
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
{
"alphabet": "en",
"numAdded": 18,
"words": [
"a",
"an",
"and",
"ant",
"car",
"care",
"cared",
"cares",
"cars",
"cat",
"cats",
"do",
"doe",
"does",
"dog",
"dogs",
"done",
"dot"
],
"indexes": [
[
0
],
[
0,
13
],
[
0,
13,
3
],
[
0,
13,
19
],
[
2,
0,
17
],
[
2,
0,
17,
4
],
[
2,
0,
17,
4,
3
],
[
2,
0,
17,
4,
18
],
[
2,
0,
17,
18
],
[
2,
0,
19
],
[
2,
0,
19,
18
],
[
3,
14
],
[
3,
14,
4
],
[
3,
14,
4,
18
],
[
3,
14,
6
],
[
3,
14,
6,
18
],
[
3,
14,
13,
4
],
[
3,
14,
19
]
]
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"alphabet": "ru",
"numAdded": 6,
"words": [
"ад",
"ар",
"оса",
"я",
"яд",
"яр"
],
"indexes": [
[
0,
4
],
[
0,
17
],
[
15,
18,
0
],
[
32
],
[
32,
4
],
[
32,
17
]
]
}
+3 -2
View File
@@ -297,8 +297,9 @@ export function validatePlay(
} }
// connected reports whether the play connects to the position (or covers the // connected reports whether the play connects to the position (or covers the
// centre on the first move). Mirrors (*Solver).connected. // centre on the first move). Mirrors (*Solver).connected. Exported so the move
function connected(b: Board, rs: Ruleset, m: Move): boolean { // generator can apply the same post-generation connectivity filter.
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
if (b.isEmpty()) { if (b.isEmpty()) {
const cr = Math.floor(rs.center / rs.cols); const cr = Math.floor(rs.center / rs.cols);
const cc = rs.center % rs.cols; const cc = rs.center % rs.cols;
+76
View File
@@ -0,0 +1,76 @@
// Dispatch a game id to its source: the local engine (an offline vs_ai game) or the network
// gateway. The same game screen (game/Game.svelte) drives both through the shared game-loop subset
// (GameLoopSource); a game id decides which. A network game's calls are unchanged — gameSource
// returns the gateway itself for it — so the seam adds the local path without touching online play.
//
// The offline engine is kept OUT of the app entry bundle: this module statically imports only the
// gateway, the tiny id helper and types; the LocalSource is dynamically imported on first use of a
// local game, so it loads as a separate chunk only when someone plays offline.
import { gateway } from './gateway';
import { isLocalGameId } from './localgame/id';
import type { GameLoopSource, LocalSource } from './localgame/source';
let loaded: Promise<LocalSource> | null = null;
function load(): Promise<LocalSource> {
if (!loaded) loaded = import('./localgame/source').then((m) => new m.LocalSource());
return loaded;
}
/**
* localSource is a lazy handle on the single offline LocalSource. It is obtainable synchronously,
* but each method dynamically imports the engine on first use, so the offline code stays out of the
* app entry bundle until a local game is actually played. It exposes the game-loop methods plus the
* local-only create() and the robot-reply event subscription events().
*/
export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
// gateway signatures but forwards only what the local source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
pass: (id) => load().then((s) => s.pass(id)),
exchange: (id, tiles, variant) => load().then((s) => s.exchange(id, tiles, variant)),
resign: (id) => load().then((s) => s.resign(id)),
hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()),
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
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) => {
let unsub = (): void => {};
let cancelled = false;
void load().then((s) => {
if (!cancelled) unsub = s.events(id, onEvent);
});
return () => {
cancelled = true;
unsub();
};
},
} 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
* id, otherwise the network gateway (which satisfies the same game-loop interface).
*/
export function gameSource(id: string): GameLoopSource {
return isLocalGameId(id) ? localSource : gateway;
}
export { isLocalGameId } from './localgame/id';
+5
View File
@@ -5,6 +5,7 @@
import type { GatewayClient } from './client'; import type { GatewayClient } from './client';
import { MockGateway } from './mock/client'; import { MockGateway } from './mock/client';
import { createTransport } from './transport'; import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte'; import { reportOffline, reportOnline } from './connection.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte'; import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
@@ -41,6 +42,7 @@ if (isMock && typeof window !== 'undefined') {
setGameLimit(v: boolean): void; setGameLimit(v: boolean): void;
clearEmail(): void; clearEmail(): void;
confirmEmailOutOfBand(email: string): void; confirmEmailOutOfBand(email: string): void;
setLocalSeed(seed: string): void;
}; };
} }
).__mock = { ).__mock = {
@@ -50,5 +52,8 @@ if (isMock && typeof window !== 'undefined') {
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v), setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
clearEmail: () => (gateway as MockGateway).mockClearEmail(), clearEmail: () => (gateway as MockGateway).mockClearEmail(),
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email), confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
// string — window hooks marshal strings cleanly).
setLocalSeed: (seed: string) => setForcedSeed(BigInt(seed)),
}; };
} }
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { hintsLeft } from './hints'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
// view carries only the two fields hintsLeft reads. // view carries only the two fields hintsLeft reads.
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
expect(hintsLeft(view(1, 0), -5)).toBe(1); expect(hintsLeft(view(1, 0), -5)).toBe(1);
}); });
}); });
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 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 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);
});
});
describe('hintLockMinutes (the toast N, rounded up)', () => {
it('rounds up to whole minutes, so a still-closed gate never reads 0', () => {
expect(hintLockMinutes(1)).toBe(1);
expect(hintLockMinutes(60_000)).toBe(1);
expect(hintLockMinutes(61_000)).toBe(2);
expect(hintLockMinutes(29 * 60_000)).toBe(29);
});
it('is 0 when nothing remains', () => {
expect(hintLockMinutes(0)).toBe(0);
});
});
+24
View File
@@ -24,3 +24,27 @@ export function hintsLeft(
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance); const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, walletBalance); return allowance + Math.max(0, walletBalance);
} }
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
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. 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(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
* min." toast, so a gate that is still closed never reads 0. */
export function hintLockMinutes(remainingMs: number): number {
return Math.ceil(remainingMs / 60000);
}
+39
View File
@@ -99,6 +99,7 @@ export const en = {
'game.draw': 'Exchange/Pass', 'game.draw': 'Exchange/Pass',
'game.shuffle': 'Shuffle', 'game.shuffle': 'Shuffle',
'game.hint': 'Hint', 'game.hint': 'Hint',
'game.hintLockedIn': 'Available in {n} min.',
'game.chat': 'Chat', 'game.chat': 'Chat',
'game.checkWord': 'Check word', 'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary', 'game.dictionary': 'Dictionary',
@@ -214,6 +215,15 @@ export const en = {
'settings.labelsClassic': 'Classic', 'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None', 'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
'about.title': 'About', 'about.title': 'About',
'about.tab': 'Info', 'about.tab': 'Info',
@@ -379,6 +389,35 @@ export const en = {
'error.game_active': 'Available only after the game is finished.', 'error.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.', 'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.', '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; } as const;
export type MessageKey = keyof typeof en; export type MessageKey = keyof typeof en;
+39
View File
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
'game.draw': 'Обмен/Пас', 'game.draw': 'Обмен/Пас',
'game.shuffle': 'Перемешать', 'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка', 'game.hint': 'Подсказка',
'game.hintLockedIn': 'Будет доступно через {n} мин.',
'game.chat': 'Чат', 'game.chat': 'Чат',
'game.checkWord': 'Проверить слово', 'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь', 'game.dictionary': 'Словарь',
@@ -214,6 +215,15 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика', 'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста', 'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо', 'about.tab': 'Инфо',
@@ -379,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
'error.game_active': 'Доступно только после завершения игры.', 'error.game_active': 'Доступно только после завершения игры.',
'error.invalid_profile': 'Некоторые поля профиля некорректны.', 'error.invalid_profile': 'Некоторые поля профиля некорректны.',
'error.already_confirmed': 'Эта почта уже подтверждена.', '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': 'Отменить партию без результата?',
}; };
+40 -24
View File
@@ -43,10 +43,10 @@ beforeEach(() => clearLobby());
describe('patchLobbyGame', () => { describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => { it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false }); setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false, offline: false });
// The player's own move flipped game "a" to the opponent's turn. // The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1)); patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby(); const snap = getLobby(false);
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']); expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1); expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0); expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
@@ -54,27 +54,27 @@ describe('patchLobbyGame', () => {
it('preserves invitations and incoming when patching a game', () => { it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false }); setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false, offline: false });
patchLobbyGame(gameView('a', 'finished')); patchLobbyGame(gameView('a', 'finished'));
expect(getLobby()?.incoming).toEqual(incoming); expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished'); expect(getLobby(false)?.games[0].status).toBe('finished');
}); });
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => { it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false }); setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false, offline: false });
patchLobbyGame(gameView('z', 'active')); patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership. // Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']); expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
}); });
it('is a no-op when there is no cached lobby yet', () => { it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a')); patchLobbyGame(gameView('a'));
expect(getLobby()).toBeNull(); expect(getLobby(false)).toBeNull();
}); });
it('does not mutate the previous snapshot array', () => { it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)]; const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [], atGameLimit: false }); setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false });
patchLobbyGame(gameView('a', 'active', 1)); patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact expect(games[0].toMove).toBe(0); // the original array/object is left intact
}); });
@@ -82,51 +82,67 @@ describe('patchLobbyGame', () => {
it('preserves the at-game-limit flag across a game patch', () => { it('preserves the at-game-limit flag across a game patch', () => {
// A finished-game patch arriving while the lobby is unmounted must not drop the flag — // A finished-game patch arriving while the lobby is unmounted must not drop the flag —
// the lobby renders the "New Game" button from the cached snapshot before the refresh. // the lobby renders the "New Game" button from the cached snapshot before the refresh.
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true }); setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false });
patchLobbyGame(gameView('a', 'finished')); patchLobbyGame(gameView('a', 'finished'));
expect(getLobby()?.atGameLimit).toBe(true); expect(getLobby(false)?.atGameLimit).toBe(true);
}); });
}); });
describe('patchLobbyInvitation', () => { describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => { it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false }); setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false });
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']); expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
}); });
it('replaces a pending invitation already in the list (e.g. an updated response)', () => { it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false }); setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 }; const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated); patchLobbyInvitation(updated);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999); expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
}); });
it('removes an invitation that reached a terminal status', () => { it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) { for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false }); setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
patchLobbyInvitation(invitation('i1', terminal)); patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
} }
}); });
it('is a no-op for a terminal invitation that is not in the list', () => { it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false }); setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
patchLobbyInvitation(invitation('i1', 'declined')); patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
}); });
it('is a no-op when there is no cached lobby yet', () => { it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()).toBeNull(); expect(getLobby(false)).toBeNull();
}); });
it('preserves games and incoming when patching an invitation', () => { it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false }); setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false });
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']); expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming); expect(getLobby(false)?.incoming).toEqual(incoming);
});
});
describe('getLobby is mode-aware (a mode flip must not render the other modes games)', () => {
it('returns the snapshot only for the mode it was stored under', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
expect(getLobby(true)).toBeNull();
});
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true });
expect(getLobby(false)).toBeNull();
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
}); });
}); });
+8 -3
View File
@@ -13,13 +13,18 @@ interface LobbySnapshot {
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last // atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
// known enabled/disabled state instantly (no flicker), then refreshes it in the background. // known enabled/disabled state instantly (no flicker), then refreshes it in the background.
atGameLimit: boolean; atGameLimit: boolean;
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
// or lets the player open — the other mode's games before the background refresh replaces them.
offline: boolean;
} }
let snapshot: LobbySnapshot | null = null; let snapshot: LobbySnapshot | null = null;
/** getLobby returns the last lobby lists, or null before the first load. */ /** getLobby returns the last lobby lists for the given mode, or null before the first load or when
export function getLobby(): LobbySnapshot | null { * the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
return snapshot; export function getLobby(offline: boolean): LobbySnapshot | null {
return snapshot && snapshot.offline === offline ? snapshot : null;
} }
/** setLobby stores the latest lobby lists. */ /** setLobby stores the latest lobby lists. */
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { Bag } from './bag';
import { RULESETS } from './ruleset';
import { BLANK_INDEX } from '../alphabet';
describe('offline bag', () => {
it('holds exactly the variant tile distribution', () => {
const rs = RULESETS.scrabble_en;
const total = rs.counts.reduce((a, b) => a + b, 0) + rs.blanks;
const bag = new Bag('scrabble_en', 42);
expect(bag.length).toBe(total);
const drawn = bag.draw(total);
expect(drawn.length).toBe(total);
expect(bag.length).toBe(0);
const tally = new Map<number, number>();
for (const t of drawn) tally.set(t, (tally.get(t) ?? 0) + 1);
for (let i = 0; i < rs.counts.length; i++) expect(tally.get(i) ?? 0).toBe(rs.counts[i]);
expect(tally.get(BLANK_INDEX) ?? 0).toBe(rs.blanks);
});
it('draws beyond the remaining, returning all and emptying', () => {
const bag = new Bag('scrabble_ru', 7);
const total = bag.length;
const all = bag.draw(total + 5);
expect(all.length).toBe(total);
expect(bag.length).toBe(0);
expect(bag.draw(3)).toEqual([]);
});
it('returns tiles back to the bag', () => {
const bag = new Bag('erudit_ru', 1);
const before = bag.length;
const seven = bag.draw(7);
expect(bag.length).toBe(before - 7);
bag.return(seven);
expect(bag.length).toBe(before);
});
it('is deterministic for a given seed and operation sequence', () => {
const a = new Bag('scrabble_en', 123);
const b = new Bag('scrabble_en', 123);
expect(a.draw(30)).toEqual(b.draw(30));
// A return reshuffles both identically, so subsequent draws still agree.
a.return([0, 1, 2]);
b.return([0, 1, 2]);
expect(a.draw(10)).toEqual(b.draw(10));
});
});
+76
View File
@@ -0,0 +1,76 @@
// The offline tile bag — the shuffled draw pile for one local game. Structurally a port of
// backend/internal/engine/bag.go (fill from the variant's counts + blanks, draw from the end,
// return-and-reshuffle for an exchange), but shuffled with a small DETERMINISTIC in-house PRNG
// rather than Go's math/rand: a local game only needs to be reproducible from its own seed and
// sequence of operations (for replay from the stored journal), not bit-identical to a server
// game (docs plan). Blanks ride as BLANK_INDEX, matching lib/alphabet.ts (and the engine's
// blankTile = 0xff = 255).
import { RULESETS } from './ruleset';
import { BLANK_INDEX } from '../alphabet';
import type { Variant } from '../model';
// mulberry32 is a compact deterministic PRNG returning a float in [0, 1). Seeded from the game
// seed so the shuffle sequence — and thus the draws — replay identically for the same seed and
// the same sequence of returns.
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
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;
};
}
/**
* Bag is one local game's draw pile. Construct it with the variant and a numeric seed; draw()
* and return() mutate it in place. It is reproducible: the same seed and the same sequence of
* operations yield the same draws.
*/
export class Bag {
private tiles: number[];
private readonly rand: () => number;
constructor(variant: Variant, seed: number) {
const rs = RULESETS[variant];
const tiles: number[] = [];
for (let i = 0; i < rs.counts.length; i++) {
for (let n = 0; n < rs.counts[i]; n++) tiles.push(i);
}
for (let n = 0; n < rs.blanks; n++) tiles.push(BLANK_INDEX);
this.tiles = tiles;
this.rand = mulberry32(seed);
this.shuffle();
}
/** length is the number of tiles left in the bag. */
get length(): number {
return this.tiles.length;
}
/**
* draw removes up to n tiles from the end of the bag and returns them. Drawing more than
* remain returns all of them; drawing from an empty bag returns an empty array.
*/
draw(n: number): number[] {
const take = Math.min(n, this.tiles.length);
return this.tiles.splice(this.tiles.length - take, take);
}
/** return puts tiles back into the bag and reshuffles, as when a player exchanges tiles. */
return(tiles: readonly number[]): void {
for (const t of tiles) this.tiles.push(t);
this.shuffle();
}
// shuffle randomises the remaining tiles in place with the bag's own PRNG (FisherYates).
private shuffle(): void {
for (let i = this.tiles.length - 1; i > 0; i--) {
const j = Math.floor(this.rand() * (i + 1));
const tmp = this.tiles[i];
this.tiles[i] = this.tiles[j];
this.tiles[j] = tmp;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// The mutable local-game board: a 15x15 row-major grid of placed tiles (alphabet-index letter
// plus a blank flag). It satisfies the read view the validator and generator need (GenBoard,
// which extends validate.ts's Board) and adds set() so the engine can apply a play. The board is
// alphabet-agnostic — a cell's letter is a variant index, meaningful with the variant's ruleset.
import { BOARD_SIZE } from '../premiums';
import type { Cell } from '../dict/validate';
import type { GenBoard } from '../dict/generate';
/** LocalBoard is the engine's in-memory board; it reads as a GenBoard and applies plays via set. */
export class LocalBoard implements GenBoard {
readonly rows = BOARD_SIZE;
readonly cols = BOARD_SIZE;
private readonly grid: (Cell | null)[];
private count = 0;
constructor() {
this.grid = new Array<Cell | null>(BOARD_SIZE * BOARD_SIZE).fill(null);
}
inBounds(row: number, col: number): boolean {
return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
}
filled(row: number, col: number): boolean {
return this.inBounds(row, col) && this.grid[row * this.cols + col] !== null;
}
cellAt(row: number, col: number): Cell {
return this.grid[row * this.cols + col]!;
}
isEmpty(): boolean {
return this.count === 0;
}
/** set places a tile at (row, col). It assumes the square was empty (a play only lays tiles
* on empty squares), keeping the filled count exact for isEmpty. */
set(row: number, col: number, letter: number, blank: boolean): void {
const i = row * this.cols + col;
if (this.grid[i] === null) this.count++;
this.grid[i] = { letter, blank };
}
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { applyEndAdjustment, winner, type EndReason } from './engine';
import { RULESETS } from './ruleset';
import type { Variant } from '../model';
// The offline engine must settle unplayed racks and decide the winner exactly as the Go engine
// does, so a local game finishes with the same scores. Golden from the in-package emitter
// (backend/internal/engine/endfixture_test.go).
interface EndCase {
name: string;
variant: Variant;
reason: EndReason;
hands: number[][];
scores: number[];
resigned: boolean[];
toMove: number;
scoresAfter: number[];
winner: number;
}
const fx = JSON.parse(
readFileSync(new URL('./testdata/endgame.json', import.meta.url), 'utf8'),
) as { cases: EndCase[] };
describe('offline engine end-game parity vs Go', () => {
for (const c of fx.cases) {
it(c.name, () => {
const values = RULESETS[c.variant].values;
const after = applyEndAdjustment(c.reason, c.hands, c.scores, c.toMove, values);
expect(after).toEqual(c.scoresAfter);
expect(winner(true, c.reason, after, c.resigned)).toBe(c.winner);
});
}
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from '../dict/dawg';
import { RACK_SIZE } from '../premiums';
import { LocalGame } from './engine';
import { decide } from '../robot/strategy';
import type { Move } from '../dict/validate';
// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed
// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner.
// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity
// suites; this test proves the engine drives to a valid finish.)
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
function bestOpponentScore(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and
// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator
// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in
// production; an exchange the bag is too small to satisfy falls back to a pass.
function robotTurn(game: LocalGame, seed: bigint): void {
const seat = game.currentPlayer;
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
if (dec.kind === 'play') {
game.play(dec.move.dir, dec.move.tiles);
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
game.exchange(dec.exchange);
} else {
game.pass();
}
}
describe('offline engine full-game smoke', () => {
it('plays a whole 2-player vs_ai game to completion', () => {
const game = new LocalGame({
variant: 'scrabble_en',
version: 'sample',
seed: 123456789n,
players: 2,
dawg,
multipleWords: true,
});
let turns = 0;
while (!game.isOver && turns < 2000) {
robotTurn(game, game.seed);
turns++;
}
expect(game.isOver).toBe(true);
expect(turns).toBeLessThan(2000);
expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason);
const w = game.winnerIndex;
expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true);
for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true);
// The move log recorded every turn.
expect(game.history.length).toBe(turns);
});
it('is reproducible from the seed', () => {
const play = (): { reason: string; scores: number[]; turns: number } => {
const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true });
let t = 0;
while (!g.isOver && t < 2000) {
robotTurn(g, g.seed);
t++;
}
return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t };
};
expect(play()).toEqual(play());
});
});
+443
View File
@@ -0,0 +1,443 @@
// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the
// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays
// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts),
// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game
// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding
// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic
// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game.
//
// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they
// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions.
import { LocalBoard } from './board';
import { Bag } from './bag';
import { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
import { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet';
import type { Dawg } from '../dict/dawg';
import type { Variant } from '../model';
/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends
* a game, mirroring engine.scorelessLimit. */
export const scorelessLimit = 6;
/** EndReason explains why a game finished, using the engine's stable labels. */
export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted';
/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */
export type DropoutTiles = 'remove' | 'return';
/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */
export interface LocalMove {
player: number;
action: 'play' | 'pass' | 'exchange' | 'resign';
dir?: Direction;
tiles?: Placement[];
/** ActionPlay only: the main word's first-letter coordinate. */
mainRow?: number;
mainCol?: number;
/** ActionPlay only: the words formed as alphabet-index letter arrays the main word first, then
* the cross words. Decoded to glyphs when building the history's MoveRecord. */
words?: number[][];
/** ActionExchange only: number of tiles swapped. */
count?: number;
/** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank),
* recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */
exchanged?: number[];
score: number;
total: number;
}
/** GameError carries a stable code for the engine's rejection reasons. */
export class GameError extends Error {
constructor(public readonly code: string) {
super(code);
this.name = 'GameError';
}
}
function letterMultOf(p: Premium): number {
return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
}
function wordMultOf(p: Premium): number {
return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
}
/**
* buildRuleset assembles the validator/generator ruleset for a variant from the static offline
* tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the
* single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's
* ruleset for the same variant; online scoring instead reads the server-sent alphabet values.
*/
export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
const prem = premiumGrid(variant);
const ctr = centre(variant);
return {
cols: BOARD_SIZE,
center: ctr.row * BOARD_SIZE + ctr.col,
rackSize: RACK_SIZE,
bingo: BINGO[variant],
values: RULESETS[variant].values,
letterMult: (r, c) => letterMultOf(prem[r][c]),
wordMult: (r, c) => wordMultOf(prem[r][c]),
ignoreCrossWords: !multipleWords,
};
}
/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors
* engine (*Game).rackValue. */
export function rackValue(hand: readonly number[], values: readonly number[]): number {
let v = 0;
for (const t of hand) if (t !== BLANK_INDEX) v += values[t];
return v;
}
/**
* applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the
* player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses
* their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment.
* Mirrors engine (*Game).applyEndAdjustment.
*/
export function applyEndAdjustment(
reason: EndReason,
hands: readonly (readonly number[])[],
scores: readonly number[],
toMove: number,
values: readonly number[],
): number[] {
const out = [...scores];
if (reason === 'out_of_tiles') {
let bonus = 0;
for (let i = 0; i < hands.length; i++) {
if (i === toMove) continue;
const v = rackValue(hands[i], values);
out[i] -= v;
bonus += v;
}
out[toMove] += bonus;
} else if (reason === 'scoreless') {
for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values);
}
return out;
}
/**
* winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an
* unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game
* returns the remaining player even when the resigner led. Mirrors engine (*Game).winner.
*/
export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number {
if (!over || reason === 'aborted') return -1;
let best = -1;
let tie = false;
for (let i = 0; i < scores.length; i++) {
if (resigned[i]) continue;
if (best === -1 || scores[i] > scores[best]) {
best = i;
tie = false;
} else if (scores[i] === scores[best]) {
tie = true;
}
}
return tie ? -1 : best;
}
/** Options for a new local game. */
export interface LocalGameOptions {
variant: Variant;
version: string;
/** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */
seed: bigint;
players: number;
dawg: Dawg;
multipleWords: boolean;
dropoutTiles?: DropoutTiles;
}
// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank).
function placementTiles(tiles: readonly Placement[]): number[] {
return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter));
}
/**
* LocalGame is the in-memory state of one local match and the rules engine over it. Construct it
* with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O.
*/
export class LocalGame {
readonly variant: Variant;
readonly version: string;
readonly seed: bigint;
private readonly vrs: Ruleset;
private readonly values: readonly number[];
private readonly rackSize: number;
private readonly dawg: Dawg;
private readonly multipleWords: boolean;
private readonly dropoutTiles: DropoutTiles;
private readonly board: LocalBoard;
private readonly bag: Bag;
private readonly hands: number[][];
private readonly scores: number[];
private readonly resigned: boolean[];
private toMove = 0;
private scorelessRun = 0;
private over = false;
private reason: EndReason = 'not_over';
private readonly log: LocalMove[] = [];
constructor(opts: LocalGameOptions) {
if (opts.players < 2 || opts.players > 4) {
throw new GameError('players_out_of_range');
}
this.variant = opts.variant;
this.version = opts.version;
this.seed = opts.seed;
this.dawg = opts.dawg;
this.multipleWords = opts.multipleWords;
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
this.values = RULESETS[opts.variant].values;
this.rackSize = RULESETS[opts.variant].rackSize;
this.board = new LocalBoard();
this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed)));
this.hands = [];
this.scores = [];
this.resigned = [];
for (let i = 0; i < opts.players; i++) {
this.hands.push(this.bag.draw(this.rackSize));
this.scores.push(0);
this.resigned.push(false);
}
}
// --- queries ---------------------------------------------------------------
get playerCount(): number {
return this.hands.length;
}
get currentPlayer(): number {
return this.toMove;
}
get isOver(): boolean {
return this.over;
}
get endReason(): EndReason {
return this.reason;
}
get bagLength(): number {
return this.bag.length;
}
get moveCount(): number {
return this.log.length;
}
scoreOf(player: number): number {
return this.scores[player];
}
/** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */
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);
}
/** config returns the rule settings the game was created with the bits, alongside the seed and
* journal, needed to reconstruct it (see localgame/serialize). */
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles };
}
/** history returns a copy of the move log. */
get history(): LocalMove[] {
return this.log.map((m) => ({ ...m }));
}
/** generateMoves returns every legal play for the current player, ranked by descending score. */
generateMoves(): Move[] {
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
}
/** evaluatePlay scores a candidate placement for the current position without committing it,
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
* arrays, main first) and the inferred direction. Backs the local move preview. */
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } {
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
const m = res.move;
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
}
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
dictionaryHas(word: readonly number[]): boolean {
return this.dawg.indexOf(word) >= 0;
}
// --- turns -----------------------------------------------------------------
/** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay),
* then plays it the client submits tiles without a direction and the engine resolves it. */
submitPlay(tiles: Placement[]): LocalMove {
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
return this.play(dir, tiles);
}
/** play validates and applies the current player's placement, scores it, refills the rack and
* advances the turn (or ends the game). Throws GameError on an illegal play. */
play(dir: Direction, tiles: Placement[]): LocalMove {
if (this.over) throw new GameError('game_over');
const player = this.toMove;
const used = placementTiles(tiles);
if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack');
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) throw new GameError('illegal_play');
const move = res.move;
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
this.removeFromHand(player, used);
this.scores[player] += move.score;
this.refill(player);
this.scorelessRun = 0;
const rec: LocalMove = {
player,
action: 'play',
dir,
tiles: tiles.map((t) => ({ ...t })),
mainRow: move.main.row,
mainCol: move.main.col,
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
score: move.score,
total: this.scores[player],
};
this.log.push(rec);
if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles');
else this.advance();
return rec;
}
/** pass forfeits the current turn, extending the scoreless run (which may end the game). */
pass(): LocalMove {
if (this.over) throw new GameError('game_over');
const player = this.toMove;
this.scorelessRun++;
const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] };
this.log.push(rec);
this.endTurnAfterScoreless();
return rec;
}
/** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones.
* Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the
* swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */
exchange(tiles: number[]): LocalMove {
if (this.over) throw new GameError('game_over');
if (tiles.length === 0) throw new GameError('nothing_to_exchange');
if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange');
const player = this.toMove;
if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack');
this.removeFromHand(player, tiles);
const drawn = this.bag.draw(tiles.length);
for (const t of drawn) this.hands[player].push(t);
this.bag.return(tiles);
this.scorelessRun++;
const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] };
this.log.push(rec);
this.endTurnAfterScoreless();
return rec;
}
/** resign drops the current player out of the game (they forfeit the win, keep their score). */
resign(): LocalMove {
return this.resignSeat(this.toMove);
}
/** resignSeat resigns a specific seat regardless of whose turn it is. */
resignSeat(seat: number): LocalMove {
if (this.over) throw new GameError('game_over');
if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over');
this.resigned[seat] = true;
this.disposeHand(seat);
const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] };
this.log.push(rec);
if (this.activeCount() <= 1) this.finish('resign');
else if (seat === this.toMove) this.advance();
return rec;
}
// --- internals -------------------------------------------------------------
private rackOf(player: number): GenRack {
const letters: number[] = [];
let blanks = 0;
for (const t of this.hands[player]) {
if (t === BLANK_INDEX) blanks++;
else letters.push(t);
}
return GenRack.from(this.values.length, letters, blanks);
}
private finish(reason: EndReason): void {
this.over = true;
this.reason = reason;
const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values);
for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i];
}
private endTurnAfterScoreless(): void {
if (this.scorelessRun >= scorelessLimit) this.finish('scoreless');
else this.advance();
}
private advance(): void {
const n = this.hands.length;
for (let i = 1; i <= n; i++) {
const next = (this.toMove + i) % n;
if (!this.resigned[next]) {
this.toMove = next;
return;
}
}
}
private activeCount(): number {
return this.resigned.reduce((n, r) => (r ? n : n + 1), 0);
}
private disposeHand(player: number): void {
if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]);
this.hands[player] = [];
}
private holds(player: number, want: readonly number[]): boolean {
const avail = new Map<number, number>();
for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
const need = new Map<number, number>();
for (const t of want) need.set(t, (need.get(t) ?? 0) + 1);
for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false;
return true;
}
private removeFromHand(player: number, used: readonly number[]): void {
const hand = this.hands[player];
for (const t of used) {
const i = hand.indexOf(t);
if (i >= 0) hand.splice(i, 1);
}
}
private refill(player: number): void {
const need = this.rackSize - this.hands[player].length;
if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { LOCAL_ID_PREFIX, isLocalGameId, newLocalGameId, randomSeed } from './id';
describe('local game id helpers', () => {
it('recognises local ids by prefix', () => {
expect(isLocalGameId(LOCAL_ID_PREFIX + 'x')).toBe(true);
expect(isLocalGameId('deadbeef')).toBe(false);
});
it('mints unique local ids', () => {
const a = newLocalGameId();
const b = newLocalGameId();
expect(isLocalGameId(a)).toBe(true);
expect(a).not.toBe(b);
});
it('produces a bigint seed in the 32-bit range', () => {
for (let i = 0; i < 50; i++) {
const s = randomSeed();
expect(typeof s).toBe('bigint');
expect(s >= 0n && s <= 0xffffffffn).toBe(true);
}
});
});
+36
View File
@@ -0,0 +1,36 @@
// Local game id helpers, in a tiny standalone module with no engine/generator imports, so the
// dispatcher (lib/gamesource) can recognise a local game id — and the game screen can branch on it
// — without eagerly bundling the whole offline engine. The engine is dynamically imported only when
// a local game is actually played (keeping the app entry bundle within its size budget).
/** LOCAL_ID_PREFIX marks a game id as a local (offline) game. */
export const LOCAL_ID_PREFIX = 'local:';
/** isLocalGameId reports whether an id belongs to a local game. */
export function isLocalGameId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX);
}
/** newLocalGameId mints a fresh, device-unique local game id (time + random suffix no crypto API,
* so it also works on the older engines the SPA still supports). */
export function newLocalGameId(): string {
return `${LOCAL_ID_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
// A test seam for the mock e2e: pinning the seed makes a local game deal a known rack, so a spec
// can play a precomputed opening word. Only the mock build's window.__mock wires setForcedSeed
// (see lib/gateway.ts); a production build never calls it, so randomSeed stays truly random.
let forcedSeed: bigint | null = null;
/** setForcedSeed pins the value randomSeed returns (the mock-e2e seam); pass null to restore
* randomness. */
export function setForcedSeed(seed: bigint | null): void {
forcedSeed = seed;
}
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag (or the pinned
* seed when a test has set one via setForcedSeed). */
export function randomSeed(): bigint {
if (forcedSeed !== null) return forcedSeed;
return BigInt(Math.floor(Math.random() * 0xffffffff));
}
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { RULESETS } from './ruleset';
import type { Variant } from '../model';
// The offline engine is self-contained: it carries each variant's tile values, bag counts and
// blank count in ruleset.ts (these are not otherwise on the client — online scoring uses the
// server-sent alphabet). This pins that hand-copied table to the Go rulesets
// (scrabble-solver/rules), whose values the movegen tool dumps to testdata/rulesets.json.
interface RulesetFix {
size: number;
rackSize: number;
bingo: number;
blanks: number;
values: number[];
counts: number[];
letters: string[];
}
const fx = JSON.parse(
readFileSync(new URL('./testdata/rulesets.json', import.meta.url), 'utf8'),
) as Record<Variant, RulesetFix>;
describe('offline ruleset table parity vs Go rules', () => {
for (const variant of Object.keys(fx) as Variant[]) {
it(variant, () => {
const rs = RULESETS[variant];
expect({
size: rs.size,
rackSize: rs.rackSize,
bingo: rs.bingo,
blanks: rs.blanks,
values: [...rs.values],
counts: [...rs.counts],
letters: [...rs.letters],
}).toEqual(fx[variant]);
});
}
});
+62
View File
@@ -0,0 +1,62 @@
// Static per-variant ruleset data for the offline engine — tile point values, bag tile counts
// and blank count, indexed by alphabet letter index. Mirrored from scrabble-solver/rules/rules.go
// (English / RussianScrabble / Erudit) so a local game is fully self-contained: online scoring
// reads the server-sent alphabet (lib/alphabet.ts), but offline there is no server, so the values
// live here. Board geometry, the centre and RACK_SIZE/BINGO already live in lib/premiums.ts; this
// table adds the values (offline copy), the bag distribution and the blank count. Pinned to the Go
// rulesets by ruleset.parity.test.ts.
import type { Variant } from '../model';
/** VariantRuleset is the offline scoring + bag data for one variant. */
export interface VariantRuleset {
/** Number of letters in the alphabet (bag distribution and values are indexed 0..size-1). */
size: number;
/** Tiles drawn to a full rack (7 for every variant). */
rackSize: number;
/** All-tiles (bingo) bonus. */
bingo: number;
/** Number of blank tiles in the bag. */
blanks: number;
/** Tile point value per letter index; a blank scores 0 (handled by the caller). */
values: readonly number[];
/** Bag tile count per letter index (how many of each letter the bag holds). */
counts: readonly number[];
/** Display glyph per letter index, upper-cased (matching lib/alphabet.ts). Offline needs these
* to translate the engine's index space to/from the glyphs the game UI shows, with no server. */
letters: readonly string[];
}
/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */
export const RULESETS: Record<Variant, VariantRuleset> = {
scrabble_en: {
size: 26,
rackSize: 7,
bingo: 50,
blanks: 2,
// a b c d e f g h i j k l m n o p q r s t u v w x y z
values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10],
counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1],
letters: [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'],
},
scrabble_ru: {
size: 33,
rackSize: 7,
bingo: 50,
blanks: 2,
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
values: [1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3],
counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2],
letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
},
erudit_ru: {
size: 33,
rackSize: 7,
bingo: 15,
blanks: 3,
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
values: [1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3],
counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3],
letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
},
};
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from '../dict/dawg';
import { RACK_SIZE } from '../premiums';
import { LocalGame } from './engine';
import { serializeGame, replayGame, type Seat } from './serialize';
import { decide } from '../robot/strategy';
import type { Move } from '../dict/validate';
// A game reconstructed from its record (seed + journal) must be identical to the original — same
// board, racks, bag, scores, turn and log — since the bag is deterministic from the seed and the
// replayed sequence of operations. Uses the committed sample dictionary.
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
const seats: Seat[] = [
{ kind: 'human', name: 'You' },
{ kind: 'robot', name: 'Robot' },
];
function bestOpponentScore(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
// drive plays one turn with the robot's choice (legal plays only — see engine.test.ts).
function drive(game: LocalGame, seed: bigint): void {
const seat = game.currentPlayer;
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
if (dec.kind === 'play') game.play(dec.move.dir, dec.move.tiles);
else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) game.exchange(dec.exchange);
else game.pass();
}
function snapshot(game: LocalGame): unknown {
const scores: number[] = [];
const hands: number[][] = [];
for (let i = 0; i < game.playerCount; i++) {
scores.push(game.scoreOf(i));
hands.push(game.handOf(i).slice().sort((a, b) => a - b));
}
return { scores, hands, toMove: game.currentPlayer, bagLen: game.bagLength, over: game.isOver, reason: game.endReason, history: game.history };
}
function makeGame(seed: bigint): LocalGame {
return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true });
}
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 };
describe('local game serialize + replay', () => {
it('reconstructs a mid-game position by replay', () => {
const g = makeGame(777n);
for (let i = 0; i < 15 && !g.isOver; i++) drive(g, g.seed);
const rec = serializeGame(g, meta);
const r = replayGame(rec, dawg);
expect(snapshot(r)).toEqual(snapshot(g));
});
it('reconstructs a finished game by replay', () => {
const g = makeGame(2024n);
let t = 0;
while (!g.isOver && t < 2000) {
drive(g, g.seed);
t++;
}
expect(g.isOver).toBe(true);
const rec = serializeGame(g, meta);
expect(rec.status).toBe('finished');
const r = replayGame(rec, dawg);
expect(snapshot(r)).toEqual(snapshot(g));
});
it('serialises the seed, rules and journal', () => {
const g = makeGame(9999999999n);
for (let i = 0; i < 5 && !g.isOver; i++) drive(g, g.seed);
const rec = serializeGame(g, meta);
expect(rec.seed).toBe('9999999999');
expect(rec.variant).toBe('scrabble_en');
expect(rec.multipleWords).toBe(true);
expect(rec.players).toBe(2);
expect(rec.seats).toEqual(seats);
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();
});
});
+127
View File
@@ -0,0 +1,127 @@
// Serialising a local game to a durable record and reconstructing it by replay — the offline
// counterpart of the server's game rehydration (backend replay: engine.New(seed) + replayMove per
// journal row). A record carries the seed, the rules, the seat metadata and the move journal; the
// board, bag and racks are NOT stored — they are rebuilt by replaying the journal on a fresh engine
// seeded the same way (the bag is deterministic from the seed and the sequence of operations).
//
// The journal is in alphabet-index space, which is dictionary-independent: a variant's alphabet
// (Latin 26 / embedded Russian 33) is fixed, so the same journal replays against any dictionary
// version of that variant (only the word list changes, not the indices). The dictionary version is
// pinned in the record, and replay applies each recorded move against that dawg.
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 {
kind: 'human' | 'robot';
name: string;
/** 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. */
export interface LocalGameRecord {
id: string;
variant: Variant;
dictVersion: string;
/** The bag seed, serialised as a decimal string (it may exceed JS safe integers). */
seed: string;
players: number;
multipleWords: boolean;
dropoutTiles: DropoutTiles;
seats: Seat[];
/** 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
* while open (no robot move yet). Persisted so the wait survives leaving and reopening the app;
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it
* see lib/hints. */
hintUnlockAtMs: number;
}
/** The record fields the caller owns (the engine supplies the rest). */
export interface RecordMeta {
id: string;
seats: Seat[];
hotseat?: boolean;
hostPin?: PinLock;
createdAtUnix: number;
updatedAtUnix: number;
hintUnlockAtMs: number;
}
/**
* serializeGame builds the durable record for game, taking the caller-owned metadata (id, seats,
* timestamps) and deriving the rest the seed, rules, journal and status from the game itself.
*/
export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecord {
const cfg = game.config;
return {
id: meta.id,
variant: game.variant,
dictVersion: game.version,
seed: game.seed.toString(),
players: game.playerCount,
multipleWords: cfg.multipleWords,
dropoutTiles: cfg.dropoutTiles,
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,
};
}
/**
* replayGame reconstructs the live LocalGame from a record and the loaded dictionary, by seeding a
* fresh engine identically and replaying every journal move in order. The recorded plays stay legal
* against the pinned dictionary, so replay reproduces the exact board, racks, bag and scores.
*/
export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
const opts: LocalGameOptions = {
variant: record.variant,
version: record.dictVersion,
seed: BigInt(record.seed),
players: record.players,
dawg,
multipleWords: record.multipleWords,
dropoutTiles: record.dropoutTiles,
};
const game = new LocalGame(opts);
for (const m of record.journal) {
switch (m.action) {
case 'play':
game.play(m.dir!, m.tiles!);
break;
case 'pass':
game.pass();
break;
case 'exchange':
game.exchange(m.exchanged!);
break;
case 'resign':
game.resignSeat(m.player);
break;
}
}
return game;
}
+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
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { LocalGameRecord } from './serialize';
// An in-memory stand-in for the IndexedDB store, so list() — which reads the *persisted* records,
// not the source's in-memory cache — can be exercised in the node env (the real store is a
// best-effort no-op without IndexedDB). Declared before the vi.mock factory that closes over it.
const store = new Map<string, LocalGameRecord>();
vi.mock('./store', () => ({
saveLocalGame: async (r: LocalGameRecord) => void store.set(r.id, r),
getLocalGame: async (id: string) => store.get(id) ?? null,
listLocalGames: async () => [...store.values()],
deleteLocalGame: async (id: string) => void store.delete(id),
}));
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';
const seats: Seat[] = [
{ kind: 'human', name: 'You' },
{ kind: 'robot', name: 'Robot' },
];
const opts = (id: string, seed: bigint) => ({
id,
variant: 'scrabble_en' as const,
dictVersion: 'sample',
seed,
multipleWords: true,
seats,
});
beforeEach(() => store.clear());
describe('LocalSource.list', () => {
it('is empty when no games are stored', async () => {
expect(await new LocalSource().list()).toEqual([]);
});
it('returns a lobby GameView for every stored local game', async () => {
const src = new LocalSource();
await src.create(opts('local:a', 1n));
await src.create(opts('local:b', 2n));
const list = await src.list();
expect(new Set(list.map((g) => g.id))).toEqual(new Set(['local:a', 'local:b']));
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
});
it('carries the human seat account id into the GameView so the client identifies "you"', async () => {
const src = new LocalSource();
await src.create({
...opts('local:me', 3n),
seats: [
{ kind: 'human', name: 'You', accountId: 'acct-42' },
{ kind: 'robot', name: 'Robot' },
],
});
const [g] = await src.list();
expect(g.seats[0].accountId).toBe('acct-42'); // the human's real id, matched against the session
expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id
});
it('delete removes a stored local game from the list', async () => {
const src = new LocalSource();
await src.create(opts('local:x', 5n));
expect((await src.list()).map((g) => g.id)).toContain('local:x');
await src.delete('local:x');
expect((await src.list()).map((g) => g.id)).not.toContain('local:x');
});
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
await new LocalSource().create(opts('local:old', 7n));
const list = await new LocalSource().list();
expect(list.map((g) => g.id)).toEqual(['local:old']);
expect(list[0].variant).toBe('scrabble_en');
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from 'vitest';
import type { PushEvent } 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, isLocalGameId } from './source';
import type { Seat } from './serialize';
import { hasAlphabet, alphabetValues } from '../alphabet';
const seats: Seat[] = [
{ kind: 'human', name: 'You' },
{ kind: 'robot', name: 'Robot' },
];
async function newSource(id: string, seed: bigint): Promise<LocalSource> {
const src = new LocalSource();
await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats });
return src;
}
describe('LocalSource', () => {
it('detects local game ids', () => {
expect(isLocalGameId('local:abc')).toBe(true);
expect(isLocalGameId('deadbeef')).toBe(false);
});
it('creates a vs_ai game with the human to move and a full rack', async () => {
const src = await newSource('local:g1', 42n);
const st = await src.gameState('local:g1');
expect(st.game.vsAi).toBe(true);
expect(st.game.status).toBe('active');
expect(st.seat).toBe(0);
expect(st.rack.length).toBe(7);
expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']);
expect(st.game.toMove).toBe(0);
});
it('seeds the per-variant alphabet from the ruleset, so offline tiles show real weights not zeros', async () => {
// Offline there is no server to send the alphabet table the rack/board render from; opening a
// local game must seed it from the static ruleset. Without this a cold offline boot draws zeros.
const src = await newSource('local:gAlpha', 42n);
await src.gameState('local:gAlpha');
expect(hasAlphabet('scrabble_en')).toBe(true);
const values = alphabetValues('scrabble_en');
expect(values.length).toBe(26);
expect(values[0]).toBe(1); // A = 1 (pinned to rules.go / ruleset.ts), not 0
expect(values.every((v) => v > 0)).toBe(true);
});
it('replies to a human pass with a synchronous robot move, delivered via the event', async () => {
const src = await newSource('local:g2', 999n);
const events: PushEvent[] = [];
src.events('local:g2', (e) => events.push(e));
const res = await src.pass('local:g2');
expect(res.move.action).toBe('pass');
expect(res.move.player).toBe(0);
// The robot has already played (synchronously) — the state reflects both moves...
const mid = await src.gameState('local:g2');
expect(mid.game.moveCount).toBeGreaterThanOrEqual(2);
// ...and its move is delivered to subscribers via the per-game event.
await Promise.resolve();
expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
});
it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => {
const src = await newSource('local:g3', 7n);
// hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for
// this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client
// (lib/hints, measured with performance.now()) so a device clock change cannot defeat it.
await src.hint('local:g3').then(
(h) => expect(h.move).toBeDefined(),
(e) => expect((e as { code: string }).code).toBe('no_hint'),
);
});
it('records decoded moves in the history', async () => {
const src = await newSource('local:g4', 55n);
await src.pass('local:g4');
const h = await src.gameHistory('local:g4');
expect(h.moves.length).toBeGreaterThanOrEqual(2);
expect(h.moves[0].action).toBe('pass');
// the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words)
const play = h.moves.find((m) => m.action === 'play');
if (play) {
expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true);
expect(play.words.every((w) => w === w.toLowerCase())).toBe(true);
}
});
it('drives a whole local game to completion', async () => {
const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {});
let turns = 0;
while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) {
await src.pass('local:g5');
await Promise.resolve();
turns++;
}
const st = await src.gameState('local:g5');
expect(st.game.status).toBe('finished');
expect(turns).toBeLessThan(500);
const winners = st.game.seats.filter((s) => s.isWinner);
expect(winners.length).toBeLessThanOrEqual(1);
});
});
+535
View File
@@ -0,0 +1,535 @@
// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game
// screen drives a local vs_ai game with no backend. It implements the game-loop subset of the
// gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the
// real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the
// human's move the robot plays synchronously in the same call, its move persisted atomically and
// delivered to the screen through this source's own per-game event emitter (events()).
//
// It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's
// alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet.
import { LocalGame, type LocalMove } from './engine';
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize';
import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store';
import { RULESETS } from './ruleset';
import { getDawg } from '../dict';
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,
GameView,
History,
HintResult,
MoveRecord,
MoveResult,
PushEvent,
Seat,
StateView,
Tile,
Variant,
WordCheckResult,
} from '../model';
import type { Move } from '../dict/validate';
export { LOCAL_ID_PREFIX, isLocalGameId };
/**
* GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
* gateway satisfies it structurally; the local source implements it for offline play.
*/
export interface GameLoopSource {
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
draftGet(gameId: string): Promise<string>;
draftSave(gameId: string, json: string): Promise<void>;
}
const BLANK_GLYPH = '?';
// letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the
// static letters table and lib/alphabet.ts).
const indexCache = new Map<Variant, Map<string, number>>();
function letterToIndex(variant: Variant, glyph: string): number {
if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX
let m = indexCache.get(variant);
if (!m) {
m = new Map();
RULESETS[variant].letters.forEach((l, i) => m!.set(l, i));
indexCache.set(variant, m);
}
const i = m.get(glyph.toUpperCase());
if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`);
return i;
}
// indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?".
function indexToLetter(variant: Variant, index: number): string {
if (index === 255) return BLANK_GLYPH;
return RULESETS[variant].letters[index] ?? '';
}
// encodePlacements turns submitted glyph tiles into the engine's index-space placements.
function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] {
return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank }));
}
// A live local game held in memory for the session, so calls do not re-replay from the store.
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;
}
/**
* LocalSource runs local games. One instance backs every local game the app has open; it caches
* the live engine per id and persists after each move.
*/
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 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;
dictVersion: string;
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');
const game = new LocalGame({
variant: opts.variant,
version: opts.dictVersion,
seed: opts.seed,
players: opts.seats.length,
dawg,
multipleWords: opts.multipleWords,
});
const now = nowUnix();
const record = serializeGame(game, {
id: opts.id,
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, unlockedSeat: null };
this.live.set(opts.id, entry);
await saveLocalGame(record);
return this.stateView(entry);
}
/**
* list returns a lobby GameView for every stored local game, most-recently-updated first, so the
* offline lobby renders them with the same card machinery as the online games. Each is
* reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its
* dawg) is skipped, since it cannot be rendered never throws.
*/
async list(): Promise<GameView[]> {
const records = await listLocalGames();
records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix);
const views: GameView[] = [];
for (const record of records) {
try {
views.push(this.gameView(await this.load(record.id)));
} catch {
/* a record that cannot be replayed (its dawg is not cached offline) is left out */
}
}
return views;
}
/** delete removes a local game from the store and the in-memory cache the offline lobby's
* finished-game delete. Best-effort: a failed store delete still drops it from this session. */
async delete(gameId: string): Promise<void> {
this.live.delete(gameId);
this.listeners.delete(gameId);
await deleteLocalGame(gameId);
}
async gameState(gameId: string): Promise<StateView> {
return this.stateView(await this.load(gameId));
}
async gameHistory(gameId: string): Promise<History> {
const { game, record } = await this.load(gameId);
return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) };
}
async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult> {
return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles)));
}
async pass(gameId: string): Promise<MoveResult> {
return this.humanMove(gameId, (g) => g.pass());
}
async exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult> {
const idx = tiles.map((t) => letterToIndex(variant, t));
return this.humanMove(gameId, (g) => g.exchange(idx));
}
async resign(gameId: string): Promise<MoveResult> {
// A resignation ends a two-player game immediately; no robot reply follows.
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
// the engine's top-ranked move; the client only calls it once the gate is open.
const entry = await this.load(gameId);
const top = this.topMove(entry.game);
if (!top) throw new GatewayError('no_hint');
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
}
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
const { game } = await this.load(gameId);
const r = game.evaluatePlay(encodePlacements(variant, tiles));
return {
legal: r.legal,
score: r.score,
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()),
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
};
}
async checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult> {
const { game } = await this.load(gameId);
const idx = Array.from(word, (ch) => letterToIndex(variant, ch));
return { word, legal: game.dictionaryHas(idx) };
}
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is
// rebuilt from the rack on reopen. The screen tolerates an empty draft.
async draftGet(): Promise<string> {
return '';
}
async draftSave(): Promise<void> {
/* no-op offline */
}
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an
* unsubscribe. The network path uses the gateway's global stream instead. */
events(gameId: string, onEvent: (e: PushEvent) => void): () => void {
let set = this.listeners.get(gameId);
if (!set) {
set = new Set();
this.listeners.set(gameId, set);
}
set.add(onEvent);
return () => set!.delete(onEvent);
}
// --- internals -------------------------------------------------------------
private async load(gameId: string): Promise<Live> {
const cached = this.live.get(gameId);
if (cached) return cached;
const record = await getLocalGame(gameId);
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, unlockedSeat: null };
this.live.set(gameId, entry);
return entry;
}
// humanMove applies the human's action, persists, returns their MoveResult, then (if the game is
// still on and it is the robot's turn) schedules the robot's synchronous reply — already applied
// and saved here, but emitted to subscribers on a microtask so the human's move renders first.
private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise<MoveResult> {
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);
if (robotMoves.length > 0) {
queueMicrotask(() => this.emitRobot(entry, robotMoves));
}
return result;
}
// runRobots plays every consecutive robot seat until it is a human's turn or the game ends,
// returning the robot moves in order. For a two-player vs_ai game this is a single move.
private runRobots(entry: Live): LocalMove[] {
const out: LocalMove[] = [];
while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) {
const g = entry.game;
const seat = g.currentPlayer;
const cands = this.legalMoves(g);
const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength);
if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
else out.push(g.pass());
}
// Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait
// survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back
// cannot freeze it.
if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS;
return out;
}
private emitRobot(entry: Live, moves: LocalMove[]): void {
const set = this.listeners.get(entry.record.id);
if (!set) return;
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 };
for (const cb of set) cb(ev);
}
if (entry.game.isOver) {
const game = this.gameView(entry);
const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game };
for (const cb of set) cb(ev);
}
}
private async persist(entry: Live): Promise<void> {
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,
});
await saveLocalGame(entry.record);
}
private isRobotSeat(entry: Live, seat: number): boolean {
return entry.record.seats[seat]?.kind === 'robot';
}
private humanSeat(entry: Live): number {
const i = entry.record.seats.findIndex((s) => s.kind === 'human');
return i >= 0 ? i : 0;
}
private bestOpponent(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
private legalMoves(game: LocalGame): Move[] {
// The generator can emit a one-letter play only for a dictionary that has one-letter words;
// real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays.
return game.generateMoves().filter((m) => m.main.letters.length >= 2);
}
private topMove(game: LocalGame): Move | null {
const moves = this.legalMoves(game);
return moves.length > 0 ? moves[0] : null;
}
// --- shape builders --------------------------------------------------------
// 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 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,
rack,
bagLen: entry.game.bagLength,
hintsRemaining: 1,
walletBalance: 0,
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
locked,
};
}
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the
// rack and board render from (lib/alphabet); seed it from the static offline ruleset so a cold-
// booted local game shows real tile weights instead of zeros. Idempotent and cheap — a no-op once
// the table is cached (from here or a prior online session).
private ensureAlphabet(variant: Variant): void {
if (hasAlphabet(variant)) return;
const rs = RULESETS[variant];
setAlphabet(
variant,
rs.letters.map((letter, index) => ({ index, letter, value: rs.values[index] })),
);
}
private gameView(entry: Live): GameView {
const { game, record } = entry;
this.ensureAlphabet(record.variant);
const seats: Seat[] = record.seats.map((s, i) => ({
seat: i,
accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
displayName: s.name,
score: game.scoreOf(i),
hintsUsed: 0,
isWinner: game.isOver && i === game.winnerIndex,
resigned: game.resignedOf(i),
}));
return {
id: record.id,
variant: record.variant,
dictVersion: record.dictVersion,
status: game.isOver ? 'finished' : 'active',
players: game.playerCount,
toMove: game.currentPlayer,
turnTimeoutSecs: 0,
multipleWordsPerTurn: record.multipleWords,
moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix,
vsAi: !record.hotseat,
hotseat: !!record.hotseat,
unreadChat: false,
unreadMessages: false,
seats,
};
}
private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult {
return {
move: this.moveRecord(entry.record.variant, move),
game: this.gameView(entry),
rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)),
bagLen: entry.game.bagLength,
};
}
private moveRecord(variant: Variant, m: LocalMove): MoveRecord {
const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
return {
player: m.player,
action: m.action,
dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V',
mainRow: m.mainRow ?? 0,
mainCol: m.mainCol ?? 0,
tiles,
words,
count: m.count ?? 0,
score: m.score,
total: m.total,
};
}
private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord {
const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
return {
player,
action: 'play',
dir: gm.dir === 0 ? 'H' : 'V',
mainRow: gm.main.row,
mainCol: gm.main.col,
tiles,
words,
count: 0,
score: gm.score,
total: 0,
};
}
}
function nowUnix(): number {
return Math.floor(Date.now() / 1000);
}
+99
View File
@@ -0,0 +1,99 @@
// Persistent store for local (offline) games, keyed by game id, in its own IndexedDB database
// (separate from the dict blob cache in lib/dict/store.ts and from the session store). Mirrors that
// module's shape: everything is best-effort — any failure resolves to null / an empty list / a
// no-op, and the caller degrades gracefully (a game that cannot be read is simply absent).
//
// A record is small (seed + rules + seat metadata + the move journal — see localgame/serialize),
// so games are stored whole; the live board/bag/racks are rebuilt by replay on load.
import type { LocalGameRecord } from './serialize';
const DB_NAME = 'scrabble-localgames';
const STORE = 'games';
let dbPromise: Promise<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}).catch(() => {
dbPromise = null;
throw new Error('indexedDB unavailable');
});
return dbPromise;
}
/** saveLocalGame stores (inserts or replaces) a game record, swallowing any failure (best-effort). */
export async function saveLocalGame(record: LocalGameRecord): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort: a failed save just loses this snapshot */
}
}
/** getLocalGame returns the record for id, or null on a miss or any failure. */
export async function getLocalGame(id: string): Promise<LocalGameRecord | null> {
const db = openDb();
if (!db) return null;
try {
const d = await db;
return await new Promise<LocalGameRecord | null>((resolve, reject) => {
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(id);
r.onsuccess = () => resolve((r.result ?? null) as LocalGameRecord | null);
r.onerror = () => reject(r.error);
});
} catch {
return null;
}
}
/** listLocalGames returns every stored game record (unordered), or an empty list on any failure
* the offline lobby sorts and filters them. */
export async function listLocalGames(): Promise<LocalGameRecord[]> {
const db = openDb();
if (!db) return [];
try {
const d = await db;
return await new Promise<LocalGameRecord[]>((resolve) => {
const req = d.transaction(STORE, 'readonly').objectStore(STORE).getAll();
req.onsuccess = () => resolve((req.result ?? []) as LocalGameRecord[]);
req.onerror = () => resolve([]);
});
} catch {
return [];
}
}
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
export async function deleteLocalGame(id: string): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
/* best-effort */
}
}
+246
View File
@@ -0,0 +1,246 @@
{
"cases": [
{
"name": "out-basic",
"variant": "scrabble_en",
"reason": "out_of_tiles",
"hands": [
[],
[
0,
1,
2
]
],
"scores": [
50,
40
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
57,
33
],
"winner": 0
},
{
"name": "out-blank",
"variant": "scrabble_en",
"reason": "out_of_tiles",
"hands": [
[],
[
255,
0
]
],
"scores": [
30,
30
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
31,
29
],
"winner": 0
},
{
"name": "out-erudit-yo",
"variant": "erudit_ru",
"reason": "out_of_tiles",
"hands": [
[],
[
6,
32
]
],
"scores": [
10,
10
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
13,
7
],
"winner": 0
},
{
"name": "out-tie",
"variant": "scrabble_en",
"reason": "out_of_tiles",
"hands": [
[],
[]
],
"scores": [
30,
30
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
30,
30
],
"winner": -1
},
{
"name": "out-3p",
"variant": "scrabble_ru",
"reason": "out_of_tiles",
"hands": [
[],
[
0,
1
],
[
2
]
],
"scores": [
10,
10,
10
],
"resigned": [
false,
false,
false
],
"toMove": 0,
"scoresAfter": [
15,
6,
9
],
"winner": 0
},
{
"name": "scoreless",
"variant": "scrabble_en",
"reason": "scoreless",
"hands": [
[
0,
1
],
[
2,
3
]
],
"scores": [
20,
20
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
16,
15
],
"winner": 0
},
{
"name": "resign-2p",
"variant": "scrabble_en",
"reason": "resign",
"hands": [
[],
[
0
]
],
"scores": [
100,
10
],
"resigned": [
true,
false
],
"toMove": 1,
"scoresAfter": [
100,
10
],
"winner": 1
},
{
"name": "resign-3p",
"variant": "scrabble_en",
"reason": "resign",
"hands": [
[],
[],
[]
],
"scores": [
50,
60,
40
],
"resigned": [
false,
true,
false
],
"toMove": 0,
"scoresAfter": [
50,
60,
40
],
"winner": 0
},
{
"name": "aborted",
"variant": "scrabble_en",
"reason": "aborted",
"hands": [
[
0
],
[
1
]
],
"scores": [
40,
30
],
"resigned": [
false,
false
],
"toMove": 0,
"scoresAfter": [
40,
30
],
"winner": -1
}
]
}
+314
View File
@@ -0,0 +1,314 @@
{
"erudit_ru": {
"size": 33,
"rackSize": 7,
"bingo": 15,
"blanks": 3,
"values": [
1,
3,
2,
3,
2,
1,
0,
5,
5,
1,
2,
2,
2,
2,
1,
1,
2,
2,
2,
2,
3,
10,
5,
10,
5,
10,
10,
10,
5,
5,
10,
10,
3
],
"counts": [
10,
3,
5,
3,
5,
9,
0,
2,
2,
8,
4,
6,
4,
5,
8,
10,
6,
6,
6,
5,
3,
1,
2,
1,
2,
1,
1,
1,
2,
2,
1,
1,
3
],
"letters": [
"А",
"Б",
"В",
"Г",
"Д",
"Е",
"Ё",
"Ж",
"З",
"И",
"Й",
"К",
"Л",
"М",
"Н",
"О",
"П",
"Р",
"С",
"Т",
"У",
"Ф",
"Х",
"Ц",
"Ч",
"Ш",
"Щ",
"Ъ",
"Ы",
"Ь",
"Э",
"Ю",
"Я"
]
},
"scrabble_en": {
"size": 26,
"rackSize": 7,
"bingo": 50,
"blanks": 2,
"values": [
1,
3,
3,
2,
1,
4,
2,
4,
1,
8,
5,
1,
3,
1,
1,
3,
10,
1,
1,
1,
1,
4,
4,
8,
4,
10
],
"counts": [
9,
2,
2,
4,
12,
2,
3,
2,
9,
1,
1,
4,
2,
6,
8,
2,
1,
6,
4,
6,
4,
2,
2,
1,
2,
1
],
"letters": [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z"
]
},
"scrabble_ru": {
"size": 33,
"rackSize": 7,
"bingo": 50,
"blanks": 2,
"values": [
1,
3,
1,
3,
2,
1,
3,
5,
5,
1,
4,
2,
2,
2,
1,
1,
2,
1,
1,
1,
2,
10,
5,
5,
5,
8,
10,
10,
4,
3,
8,
8,
3
],
"counts": [
8,
2,
4,
2,
4,
8,
1,
1,
2,
5,
1,
4,
4,
3,
5,
10,
4,
5,
5,
5,
4,
1,
1,
1,
1,
1,
1,
1,
2,
2,
1,
1,
2
],
"letters": [
"А",
"Б",
"В",
"Г",
"Д",
"Е",
"Ё",
"Ж",
"З",
"И",
"Й",
"К",
"Л",
"М",
"Н",
"О",
"П",
"Р",
"С",
"Т",
"У",
"Ф",
"Х",
"Ц",
"Ч",
"Ш",
"Щ",
"Ъ",
"Ы",
"Ь",
"Э",
"Ю",
"Я"
]
}
}
+7 -3
View File
@@ -166,9 +166,13 @@ export class MockGateway implements GatewayClient {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' }; return { blocked: false, permanent: false, until: '', reason: '' };
} }
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> { async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// No local dictionary in mock mode; the caller falls back to the mock evaluate. // The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
throw new Error('fetchDict unsupported in mock'); // in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
// vs_ai game can generate real moves. Version is ignored (a single served file per variant).
const res = await fetch(`/e2edict/${variant}.dawg`, { signal: opts?.signal });
if (!res.ok) throw new Error(`fetchDict ${variant}: HTTP ${res.status}`);
return res.arrayBuffer();
} }
async reportLocalEval(_counts: Record<string, number>): Promise<void> { async reportLocalEval(_counts: Record<string, number>): Promise<void> {
+3
View File
@@ -42,6 +42,9 @@ export const PROFILE: Profile = {
email: 'you@example.com', email: 'you@example.com',
telegramLinked: false, telegramLinked: false,
vkLinked: false, vkLinked: false,
// Every variant's current dictionary version, as the backend advertises it on the profile —
// the offline preloader targets `variant@version`.
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
}; };
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+19
View File
@@ -29,6 +29,9 @@ export interface Seat {
score: number; score: number;
hintsUsed: number; hintsUsed: number;
isWinner: boolean; isWinner: boolean;
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
resigned?: boolean;
} }
export interface GameView { export interface GameView {
@@ -47,6 +50,10 @@ export interface GameView {
lastActivityUnix: number; lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */ /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean; 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 /** 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, * 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). */ * move result); the live-event GameView leaves it false (events bump unread instead). */
@@ -82,6 +89,14 @@ export interface StateView {
bagLen: number; bagLen: number;
hintsRemaining: number; hintsRemaining: number;
walletBalance: number; walletBalance: 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 { export interface MoveResult {
@@ -161,6 +176,10 @@ export interface Profile {
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
telegramLinked: boolean; telegramLinked: boolean;
vkLinked: boolean; vkLinked: boolean;
/** Current dictionary version per game variant (deployment-wide, not per-account). An
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
dictVersions: Partial<Record<Variant, string>>;
} }
/** Banner is the advertising-banner block of an eligible viewer's profile. */ /** Banner is the advertising-banner block of an eligible viewer's profile. */
+113
View File
@@ -0,0 +1,113 @@
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
// network, tint the chrome blue and show only local games. It is the player's own choice (the
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref());
// True while the current offline state was entered automatically (no network detected), not chosen
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is
// left as the player's choice.
let auto = $state(false);
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = {
/** active is true while the app is in offline mode (deliberate or auto-detected). */
get active(): boolean {
return active;
},
/** auto is true while offline was entered automatically (no network), not by the player. */
get auto(): boolean {
return auto;
},
};
/**
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped)
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
*/
export function setOfflineMode(on: boolean, persist = true): void {
active = on;
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
if (persist) saveOfflinePref(on);
}
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
export const TOGGLE_READY_BUDGET_MS = 5000;
/**
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
* returns false the caller shows the "needs internet" note while the fetch keeps warming the
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
* so neither is pulled into the main bundle. Returns false with no profile.
*/
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
if (!prof) return false;
const m = await import('./dict/offlineready');
const ready = await m.ensureOfflineDicts(prof, budgetMs);
if (ready) setOfflineMode(true);
return ready;
}
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
export const dictPreloadWarning = {
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
get active(): boolean {
return preloadWarn;
},
};
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on;
}
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false;
/**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
* browser tab, without a confirmed email, while offline, or when a preload is already running;
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
* and generator are imported dynamically, so neither is pulled into the main bundle.
*/
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return;
const eligible = offlinePreloadEligible({
hasEmail: !!prof.email,
standalone: isStandalone(),
inTelegram: insideTelegram(),
inVK: insideVK(),
online: typeof navigator === 'undefined' || navigator.onLine !== false,
});
if (!eligible) return;
preloadInFlight = true;
void import('./dict/preloadrun')
.then((m) => m.runPreload(prof, warnOnFail))
.catch(() => {
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
})
.finally(() => {
preloadInFlight = false;
});
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none).
beforeEach(() => {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
length: 0,
} as Storage;
});
describe('offline mode helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
});
});
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
const never = (): Promise<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => Promise.resolve();
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
const run = Promise.resolve({ failed: [] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
});
it('is not ready when a variant is still missing after the fetch', async () => {
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
});
it('is not ready when the budget elapses before the fetch resolves', async () => {
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
// and shows only local games.
import type { Variant } from './model';
const STORAGE_KEY = 'scrabble.offlineMode';
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
* storage is unavailable, so a device that cannot persist simply starts online. */
export function loadOfflinePref(): boolean {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}
/** saveOfflinePref persists the offline-mode flag (best-effort). */
export function saveOfflinePref(on: boolean): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
} catch {
/* best-effort — a failed persist just reverts to online on the next launch */
}
}
/**
* offlineReady reports whether the device can play offline right now: at least one variant is
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
*/
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
return enabled.length > 0 && enabled.every((v) => hasDict(v));
}
/** missingDicts lists the enabled variants whose dictionary is not yet available the ones the
* toggle must fetch (or wait on) before offline mode can be entered. */
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v));
}
/**
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` it
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
* the logic stays pure and unit-tests in the node env.
*/
export async function raceOfflineReady(
run: Promise<{ failed: readonly unknown[] }>,
budgetMs: number,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
): Promise<boolean> {
const elapsed = sleep(budgetMs).then(() => null);
const res = await Promise.race([run, elapsed]);
return res !== null && res.failed.length === 0;
}
/**
* offlinePreloadEligible reports whether a background dictionary preload should run in this
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth the context
* has no offline mode to prepare for so kickDictPreload skips it. Mirrors the Settings offline
* toggle's eligibility so the two never disagree.
*/
export function offlinePreloadEligible(opts: {
hasEmail: boolean;
standalone: boolean;
inTelegram: boolean;
inVK: boolean;
online: boolean;
}): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
}
/**
* shouldBootOffline decides whether a cold start skips the network and launches straight into
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
* the app must boot online once first the caller then drops the sticky flag and continues online.
*/
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
return opts.offlineActive && opts.hasSession && opts.hasProfile;
}
+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);
});
});

Some files were not shown because too many files have changed in this diff Show More