From 69673e7727a4d0322b1375975972c330a8925d55 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:11:13 +0200 Subject: [PATCH 01/18] feat(offline): PIN lock primitives + Apple-style keypad Groundwork for offline pass-and-play (hotseat) seat/host PIN locks: - lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin); a social lock for a shared device (documented, not cryptography). - components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit on the 4th digit, shake on mismatch, hardware-keyboard support. - i18n: pin.* keys (en + ru). Engine/source/UI wiring follows. --- ui/src/components/PinPad.svelte | 262 ++++++++++++++++++++++++++++++++ ui/src/lib/i18n/en.ts | 9 ++ ui/src/lib/i18n/ru.ts | 9 ++ ui/src/lib/pin.test.ts | 49 ++++++ ui/src/lib/pin.ts | 45 ++++++ 5 files changed, 374 insertions(+) create mode 100644 ui/src/components/PinPad.svelte create mode 100644 ui/src/lib/pin.test.ts create mode 100644 ui/src/lib/pin.ts diff --git a/ui/src/components/PinPad.svelte b/ui/src/components/PinPad.svelte new file mode 100644 index 0000000..ff2f125 --- /dev/null +++ b/ui/src/components/PinPad.svelte @@ -0,0 +1,262 @@ + + + + + +
+ {#if phase === 'menu'} + + {:else} +

{promptText()}

+ {#key shakeSeq} + + {/key} +
+ {#each keys as k} + + {/each} + + + +
+ {/if} +
+
+ + diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1383a6d..7e4b2e5 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -389,6 +389,15 @@ export const en = { 'error.game_active': 'Available only after the game is finished.', 'error.invalid_profile': 'Some profile fields are invalid.', 'error.already_confirmed': 'This email is already confirmed.', + + 'pin.enter': 'Enter PIN', + 'pin.create': 'Create a PIN', + 'pin.repeat': 'Repeat the PIN', + 'pin.enterCurrent': 'Enter current PIN', + 'pin.mismatch': "PINs don't match", + 'pin.wrong': 'Wrong PIN', + 'pin.setNew': 'Set a new PIN', + 'pin.remove': 'Remove PIN', } 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 e40e1b9..e752d41 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -389,4 +389,13 @@ export const ru: Record = { 'error.game_active': 'Доступно только после завершения игры.', 'error.invalid_profile': 'Некоторые поля профиля некорректны.', 'error.already_confirmed': 'Эта почта уже подтверждена.', + + 'pin.enter': 'Введите PIN', + 'pin.create': 'Придумайте PIN', + 'pin.repeat': 'Повторите PIN', + 'pin.enterCurrent': 'Введите текущий PIN', + 'pin.mismatch': 'PIN не совпадает', + 'pin.wrong': 'Неверный PIN', + 'pin.setNew': 'Задать новый PIN', + 'pin.remove': 'Удалить пароль', }; diff --git a/ui/src/lib/pin.test.ts b/ui/src/lib/pin.test.ts new file mode 100644 index 0000000..af497b3 --- /dev/null +++ b/ui/src/lib/pin.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { hashPin, newLock, newSalt, verifyPin } from './pin'; + +describe('hashPin', () => { + it('is deterministic for the same pin and salt', async () => { + const salt = 'fixedsalt'; + expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt)); + }); + + it('differs when the salt differs', async () => { + expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB')); + }); + + it('differs when the pin differs', async () => { + const salt = 'fixedsalt'; + expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt)); + }); +}); + +describe('newSalt', () => { + it('returns a non-empty value', () => { + expect(newSalt().length).toBeGreaterThan(0); + }); + + it('returns a fresh value each call', () => { + expect(newSalt()).not.toBe(newSalt()); + }); +}); + +describe('newLock / verifyPin', () => { + it('produces a lock that verifies its own pin', async () => { + const lock = await newLock('1234'); + expect(lock.hash.length).toBeGreaterThan(0); + expect(lock.salt.length).toBeGreaterThan(0); + expect(await verifyPin('1234', lock)).toBe(true); + }); + + it('rejects a wrong pin', async () => { + const lock = await newLock('1234'); + expect(await verifyPin('0000', lock)).toBe(false); + }); + + it('salts each lock independently (same pin, different hashes)', async () => { + const a = await newLock('1234'); + const b = await newLock('1234'); + expect(a.salt).not.toBe(b.salt); + expect(a.hash).not.toBe(b.hash); + }); +}); diff --git a/ui/src/lib/pin.ts b/ui/src/lib/pin.ts new file mode 100644 index 0000000..6232b79 --- /dev/null +++ b/ui/src/lib/pin.ts @@ -0,0 +1,45 @@ +// Seat / host PIN locks for offline pass-and-play (hotseat) games. +// +// A PinLock is a salted SHA-256 digest of a 4-digit PIN. This is a SOCIAL lock for a +// shared device — not cryptographic protection. A 4-digit PIN has only 10^4 +// combinations and the whole game runs on the client, so a determined peek reads the +// racks straight from memory regardless. The salt only keeps the PIN itself out of a +// casual devtools glance and defeats precomputed tables; it does not make the PIN +// brute-force resistant. + +/** PinLock is a stored, salted SHA-256 digest of a PIN (see the file header caveat). */ +export interface PinLock { + hash: string; + salt: string; +} + +// b64url encodes bytes as base64url without padding (matches the idiom in vkid.ts). +function b64url(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** newSalt returns a fresh random base64url salt for a PinLock. */ +export function newSalt(): string { + const a = new Uint8Array(16); + crypto.getRandomValues(a); + return b64url(a); +} + +/** hashPin returns the base64url SHA-256 digest binding salt to pin. */ +export async function hashPin(pin: string, salt: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${pin}`)); + return b64url(new Uint8Array(digest)); +} + +/** newLock builds a PinLock for pin with a fresh salt. */ +export async function newLock(pin: string): Promise { + const salt = newSalt(); + return { salt, hash: await hashPin(pin, salt) }; +} + +/** verifyPin reports whether pin matches the digest stored in lock. */ +export async function verifyPin(pin: string, lock: PinLock): Promise { + return (await hashPin(pin, lock.salt)) === lock.hash; +} -- 2.52.0 From 8c67d679d93111a3754b8efcc87738ddc4e47325 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:20:39 +0200 Subject: [PATCH 02/18] 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. --- ui/src/lib/localgame/serialize.test.ts | 16 +++ ui/src/lib/localgame/serialize.ts | 14 +++ ui/src/lib/localgame/source.hotseat.test.ts | 132 ++++++++++++++++++++ ui/src/lib/localgame/source.ts | 82 ++++++++++-- ui/src/lib/model.ts | 7 ++ 5 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 ui/src/lib/localgame/source.hotseat.test.ts diff --git a/ui/src/lib/localgame/serialize.test.ts b/ui/src/lib/localgame/serialize.test.ts index 3905505..6e9525c 100644 --- a/ui/src/lib/localgame/serialize.test.ts +++ b/ui/src/lib/localgame/serialize.test.ts @@ -83,4 +83,20 @@ describe('local game serialize + replay', () => { expect(rec.journal.length).toBe(g.moveCount); expect(replayGame(rec, dawg).seed).toBe(9999999999n); }); + + it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => { + const { newLock } = await import('../pin'); + const hostPin = await newLock('9999'); + const seatPin = await newLock('1234'); + const hotseatSeats: Seat[] = [ + { kind: 'human', name: 'Ann', pin: seatPin }, + { kind: 'human', name: 'Bob' }, + ]; + const g = makeGame(5n); + const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 }); + expect(rec.hotseat).toBe(true); + expect(rec.hostPin).toEqual(hostPin); + expect(rec.seats[0].pin).toEqual(seatPin); + expect(rec.seats[1].pin).toBeUndefined(); + }); }); diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts index b528d82..d635c66 100644 --- a/ui/src/lib/localgame/serialize.ts +++ b/ui/src/lib/localgame/serialize.ts @@ -12,6 +12,7 @@ import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine'; import type { Dawg } from '../dict/dawg'; import type { Variant } from '../model'; +import type { PinLock } from '../pin'; /** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */ export interface Seat { @@ -20,6 +21,9 @@ export interface Seat { /** The player's real account id for a human seat, so the client identifies "you" and the correct * turn exactly as in an online game; absent for the robot (a synthetic id is derived). */ accountId?: string; + /** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden + * until the PIN is entered (a social lock for a shared device — see lib/pin). */ + pin?: PinLock; } /** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ @@ -36,6 +40,12 @@ export interface LocalGameRecord { /** The dictionary-independent, alphabet-index-space move journal. */ journal: LocalMove[]; status: 'active' | 'finished'; + /** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on + * older records predating the feature), which the source reads as false. */ + hotseat?: boolean; + /** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a + * seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */ + hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; /** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0 @@ -49,6 +59,8 @@ export interface LocalGameRecord { export interface RecordMeta { id: string; seats: Seat[]; + hotseat?: boolean; + hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; hintUnlockAtMs: number; @@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor seats: meta.seats, journal: game.history, status: game.isOver ? 'finished' : 'active', + hotseat: meta.hotseat, + hostPin: meta.hostPin, createdAtUnix: meta.createdAtUnix, updatedAtUnix: meta.updatedAtUnix, hintUnlockAtMs: meta.hintUnlockAtMs, diff --git a/ui/src/lib/localgame/source.hotseat.test.ts b/ui/src/lib/localgame/source.hotseat.test.ts new file mode 100644 index 0000000..f7e8a0d --- /dev/null +++ b/ui/src/lib/localgame/source.hotseat.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { StateView } from '../model'; + +// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node, +// so games live in the source's in-memory cache — created once, then driven without reload). +vi.mock('../dict', () => ({ + getDawg: async () => { + const { Dawg } = await import('../dict/dawg'); + const { readFileSync } = await import('node:fs'); + return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); + }, +})); + +import { LocalSource } from './source'; +import type { Seat } from './serialize'; +import { newLock } from '../pin'; + +async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise { + const src = new LocalSource(); + await src.create({ + id, + variant: 'scrabble_en', + dictVersion: 'sample', + seed: 3n, + multipleWords: true, + seats, + hotseat: true, + hostPin: await newLock(hostDigits), + }); + return src; +} + +describe('LocalSource hotseat', () => { + it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => { + const src = await hotseat('local:h1', [ + { kind: 'human', name: 'Ann' }, + { kind: 'human', name: 'Bob' }, + ]); + const st = await src.gameState('local:h1'); + expect(st.game.vsAi).toBe(false); + expect(st.game.hotseat).toBe(true); + expect(st.game.toMove).toBe(0); + expect(st.locked).toBeFalsy(); // seat 0 has no PIN + expect(st.rack.length).toBe(7); + expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']); + }); + + it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => { + const src = await hotseat('local:h2', [ + { kind: 'human', name: 'Ann', pin: await newLock('1234') }, + { kind: 'human', name: 'Bob' }, + ]); + const st = await src.gameState('local:h2'); + expect(st.locked).toBe(true); + expect(st.rack).toEqual([]); + + await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' }); + + const opened = await src.unlockSeat('local:h2', '1234'); + expect(opened.locked).toBe(false); + expect(opened.rack.length).toBe(7); + }); + + it('re-locks the seat when the turn advances and again when it comes back', async () => { + const src = await hotseat('local:h3', [ + { kind: 'human', name: 'A', pin: await newLock('1111') }, + { kind: 'human', name: 'B' }, + { kind: 'human', name: 'C' }, + ]); + await src.unlockSeat('local:h3', '1111'); + await src.pass('local:h3'); // seat 0 -> 1 + let st = await src.gameState('local:h3'); + expect(st.game.toMove).toBe(1); + expect(st.locked).toBeFalsy(); // seat 1 has no PIN + + await src.pass('local:h3'); // 1 -> 2 + await src.pass('local:h3'); // 2 -> 0 + st = await src.gameState('local:h3'); + expect(st.game.toMove).toBe(0); + expect(st.locked).toBe(true); // seat 0 needs re-unlocking + }); + + it('lets the host skip the current turn, gated by the master PIN', async () => { + const src = await hotseat('local:h4', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + ], '4321'); + expect((await src.gameState('local:h4')).game.toMove).toBe(0); + await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' }); + const st = await src.hostAction('local:h4', '4321', 'skip'); + expect(st).not.toBeNull(); + expect((st as StateView).game.toMove).toBe(1); + }); + + it('lets the host resign a seat (ending a two-player game)', async () => { + const src = await hotseat('local:h5', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + ], '4321'); + const st = await src.hostAction('local:h5', '4321', 'resign', 1); + expect((st as StateView).game.status).toBe('finished'); + }); + + it('lets the host terminate the game, deleting it', async () => { + const src = await hotseat('local:h6', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + ], '4321'); + const r = await src.hostAction('local:h6', '4321', 'terminate'); + expect(r).toBeNull(); + await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' }); + }); + + it('verifies the master PIN', async () => { + const src = await hotseat('local:h7', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + ], '4321'); + expect(await src.verifyHostPin('local:h7', '4321')).toBe(true); + expect(await src.verifyHostPin('local:h7', '0000')).toBe(false); + }); + + it('persists a naturally finished game (scoreless run) as finished', async () => { + const src = await hotseat('local:h8', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + ]); + for (let i = 0; i < 6; i++) await src.pass('local:h8'); + const st = await src.gameState('local:h8'); + expect(st.game.status).toBe('finished'); + }); +}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 0060d69..909d0f1 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -17,6 +17,7 @@ import { setAlphabet, hasAlphabet } from '../alphabet'; import { HINT_GATE_MS } from '../hints'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; +import { verifyPin, type PinLock } from '../pin'; import { GatewayError, type PlacedTile } from '../client'; import type { EvalResult, @@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; interface Live { game: LocalGame; record: LocalGameRecord; + /** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null. + * Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */ + unlockedSeat: number | null; } /** @@ -97,7 +101,8 @@ export class LocalSource implements GameLoopSource { private readonly live = new Map(); private readonly listeners = new Map void>>(); - /** create starts a new local vs_ai game, persists it and returns its state. */ + /** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when + * hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */ async create(opts: { id: string; variant: Variant; @@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource { seed: bigint; multipleWords: boolean; seats: LocalSeat[]; + hotseat?: boolean; + hostPin?: PinLock; }): Promise { const dawg = await getDawg(opts.variant, opts.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); @@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource { const now = nowUnix(); const record = serializeGame(game, { id: opts.id, - seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })), + seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })), + hotseat: opts.hotseat, + hostPin: opts.hostPin, createdAtUnix: now, updatedAtUnix: now, hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once }); - const entry: Live = { game, record }; + const entry: Live = { game, record, unlockedSeat: null }; this.live.set(opts.id, entry); await saveLocalGame(record); return this.stateView(entry); @@ -185,10 +194,59 @@ export class LocalSource implements GameLoopSource { const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = entry.game.resign(); + entry.unlockedSeat = null; await this.persist(entry); return this.moveResult(entry, move, seat); } + /** + * unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin + * against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks + * the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a + * mismatch. Returns the freshly unlocked state (rack revealed). + */ + async unlockSeat(gameId: string, pin: string): Promise { + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const lock = entry.record.seats[seat]?.pin; + if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin'); + entry.unlockedSeat = seat; + return this.stateView(entry); + } + + /** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */ + async verifyHostPin(gameId: string, pin: string): Promise { + const entry = await this.load(gameId); + return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin)); + } + + /** + * hostAction runs a host (referee) override on a hotseat game, gated by the master PIN: + * 'skip' — pass the current seat's turn; + * 'resign' — resign seat targetSeat (exclude a player / concede on their behalf); + * 'terminate' — end the game with no result and delete it from the store. + * Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad + * PIN. + */ + async hostAction( + gameId: string, + pin: string, + action: 'skip' | 'resign' | 'terminate', + targetSeat?: number, + ): Promise { + const entry = await this.load(gameId); + if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin'); + if (action === 'terminate') { + await this.delete(gameId); + return null; + } + if (action === 'skip') entry.game.pass(); + else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); + entry.unlockedSeat = null; + await this.persist(entry); + return this.stateView(entry); + } + async hint(gameId: string): Promise { // The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints): // a wall-clock timestamp here could be defeated by changing the device clock. This just serves @@ -246,7 +304,7 @@ export class LocalSource implements GameLoopSource { if (!record) throw new GatewayError('game_not_found'); const dawg = await getDawg(record.variant, record.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); - const entry: Live = { game: replayGame(record, dawg), record }; + const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null }; this.live.set(gameId, entry); return entry; } @@ -258,6 +316,7 @@ export class LocalSource implements GameLoopSource { const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = act(entry.game); + entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai) const robotMoves = this.runRobots(entry); await this.persist(entry); const result = this.moveResult(entry, move, seat); @@ -306,6 +365,8 @@ export class LocalSource implements GameLoopSource { entry.record = serializeGame(entry.game, { id: entry.record.id, seats: entry.record.seats, + hotseat: entry.record.hotseat, + hostPin: entry.record.hostPin, createdAtUnix: entry.record.createdAtUnix, updatedAtUnix: nowUnix(), hintUnlockAtMs: entry.record.hintUnlockAtMs, @@ -351,9 +412,14 @@ export class LocalSource implements GameLoopSource { return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0; } + // stateView is the seated player's private view. For a vs_ai game that is the single human seat; for + // a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld + // (empty, locked=true) until unlockSeat is called this turn. private stateView(entry: Live): StateView { - const seat = this.humanSeat(entry); - const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); + const hotseat = !!entry.record.hotseat; + const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry); + const locked = hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat; + const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); return { game: this.gameView(entry), seat, @@ -362,6 +428,7 @@ export class LocalSource implements GameLoopSource { hintsRemaining: 1, walletBalance: 0, hintUnlockLeftSeconds: this.hintUnlockLeft(entry), + locked, }; } @@ -401,7 +468,8 @@ export class LocalSource implements GameLoopSource { moveCount: game.moveCount, endReason: game.isOver ? game.endReason : '', lastActivityUnix: record.updatedAtUnix, - vsAi: true, + vsAi: !record.hotseat, + hotseat: !!record.hotseat, unreadChat: false, unreadMessages: false, seats, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 7dbe543..8b22cbb 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -47,6 +47,10 @@ export interface GameView { lastActivityUnix: number; /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */ vsAi: boolean; + /** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by + * the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host + * menu (which replaces the hint control). A hotseat game is never vsAi. */ + hotseat?: boolean; /** Per-viewer flag: the requesting player has at least one unread chat entry (message or * nudge) in this game. Set on the authoritative REST views (lobby list, game state, * move result); the live-event GameView leaves it false (events bump unread instead). */ @@ -87,6 +91,9 @@ export interface StateView { * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, * so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */ hintUnlockLeftSeconds?: number; + /** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the + * seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */ + locked?: boolean; } export interface MoveResult { -- 2.52.0 From 6216359c6f072dd4453a0c735fe6159fd09cabe7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:28:17 +0200 Subject: [PATCH 03/18] 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); + } -- 2.52.0 From 4ddc690c384a40a8c3702c6e4d2284bdf860b334 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:39:43 +0200 Subject: [PATCH 04/18] feat(offline): hotseat in-game seat lock + host menu Game.svelte drives a hotseat game: - the seat-to-move's rack is replaced by an Unlock button when the seat is PIN-locked (board stays visible); canMove gates the move controls on the unlock; PinPad(verify) -> source.unlockSeat reveals it. - the hint slot becomes a host button (hints off in hotseat), always enabled while the game runs (acts on a locked seat too). Master-PIN -> action sheet: skip current / exclude a player / end the game, each with a confirm + a fading check; terminate deletes and returns to the lobby. - per-turn advance re-fetches the next seat's (locked) state; self-resign and chat are hidden (hotseat resign is a host action, no comms). - gamesource: expose unlockSeat / verifyHostPin / hostAction on the local proxy. i18n hotseat.* (en + ru). --- ui/src/game/Game.svelte | 266 ++++++++++++++++++++++++++++++++++++--- ui/src/lib/gamesource.ts | 8 +- ui/src/lib/i18n/en.ts | 10 ++ ui/src/lib/i18n/ru.ts | 10 ++ 4 files changed, 275 insertions(+), 19 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 09e884d..a9bf0e1 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -4,6 +4,7 @@ import TabBar from '../components/TabBar.svelte'; import TapConfirm from '../components/TapConfirm.svelte'; import Modal from '../components/Modal.svelte'; + import PinPad from '../components/PinPad.svelte'; import DictWarmup from '../components/DictWarmup.svelte'; import Board from './Board.svelte'; import Rack from './Rack.svelte'; @@ -163,6 +164,11 @@ const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status === 'finished'); + // Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn. + // While locked the board stays visible but the rack is withheld and the move controls disabled; + // canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game). + const locked = $derived(!!view?.locked); + const canMove = $derived(isMyTurn && !locked); // The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the // wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet // hint was spent in another game (see lib/hints). @@ -774,8 +780,8 @@ // rapid placements do not pile up requests on a slow link. evalCtrl?.abort(); evalCtrl = null; - // Off-turn the composition is position-only: no score preview or evaluate. - if (!isMyTurn) return; + // Off-turn (or a locked hotseat seat) the composition is position-only: no score preview. + if (!canMove) return; const sub = toSubmit(placement); if (!sub) return; // Instant on-device preview when the game's dictionary is warm; the network otherwise. @@ -833,12 +839,106 @@ refreshRecent(); } + // --- offline hotseat: seat unlock + host (referee) overrides ------------------- + let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack + // The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is + // the verified master PIN, reused to authorise the chosen action (the source re-checks it). + let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed'); + let hostPinEntered = $state(''); + // The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list. + let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null); + let hostDone = $state(false); // the transient success ✅ shown after an override + + // hotseatName is the plain display name of a seat by index (hotseat seats are account-less local + // players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere). + function hotseatName(i: number): string { + return view?.game.seats[i]?.displayName ?? ''; + } + + function startSkip(): void { + if (!view) return; + hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) }; + } + + function flashDone(): void { + hostDone = true; + setTimeout(() => (hostDone = false), 1100); + } + + // advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced + // the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked). + async function advanceHotseat(): Promise { + const st = await source.gameState(id, false); + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + preview = null; + setCachedGame(id, st, moves, ''); + } + + // onUnlock verifies the current seat's PIN through the source (which reveals the rack on success) + // and applies the unlocked state; handed to the PIN pad as its verifier. + async function onUnlock(pin: string): Promise { + try { + const st = await localSource.unlockSeat(id, pin); + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + return true; + } catch { + return false; + } + } + + // verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it. + async function verifyHost(pin: string): Promise { + hostPinEntered = pin; + return localSource.verifyHostPin(id, pin); + } + + function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string { + if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' }); + if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' }); + return t('hotseat.askTerminate'); + } + + async function runHostAction(): Promise { + if (!hostConfirm) return; + const { action, seat } = hostConfirm; + hostConfirm = null; + hostMenuStep = 'closed'; + busy = true; + try { + const st = await localSource.hostAction(id, hostPinEntered, action, seat); + if (st === null) { + navigate('/'); // terminated: the game is deleted, so return to the lobby + return; + } + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + preview = null; + moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move + setCachedGame(id, st, moves, ''); + flashDone(); + } catch (e) { + handleError(e); + } finally { + busy = false; + hostPinEntered = ''; + } + } + async function commit() { const sub = toSubmit(placement); if (!sub) return; busy = true; try { applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); + if (view?.game.hotseat) await advanceHotseat(); haptic('success'); zoomed = false; } catch (e) { @@ -858,6 +958,7 @@ busy = true; try { applyMoveResult(await source.pass(id)); + if (view?.game.hotseat) await advanceHotseat(); } catch (e) { handleError(e); } finally { @@ -979,6 +1080,7 @@ busy = true; try { applyMoveResult(await source.exchange(id, tiles, variant)); + if (view?.game.hotseat) await advanceHotseat(); } catch (e) { handleError(e); } finally { @@ -1433,13 +1535,17 @@ {:else} {/if} + {:else if view.game.hotseat} + + {:else} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - {#if !(gameOver && view.game.vsAi)} + game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). + A hotseat game has no chat either (local players, no accounts). --> + {#if !(gameOver && view.game.vsAi) && !view.game.hotseat} @@ -1512,7 +1618,7 @@ {#if gameOver} {resultText()} {:else if placement.pending.length === 0} - {isMyTurn ? t('game.yourTurn') : turnLabel()} + {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} @@ -1526,28 +1632,40 @@ a finished game shows the final rack greyed out and the controls disabled. -->
- + {#if locked} + + + {:else} + + {/if}
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} - + {/if}
{/snippet} {#snippet controlButtons()} - - {#if view?.game.vsAi} + {#if view?.game.hotseat} + + + {:else if view?.game.vsAi} @@ -1623,6 +1741,55 @@ {/if} + +{#if unlockOpen && view} + (unlockOpen = false)} + onresult={() => (unlockOpen = false)} + /> +{/if} + + +{#if hostMenuStep === 'pin'} + (hostMenuStep = 'closed')} + onresult={() => (hostMenuStep = 'menu')} + /> +{/if} +{#if hostMenuStep === 'menu' && view} + { hostMenuStep = 'closed'; hostConfirm = null; }}> + {#if hostConfirm} +

{hostConfirmText(hostConfirm)}

+
+ + +
+ {:else} +
+ +
{t('hotseat.exclude')}
+ {#each view.game.seats as s (s.seat)} + + {/each} + +
+ {/if} +
+{/if} +{#if hostDone} + +{/if} + {#if exportOpen} (exportOpen = false)}>
@@ -2122,4 +2289,67 @@ font-size: 0.62rem; line-height: 1; } + /* --- offline hotseat --- */ + /* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above + stays visible. */ + .unlock { + width: 100%; + height: 100%; + min-height: 44px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .host-menu { + display: flex; + flex-direction: column; + gap: 8px; + } + .host-act, + .host-seat { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-weight: 600; + text-align: left; + } + .host-act.danger { + border-color: var(--danger); + color: var(--danger); + } + .host-sub { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 4px; + } + .host-q { + margin: 0 0 12px; + font-weight: 600; + } + /* The transient success tick after a host override — a centred ✅ that fades out on a timer. */ + .host-done { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + z-index: 50; + pointer-events: none; + animation: host-done-fade 1.1s ease forwards; + } + @keyframes host-done-fade { + 0% { opacity: 0; transform: scale(0.7); } + 25% { opacity: 1; transform: scale(1); } + 75% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(1); } + } diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index 201ffbe..9031aac 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -41,6 +41,11 @@ export const localSource = { create: (opts) => load().then((s) => s.create(opts)), list: () => load().then((s) => s.list()), delete: (id) => load().then((s) => s.delete(id)), + // Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host + // (referee) overrides gated by the master PIN. + unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)), + verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)), + hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)), // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // the real subscription once the engine loads and, until then, cancels a pending subscribe. events: (id, onEvent) => { @@ -54,7 +59,8 @@ export const localSource = { unsub(); }; }, -} satisfies GameLoopSource & Pick; +} satisfies GameLoopSource & + Pick; /** * gameSource returns the source that runs the game with the given id: the local engine for a local diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index f3bd967..d665346 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -408,6 +408,16 @@ export const en = { 'hotseat.hostPlaysTitle': 'Are you playing too?', 'hotseat.hostPlaysYes': 'Yes, I play', 'hotseat.hostPlaysNo': 'No', + 'hotseat.unlock': 'Unlock', + 'hotseat.unlockTitle': 'Unlock: {name}', + 'hotseat.turnOf': 'Turn: {name}', + 'hotseat.host': 'Host', + 'hotseat.skip': 'Skip the current turn', + 'hotseat.exclude': 'Exclude a player:', + 'hotseat.terminate': 'End the game', + 'hotseat.askSkip': "Skip {name}'s turn?", + 'hotseat.askExclude': 'Remove {name} from the game?', + 'hotseat.askTerminate': 'End the game with no result?', } 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 ea0310c..05ecef9 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -408,4 +408,14 @@ export const ru: Record = { 'hotseat.hostPlaysTitle': 'Принимаете участие в игре?', 'hotseat.hostPlaysYes': 'Да, играю', 'hotseat.hostPlaysNo': 'Нет', + 'hotseat.unlock': 'Разблокировать', + 'hotseat.unlockTitle': 'Разблокировать: {name}', + 'hotseat.turnOf': 'Ход: {name}', + 'hotseat.host': 'Ведущий', + 'hotseat.skip': 'Пропустить ход', + 'hotseat.exclude': 'Исключить игрока:', + 'hotseat.terminate': 'Отменить партию', + 'hotseat.askSkip': 'Пропустить ход игрока {name}?', + 'hotseat.askExclude': 'Удалить {name} из игры?', + 'hotseat.askTerminate': 'Отменить партию без результата?', }; -- 2.52.0 From 452a686e07690f7b3eda42a2d9c1fe5f4879fb4c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 11:43:02 +0200 Subject: [PATCH 05/18] feat(offline): hotseat lobby delete behind the master PIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lobby.svelte: hotseat games show the kebab / swipe delete in BOTH the active and finished groups (not finished-only); deleting one opens a master-PIN pad first — active = terminate (no result), finished = remove — so the last mover cannot instantly wipe a game. Local games stay deletable offline. Non-hotseat games are unchanged (finished-only, free). --- ui/src/screens/Lobby.svelte | 46 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 6ba4fbe..6189512 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -4,6 +4,7 @@ import Screen from '../components/Screen.svelte'; import TabBar from '../components/TabBar.svelte'; import Modal from '../components/Modal.svelte'; + import PinPad from '../components/PinPad.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { gateway } from '../lib/gateway'; @@ -186,6 +187,9 @@ // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is // per-account and irreversible. Only one row is revealed at a time. let revealedId = $state(null); + // A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game) + // and finished; pinTarget holds the game id while its PIN pad is open. + let pinTarget = $state(null); let drag: { id: string; x0: number; y0: number } | null = null; // A horizontal swipe must not also count as a tap that opens the game; armed on swipe, // consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten. @@ -196,13 +200,14 @@ if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe drag = { id, x0: e.clientX, y0: e.clientY }; } - function onRowUp(e: PointerEvent, finished: boolean): void { + function onRowUp(e: PointerEvent, revealable: boolean): void { if (!drag) return; const dx = e.clientX - drag.x0; const dy = e.clientY - drag.y0; - // Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it - // does not also open the game. Active rows ignore swipes and stay plain tap-to-open. - if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { + // Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal + // swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows + // ignore swipes and stay plain tap-to-open. + if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { swiped = true; revealedId = dx < 0 ? drag.id : null; } @@ -240,6 +245,14 @@ } } + // startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host + // master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at + // once. + function startDelete(g: GameView): void { + if (g.hotseat) pinTarget = g.id; + else void hide(g.id); + } + async function acceptInvite(inv: Invitation) { try { const r = await gateway.invitationAccept(inv.id); @@ -315,15 +328,15 @@
{#each group.list as g (g.id)} {@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)} -
- {#if group.finished} - +
+ {#if group.finished || g.hotseat} + {/if}
- {#if group.finished} + {#if group.finished || g.hotseat} {:else} + {#if pinTarget} + localSource.verifyHostPin(pinTarget ?? '', p)} + onclose={() => (pinTarget = null)} + onresult={() => { + const id = pinTarget; + pinTarget = null; + if (id) void hide(id); + }} + /> + {/if} + {#snippet tabbar()} {:else if offlineMode.active} + PIN first (it gates the roster); each seat may add its own PIN. The scrollable region + + the pinned Start button mirror the friends form, so a name input's soft keyboard shrinks + the region (no full-height spacer that would paint a blank block behind the keyboard). -->
-
- {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)} - /> - +
+ +
+ {#each variants as v (v.id)} + - {#if rows.length > 2} - - {/if} -
- {/each} + placeholder={t('hotseat.playerName')} + maxlength="40" + oninput={() => (row.committed = commitName(row.name, row.committed))} + onblur={() => (row.name = row.committed)} + /> + + {#if rows.length > 2} + {#if deleteRow === i} + + {/if} + + {/if} +
+ {/each} +
+ {#if rows.length < 4} + + {/if}
- {#if rows.length < 4} - - {/if} -
{:else if offlineMode.active} -
-
-
- {t('hotseat.hostPin')} - -
- -
- {#each variants as v (v.id)} - - {/each} -
- {#if variants.some((v) => supportsMultipleWordsToggle(v.id))} - - {/if} -
- {#each rows as row, i (i)} -
- (row.committed = commitName(row.name, row.committed))} - onblur={() => (row.name = row.committed)} - /> - - {#if rows.length > 2} - {#if deleteRow === i} - - {/if} - - {/if} -
- {/each} -
- {#if rows.length < 4} - - {/if} -
- + PIN first (it gates the roster); each seat may add its own PIN. --> +
+ {t('hotseat.hostPin')} +
+ +
+ {#each variants as v (v.id)} + + {/each} +
+ {#if variants.some((v) => supportsMultipleWordsToggle(v.id))} + + {/if} +
+ {#each rows as row, i (i)} +
+ (row.committed = commitName(row.name, row.committed))} + onblur={() => (row.name = row.committed)} + /> + + {#if rows.length > 2} + {#if deleteRow === i} + + {/if} + + {/if} +
+ {/each} +
+ {#if rows.length < 4} + + {/if} + {:else if friends.length === 0}

{t('new.noFriends')}

{:else} @@ -486,6 +492,12 @@ height: 100%; box-sizing: border-box; } + /* The hotseat form: natural height (grows past the viewport → the screen's own scroll takes over) + with no full-height pinning, so a focused name input just scrolls the page above the keyboard. */ + .page.scrollform { + height: auto; + min-height: 100%; + } .subtitle { color: var(--text-muted); margin: 0; @@ -680,17 +692,6 @@ line-height: 1.4; } /* --- offline hotseat roster --- */ - /* The scrollable region (host PIN + variants + roster), pinned above the Start button — mirrors - the friends form so a focused name input's soft keyboard shrinks this region (via --vvh on the - screen) instead of leaving a full-height blank spacer painted behind the keyboard. */ - .hs-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 14px; - } .hostpin { display: flex; align-items: center; -- 2.52.0 From c1ac77bc6ad8895dbb8f42b9026007c3220c875a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 13:19:22 +0200 Subject: [PATCH 11/18] fix(offline): unify NewGame layout to natural-flow + PinPad verdict pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewGame: all three forms (quick / friends / hotseat) now use the Profile sub-view's natural-flow .page (no forced height, no pinned-CTA .grow/.fg scroll), so the screen's single .content scroll + --vvh handle overflow and a focused name input scrolls into view cleanly — fixes the keyboard blank-space / deep-scroll regression. One layout rule, one place. - PinPad: pause 250ms after the 4th digit before the verdict, so the fill (and the shake on a wrong PIN) reads as a deliberate response. - e2e: typePin waits for empty dots before typing (robust to the pause). --- ui/e2e/hotseat.spec.ts | 3 +++ ui/src/components/PinPad.svelte | 3 +++ ui/src/screens/NewGame.svelte | 35 ++++----------------------------- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts index 1ac8c6a..4f92a54 100644 --- a/ui/e2e/hotseat.spec.ts +++ b/ui/e2e/hotseat.spec.ts @@ -27,7 +27,10 @@ async function goOffline(page: Page): Promise { } // typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action). +// It first waits for the dots to be empty, so a call made right after a previous entry does not lose +// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance). async function typePin(page: Page, digits: string): Promise { + await expect(page.locator('.pad .dot.on')).toHaveCount(0); for (const d of digits) await page.locator('.pad .key', { hasText: d }).click(); } diff --git a/ui/src/components/PinPad.svelte b/ui/src/components/PinPad.svelte index f903392..9514e61 100644 --- a/ui/src/components/PinPad.svelte +++ b/ui/src/components/PinPad.svelte @@ -72,6 +72,9 @@ async function commit(): Promise { busy = true; try { + // Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake) + // reads as a deliberate response rather than an instant flicker. + await new Promise((r) => setTimeout(r, 250)); if (phase === 'old') { if (verify && (await verify(buffer))) { buffer = ''; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 8ada7c3..f3c607b 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -232,14 +232,6 @@ deleteRow = null; } - // Centre a focused name input above the soft keyboard: the keyboard shrinks the screen (--vvh), - // so wait a beat for it to open + the layout to settle, then scroll the field into view (the - // default focus-scroll leaves a bottom row hidden behind the keyboard). - function bringIntoView(e: FocusEvent): void { - const el = e.currentTarget as HTMLElement; - setTimeout(() => el.scrollIntoView({ block: 'center' }), 300); - } - function setHostPlays(yes: boolean): void { askHostPlays = false; if (!yes) return; @@ -286,10 +278,7 @@ - -
+
@@ -333,7 +322,6 @@ {/if} -

{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

{#if opponent === 'random'}

{t('new.searchHint')}

{/if}