From 6216359c6f072dd4453a0c735fe6159fd09cabe7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:28:17 +0200 Subject: [PATCH] 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. --- ui/src/components/PinPad.svelte | 25 +-- ui/src/lib/i18n/en.ts | 10 ++ ui/src/lib/i18n/ru.ts | 10 ++ ui/src/lib/roster.test.ts | 50 ++++++ ui/src/lib/roster.ts | 32 ++++ ui/src/screens/NewGame.svelte | 280 +++++++++++++++++++++++++++++++- 6 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 ui/src/lib/roster.test.ts create mode 100644 ui/src/lib/roster.ts diff --git a/ui/src/components/PinPad.svelte b/ui/src/components/PinPad.svelte index ff2f125..f903392 100644 --- a/ui/src/components/PinPad.svelte +++ b/ui/src/components/PinPad.svelte @@ -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; 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; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 7e4b2e5..f3bd967 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -398,6 +398,16 @@ export const en = { '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', } as const; export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index e752d41..ea0310c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -398,4 +398,14 @@ export const ru: Record = { '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': 'Нет', }; diff --git a/ui/src/lib/roster.test.ts b/ui/src/lib/roster.test.ts new file mode 100644 index 0000000..8b7007d --- /dev/null +++ b/ui/src/lib/roster.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { buildSeats, commitName, rosterReady, type RosterRow } from './roster'; + +describe('commitName (keep-last-valid)', () => { + it('commits a valid, trimmed name', () => { + expect(commitName('Ann', '')).toBe('Ann'); + expect(commitName(' Ann ', '')).toBe('Ann'); + }); + + it('keeps the previous valid name when the input goes invalid', () => { + expect(commitName('', 'Ann')).toBe('Ann'); + expect(commitName(' ', 'Ann')).toBe('Ann'); + expect(commitName('a'.repeat(33), 'Ann')).toBe('Ann'); // over the 32-rune cap + expect(commitName('_bad', 'Ann')).toBe('Ann'); // leading separator + }); + + it('replaces the committed name with a newer valid one', () => { + expect(commitName('Bob', 'Ann')).toBe('Bob'); + }); +}); + +describe('rosterReady', () => { + const row = (committed: string): RosterRow => ({ name: committed, committed }); + + it('needs 2-4 rows, each with a committed name', () => { + expect(rosterReady([row('A'), row('B')])).toBe(true); + expect(rosterReady([row('A'), row('B'), row('C'), row('D')])).toBe(true); + }); + + it('rejects fewer than 2, more than 4, or an empty row', () => { + expect(rosterReady([row('A')])).toBe(false); + expect(rosterReady([row('A'), row('B'), row('C'), row('D'), row('E')])).toBe(false); + expect(rosterReady([row('A'), row('')])).toBe(false); + }); +}); + +describe('buildSeats', () => { + it('maps committed rows to account-less human seats, carrying the PIN', () => { + const lock = { hash: 'h', salt: 's' }; + const seats = buildSeats([ + { name: 'Ann', committed: 'Ann', pin: lock }, + { name: 'x', committed: 'Bob' }, + ]); + expect(seats).toEqual([ + { kind: 'human', name: 'Ann', pin: lock }, + { kind: 'human', name: 'Bob', pin: undefined }, + ]); + expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true); + }); +}); diff --git a/ui/src/lib/roster.ts b/ui/src/lib/roster.ts new file mode 100644 index 0000000..31768bd --- /dev/null +++ b/ui/src/lib/roster.ts @@ -0,0 +1,32 @@ +// Pure helpers for the offline hotseat (pass-and-play) creation roster: keeping a last-valid name as +// a row is edited, and turning the finished roster into engine seats. Kept out of the component so it +// is unit-testable; the form itself is exercised by the e2e layer. + +import { validDisplayName } from './profileValidation'; +import type { PinLock } from './pin'; +import type { Seat } from './localgame/serialize'; + +/** RosterRow is one editable player row: the live input, the last valid name kept when the input + * goes invalid, and the optional per-seat PIN lock. */ +export interface RosterRow { + name: string; + committed: string; + pin?: PinLock; +} + +/** commitName returns the new last-valid name: the trimmed input when it is a valid display name, + * otherwise prevCommitted unchanged — so editing a set name into an invalid one keeps the old one. */ +export function commitName(input: string, prevCommitted: string): string { + return validDisplayName(input) ? input.trim() : prevCommitted; +} + +/** rosterReady reports whether the roster can start a game: 2-4 rows, each with a committed name. */ +export function rosterReady(rows: RosterRow[]): boolean { + return rows.length >= 2 && rows.length <= 4 && rows.every((r) => r.committed !== ''); +} + +/** buildSeats turns committed roster rows into engine seats — all human, account-less (local + * players sharing a device), carrying each row's optional PIN. */ +export function buildSeats(rows: RosterRow[]): Seat[] { + return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin })); +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 751b244..7b5d502 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,6 +1,8 @@
- - {#if !guest && !offlineMode.active} + + {#if !guest || offlineMode.active}
@@ -198,6 +302,68 @@ disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting} onclick={() => selectedAuto && find(selectedAuto)} >{t('new.start')} + {:else if offlineMode.active} + +
+
+ {t('hotseat.hostPin')} + +
+ + {#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)} + + {/if} +
+ {#each rows as row, i (i)} +
+ (row.committed = commitName(row.name, row.committed))} + onblur={() => (row.name = row.committed)} + /> + + {#if rows.length > 2} + + {/if} +
+ {/each} +
+ {#if rows.length < 4} + + {/if} +
+ +
{:else if friends.length === 0}

{t('new.noFriends')}

{:else} @@ -245,6 +411,25 @@
{/if} + + {#if pad} + (pad = null)} + onresult={onPad} + /> + {/if} + {#if askHostPlays} + (askHostPlays = false)}> +
+ + +
+
+ {/if}
@@ -450,4 +635,93 @@ font-size: 0.85rem; line-height: 1.4; } + /* --- offline hotseat roster --- */ + .hostpin { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 11px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius-sm); + } + .roster { + display: flex; + flex-direction: column; + gap: 8px; + } + .prow { + display: flex; + align-items: center; + gap: 8px; + } + .pname { + flex: 1; + min-width: 0; + padding: 10px 11px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + } + .pname:disabled { + opacity: 0.5; + } + .pname.invalid { + border-color: var(--danger); + } + .plink { + flex: 0 0 auto; + padding: 8px 10px; + border: none; + background: none; + color: var(--accent); + font-weight: 600; + white-space: nowrap; + } + .plink:disabled { + color: var(--text-muted); + opacity: 0.6; + } + .pkebab { + flex: 0 0 auto; + padding: 6px 8px; + border: none; + background: none; + color: var(--text-muted); + font-size: 1.1rem; + line-height: 1; + } + .addrow { + align-self: flex-start; + padding: 8px 12px; + border: 1px dashed var(--border); + background: none; + color: var(--accent); + border-radius: var(--radius-sm); + font-weight: 600; + } + .addrow:disabled { + opacity: 0.5; + color: var(--text-muted); + } + .hostplays { + display: flex; + gap: 10px; + justify-content: flex-end; + } + .hostplays button { + padding: 10px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-weight: 600; + } + .hostplays .primary { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + }