Commit Graph

18 Commits

Author SHA1 Message Date
Ilia Denisov d7337d24ea feat(rules): forbid repeating a word already on the board in Erudit
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
2026-07-27 18:33:56 +02:00
Ilia Denisov ebd1f05da8 fix(offline): keep the local game's composition across a state reload
A local (offline) game persisted no draft: draftGet returned an empty
string and draftSave was a no-op, on the assumption that the arrangement
is only ever rebuilt from the rack when the game is reopened. The game
screen, however, refetches the state on several triggers, and every one
of them applied that empty draft over the live composition — dropping the
player's pending tiles back into the rack and resetting the rack order.

Offline the most visible trigger fires on its own: with no network the
live-stream watchdog declares the stream dead after 25 s, the reconnect
4 s later flips app.streamAlive back to true, and the game screen's
reconnect effect refetches. Composing a move offline was therefore reset
roughly every half minute.

Store the draft in the game's own record instead, so a reload restores it
and it survives leaving and reopening the app, and clear it wherever the
turn advances (a committed move, a resignation, a host skip) so a hotseat
seat never inherits the previous player's arrangement. Gate the two
stream-driven refetches on the game being a network one: a local game
takes its events from the source, so reacting to the stream only cost it
a pointless reload.
2026-07-27 18:12:09 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
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 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
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 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +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 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 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 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 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
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
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
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 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 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
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
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