feat(offline): randomise the hotseat seating (random first mover)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The 2-4 hotseat players now start in a random seating order (so who goes first, and the turn order, is random) — matching the online games. The engine starts at seat 0, so shuffling the seats at creation picks the starter; the shuffle is seed-driven (a distinct derivation from the tile bag), so replay reproduces it. Scoped to hotseat — vs_ai stays human-first. - lib/roster.ts: shuffleSeeded (unit-tested); NewGame shuffles buildSeats with the game seed before create. - e2e: pin a seed that keeps the roster order so the lock assertions stay deterministic.
This commit is contained in:
@@ -41,8 +41,10 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
|||||||
await enterLobby(page);
|
await enterLobby(page);
|
||||||
await goOffline(page);
|
await goOffline(page);
|
||||||
|
|
||||||
// A known bag seed so the locked seat deals a full rack deterministically.
|
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
// 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).
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
await page.locator('button.tab').nth(0).click();
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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)', () => {
|
describe('commitName (keep-last-valid)', () => {
|
||||||
it('commits a valid, trimmed name', () => {
|
it('commits a valid, trimmed name', () => {
|
||||||
@@ -48,3 +48,21 @@ describe('buildSeats', () => {
|
|||||||
expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,3 +30,25 @@ export function rosterReady(rows: RosterRow[]): boolean {
|
|||||||
export function buildSeats(rows: RosterRow[]): Seat[] {
|
export function buildSeats(rows: RosterRow[]): Seat[] {
|
||||||
return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin }));
|
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<T>(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { validDisplayName } from '../lib/profileValidation';
|
import { validDisplayName } from '../lib/profileValidation';
|
||||||
import { verifyPin, type PinLock } from '../lib/pin';
|
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 type { AccountRef, Variant } from '../lib/model';
|
||||||
import {
|
import {
|
||||||
availableVariants,
|
availableVariants,
|
||||||
@@ -255,16 +255,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = newLocalGameId();
|
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
|
// 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
|
// fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store)
|
||||||
// is best-effort) — so the game would vanish on reload. See lib/localgame/store.
|
// — so the game would vanish on reload. See lib/localgame/store.
|
||||||
await localSource.create({
|
await localSource.create({
|
||||||
id,
|
id,
|
||||||
variant: inviteVariant,
|
variant: inviteVariant,
|
||||||
dictVersion: version,
|
dictVersion: version,
|
||||||
seed: randomSeed(),
|
seed,
|
||||||
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
||||||
seats: $state.snapshot(buildSeats(rows)),
|
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
|
||||||
hotseat: true,
|
hotseat: true,
|
||||||
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user