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.
This commit is contained in:
Ilia Denisov
2026-07-07 11:28:17 +02:00
parent 8c67d679d9
commit 6216359c6f
6 changed files with 393 additions and 14 deletions
+14 -11
View File
@@ -2,15 +2,17 @@
// 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; auto-checks against `lock` on the 4th digit; emits
// { kind: 'verified' } or shakes + clears on a wrong PIN.
// change — verify the current PIN (against `lock`), then offer "set a new PIN"
// (the set flow) or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
// 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.
// 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, verifyPin, type PinLock } from '../lib/pin';
import { newLock, type PinLock } from '../lib/pin';
/** PinResult is the outcome handed to onresult when the pad completes. */
export type PinResult =
@@ -21,14 +23,15 @@
let {
mode,
title = '',
lock,
verify,
allowRemove = false,
onclose,
onresult,
}: {
mode: 'set' | 'verify' | 'change';
title?: string;
lock?: PinLock;
/** Verifies an entered PIN (required for 'verify' and 'change'). */
verify?: (pin: string) => Promise<boolean>;
allowRemove?: boolean;
onclose?: () => void;
onresult: (r: PinResult) => void;
@@ -70,13 +73,13 @@
busy = true;
try {
if (phase === 'old') {
if (lock && (await verifyPin(buffer, lock))) {
if (verify && (await verify(buffer))) {
buffer = '';
error = false;
phase = 'menu';
} else fail();
} else if (phase === 'enter' && mode === 'verify') {
if (lock && (await verifyPin(buffer, lock))) onresult({ kind: 'verified' });
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
else fail();
} else if (phase === 'enter') {
first = buffer;