a7f483792f
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.
55 lines
2.4 KiB
TypeScript
55 lines
2.4 KiB
TypeScript
// 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 }));
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|