diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts index 875beb6..2e26dab 100644 --- a/ui/e2e/hotseat.spec.ts +++ b/ui/e2e/hotseat.spec.ts @@ -41,8 +41,10 @@ test.describe('offline hotseat (pass-and-play)', () => { await enterLobby(page); await goOffline(page); - // A known bag seed so the locked seat deals a full rack deterministically. - await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); + // A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps + // the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is + // randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.) + await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2')); // New game -> the offline mode selector now offers "with friends" (hotseat). await page.locator('button.tab').nth(0).click(); diff --git a/ui/src/lib/roster.test.ts b/ui/src/lib/roster.test.ts index 8b7007d..117d0b2 100644 --- a/ui/src/lib/roster.test.ts +++ b/ui/src/lib/roster.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildSeats, commitName, rosterReady, type RosterRow } from './roster'; +import { buildSeats, commitName, rosterReady, shuffleSeeded, type RosterRow } from './roster'; describe('commitName (keep-last-valid)', () => { it('commits a valid, trimmed name', () => { @@ -48,3 +48,21 @@ describe('buildSeats', () => { expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true); }); }); + +describe('shuffleSeeded', () => { + it('is deterministic for the same seed', () => { + expect(shuffleSeeded([0, 1, 2, 3], 42n)).toEqual(shuffleSeeded([0, 1, 2, 3], 42n)); + }); + + it('keeps every element (a permutation) and does not mutate the input', () => { + const input = [0, 1, 2, 3]; + const out = shuffleSeeded(input, 7n); + expect([...out].sort((a, b) => a - b)).toEqual([0, 1, 2, 3]); + expect(input).toEqual([0, 1, 2, 3]); // input untouched + }); + + it('varies the order across seeds (not always the identity)', () => { + const orders = new Set([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n].map((s) => shuffleSeeded([0, 1, 2, 3], s).join(''))); + expect(orders.size).toBeGreaterThan(1); + }); +}); diff --git a/ui/src/lib/roster.ts b/ui/src/lib/roster.ts index 31768bd..0c2c7b4 100644 --- a/ui/src/lib/roster.ts +++ b/ui/src/lib/roster.ts @@ -30,3 +30,25 @@ export function rosterReady(rows: RosterRow[]): boolean { export function buildSeats(rows: RosterRow[]): Seat[] { return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin })); } + +/** + * shuffleSeeded returns a deterministic shuffle of items driven by seed — a Fisher-Yates over a small + * in-house PRNG (a distinct seed derivation from the tile bag, so the seating is not correlated with + * the tile draws). Used to randomise the hotseat seating at game start, so the first mover is random; + * being seed-driven keeps it reproducible for replay and tests. + */ +export function shuffleSeeded(items: readonly T[], seed: bigint): T[] { + const out = [...items]; + let a = (Number(BigInt.asUintN(32, seed ^ 0x9e3779b9n)) >>> 0) || 1; + const rand = (): number => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index f3c607b..be1e01d 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -13,7 +13,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { validDisplayName } from '../lib/profileValidation'; import { verifyPin, type PinLock } from '../lib/pin'; - import { commitName, rosterReady, buildSeats, type RosterRow } from '../lib/roster'; + import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster'; import type { AccountRef, Variant } from '../lib/model'; import { availableVariants, @@ -255,16 +255,19 @@ return; } const id = newLocalGameId(); + const seed = randomSeed(); + // Randomise the seating so the FIRST mover is random (the engine starts at seat 0, so shuffling + // the seats picks a random starter and turn order). Seed-driven, so replay is consistent. // Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy - // fails structured-clone when the record is persisted to IndexedDB (silently, since the store - // is best-effort) — so the game would vanish on reload. See lib/localgame/store. + // fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store) + // — so the game would vanish on reload. See lib/localgame/store. await localSource.create({ id, variant: inviteVariant, dictVersion: version, - seed: randomSeed(), + seed, multipleWords: multipleWordsForRequest(inviteVariant, multipleWords), - seats: $state.snapshot(buildSeats(rows)), + seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)), hotseat: true, hostPin: hostPin ? $state.snapshot(hostPin) : undefined, });