Files
scrabble-game/ui/src/lib/roster.test.ts
T
Ilia Denisov 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
feat(offline): randomise the hotseat seating (random first mover)
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.
2026-07-07 16:20:53 +02:00

69 lines
2.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildSeats, commitName, rosterReady, shuffleSeeded, 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);
});
});
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);
});
});