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
+10
View File
@@ -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;
+10
View File
@@ -398,4 +398,14 @@ export const ru: Record<MessageKey, string> = {
'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': 'Нет',
};
+50
View File
@@ -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);
});
});
+32
View File
@@ -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 }));
}