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).
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.
- 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.
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.