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

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:
Ilia Denisov
2026-07-07 16:20:53 +02:00
parent 80cc2a76e8
commit a7f483792f
4 changed files with 53 additions and 8 deletions
+19 -1
View File
@@ -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);
});
});
+22
View File
@@ -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<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;
}