diff --git a/package-lock.json b/package-lock.json index b9a307f1d..aebb5cb81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,6 @@ "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", "obscenity": "^0.4.6", - "seedrandom": "^3.0.5", "ts-node": "^10.9.2", "tsx": "^4.21.0", "winston": "^3.19.0", @@ -57,7 +56,6 @@ "@types/msgpack5": "^3.4.6", "@types/node": "^24.12.0", "@types/pg": "^8.20.0", - "@types/seedrandom": "^3.0.8", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", @@ -2669,13 +2667,6 @@ "@types/node": "*" } }, - "node_modules/@types/seedrandom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz", - "integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -8054,12 +8045,6 @@ "node": ">=v12.22.7" } }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", diff --git a/package.json b/package.json index 6e7416d96..75aecc69a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "@types/msgpack5": "^3.4.6", "@types/node": "^24.12.0", "@types/pg": "^8.20.0", - "@types/seedrandom": "^3.0.8", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", @@ -110,7 +109,6 @@ "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", "obscenity": "^0.4.6", - "seedrandom": "^3.0.5", "ts-node": "^10.9.2", "tsx": "^4.21.0", "winston": "^3.19.0", diff --git a/src/core/PseudoRandom.ts b/src/core/PseudoRandom.ts index be1410870..9e192c0d4 100644 --- a/src/core/PseudoRandom.ts +++ b/src/core/PseudoRandom.ts @@ -1,34 +1,62 @@ -import seedrandom from "seedrandom"; - export class PseudoRandom { - private rng: seedrandom.PRNG; + // sfc32 state. All operations are 32-bit integer ops, so sequences are + // identical across platforms. + private s0: number; + private s1: number; + private s2: number; + private s3: number; private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8 constructor(seed: number) { - this.rng = seedrandom(String(seed)); + // The seed is truncated to 32 bits: seeds congruent mod 2^32 produce + // identical streams, and fractional parts are discarded. + // Expand the numeric seed into four state words with splitmix32. + let h = seed | 0; + const split = () => { + h = (h + 0x9e3779b9) | 0; + let t = h ^ (h >>> 16); + t = Math.imul(t, 0x21f0aaad); + t = t ^ (t >>> 15); + t = Math.imul(t, 0x735a2d97); + return (t ^ (t >>> 15)) | 0; + }; + this.s0 = split(); + this.s1 = split(); + this.s2 = split(); + this.s3 = split(); + // Warm up to diffuse low-entropy seeds (sequential ints, small numbers). + for (let i = 0; i < 12; i++) { + this.next(); + } } // Generates the next pseudorandom number between 0 and 1. next(): number { - return this.rng(); + const t = (((this.s0 + this.s1) | 0) + this.s3) | 0; + this.s3 = (this.s3 + 1) | 0; + this.s0 = this.s1 ^ (this.s1 >>> 9); + this.s1 = (this.s2 + (this.s2 << 3)) | 0; + this.s2 = (this.s2 << 21) | (this.s2 >>> 11); + this.s2 = (this.s2 + t) | 0; + return (t >>> 0) / 4294967296; } // Generates a random integer between min (inclusive) and max (exclusive). nextInt(min: number, max: number): number { const lo = Math.floor(min); const hi = Math.floor(max); - return Math.floor(this.rng() * (hi - lo)) + lo; + return Math.floor(this.next() * (hi - lo)) + lo; } // Generates a random float between min (inclusive) and max (exclusive). nextFloat(min: number, max: number): number { - return this.rng() * (max - min) + min; + return this.next() * (max - min) + min; } // Generates a random ID (8 characters, alphanumeric). nextID(): string { - return Math.floor(this.rng() * PseudoRandom.POW36_8) + return Math.floor(this.next() * PseudoRandom.POW36_8) .toString(36) .padStart(8, "0"); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 44ba4efb6..92fc3e3d8 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -66,6 +66,25 @@ class Donation { ) {} } +// Shared singletons for empty collections in toFullUpdate. Sharing +// references lets diffPlayerUpdate's `a === b` fast paths skip structural +// comparison and avoids per-player-per-tick allocations. The arrays are +// frozen so accidental in-worker mutation throws instead of silently +// corrupting every player's updates; updates crossing to the main thread +// are structured-cloned (clones are mutable). Sets cannot be frozen +// (Set.add ignores freeze) — EMPTY_EMBARGOES must never be mutated. +const EMPTY_NUMBER_ARRAY: number[] = []; +const EMPTY_STRING_ARRAY: string[] = []; +const EMPTY_ATTACK_UPDATES: AttackUpdate[] = []; +const EMPTY_ALLIANCE_VIEWS: AllianceView[] = []; +const EMPTY_EMOJIS: EmojiMessage[] = []; +const EMPTY_EMBARGOES = new Set(); +Object.freeze(EMPTY_NUMBER_ARRAY); +Object.freeze(EMPTY_STRING_ARRAY); +Object.freeze(EMPTY_ATTACK_UPDATES); +Object.freeze(EMPTY_ALLIANCE_VIEWS); +Object.freeze(EMPTY_EMOJIS); + export class PlayerImpl implements Player { public _lastTileChange: number = 0; public _pseudo_random: PseudoRandom; @@ -146,9 +165,92 @@ export class PlayerImpl implements Player { } private toFullUpdate(): PlayerUpdate { - const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) => - ar.recipient().id(), - ); + // Empty collections reuse shared singletons (EMPTY_*) so + // diffPlayerUpdate's reference fast paths hit and nothing is allocated. + // This runs for every player every tick; most collections are empty for + // most players. The singletons are never mutated — updates are + // structured-cloned before leaving the worker. + let outgoingAllianceRequests = EMPTY_STRING_ARRAY; + for (const ar of this.mg.allianceRequests) { + if (ar.requestor() === this) { + if (outgoingAllianceRequests === EMPTY_STRING_ARRAY) { + outgoingAllianceRequests = []; + } + outgoingAllianceRequests.push(ar.recipient().id()); + } + } + + const alliances = this.alliances(); + let allies = EMPTY_NUMBER_ARRAY; + let allianceViews = EMPTY_ALLIANCE_VIEWS; + if (alliances.length > 0) { + allies = alliances.map((a) => a.other(this).smallID()); + const extensionCutoff = + this.mg.ticks() + this.mg.config().allianceExtensionPromptOffset(); + allianceViews = alliances.map( + (a) => + ({ + id: a.id(), + other: a.other(this).id(), + createdAt: a.createdAt(), + expiresAt: a.expiresAt(), + hasExtensionRequest: a.expiresAt() <= extensionCutoff, + }) satisfies AllianceView, + ); + } + + let embargoes = EMPTY_EMBARGOES; + if (this.embargoes.size > 0) { + embargoes = new Set(); + for (const id of this.embargoes.keys()) { + embargoes.add(id.toString()); + } + } + + let targets = EMPTY_NUMBER_ARRAY; + if (this.targets_.length > 0) { + const t = this.targets(); + if (t.length > 0) { + targets = t.map((p) => p.smallID()); + } + } + + let outgoingEmojis = EMPTY_EMOJIS; + if (this.outgoingEmojis_.length > 0) { + const e = this.outgoingEmojis(); + if (e.length > 0) { + outgoingEmojis = e; + } + } + + const outgoingAttacks = + this._outgoingAttacks.length === 0 + ? EMPTY_ATTACK_UPDATES + : this._outgoingAttacks.map((a) => { + return { + attackerID: a.attacker().smallID(), + targetID: a.target().smallID(), + troops: a.troops(), + id: a.id(), + retreating: a.retreating(), + } satisfies AttackUpdate; + }); + + let incomingAttacks = EMPTY_ATTACK_UPDATES; + if (this._incomingAttacks.length > 0) { + const incoming = this.incomingAttacks(); + if (incoming.length > 0) { + incomingAttacks = incoming.map((a) => { + return { + attackerID: a.attacker().smallID(), + targetID: a.target().smallID(), + troops: a.troops(), + id: a.id(), + retreating: a.retreating(), + } satisfies AttackUpdate; + }); + } + } return { type: GameUpdateType.Player, @@ -164,44 +266,16 @@ export class PlayerImpl implements Player { tilesOwned: this.numTilesOwned(), gold: this._gold, troops: this.troops(), - allies: this.alliances().map((a) => a.other(this).smallID()), - embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())), + allies: allies, + embargoes: embargoes, isTraitor: this.isTraitor(), traitorRemainingTicks: this.getTraitorRemainingTicks(), - targets: this.targets().map((p) => p.smallID()), - outgoingEmojis: this.outgoingEmojis(), - outgoingAttacks: this.outgoingAttacks().map((a) => { - return { - attackerID: a.attacker().smallID(), - targetID: a.target().smallID(), - troops: a.troops(), - id: a.id(), - retreating: a.retreating(), - } satisfies AttackUpdate; - }), - incomingAttacks: this.incomingAttacks().map((a) => { - return { - attackerID: a.attacker().smallID(), - targetID: a.target().smallID(), - troops: a.troops(), - id: a.id(), - retreating: a.retreating(), - } satisfies AttackUpdate; - }), + targets: targets, + outgoingEmojis: outgoingEmojis, + outgoingAttacks: outgoingAttacks, + incomingAttacks: incomingAttacks, outgoingAllianceRequests: outgoingAllianceRequests, - alliances: this.alliances().map( - (a) => - ({ - id: a.id(), - other: a.other(this).id(), - createdAt: a.createdAt(), - expiresAt: a.expiresAt(), - hasExtensionRequest: - a.expiresAt() <= - this.mg.ticks() + - this.mg.config().allianceExtensionPromptOffset(), - }) satisfies AllianceView, - ), + alliances: allianceViews, hasSpawned: this.hasSpawned(), spawnTile: this._spawnTile, betrayals: this._betrayalCount, diff --git a/tests/AiAttackBehavior.test.ts b/tests/AiAttackBehavior.test.ts index 3f92c720a..fe20fbfbc 100644 --- a/tests/AiAttackBehavior.test.ts +++ b/tests/AiAttackBehavior.test.ts @@ -1,3 +1,4 @@ +import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior"; import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior"; import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; import { PseudoRandom } from "../src/core/PseudoRandom"; @@ -123,13 +124,19 @@ describe("Ai Attack Behavior", () => { nation.addTroops(1000); + // Provide an emoji behavior so sendAttack can run the full Nation code + // path; the attack on an ally must be blocked by AttackExecution's + // alliance check regardless of what the AI decides. + const nationRandom = new PseudoRandom(42); const nationBehavior = new AiAttackBehavior( - new PseudoRandom(42), + nationRandom, game, nation, 0.5, 0.5, 0.2, + undefined, + new NationEmojiBehavior(nationRandom, game, nation), ); // Alliance between nation and human @@ -141,8 +148,9 @@ describe("Ai Attack Behavior", () => { const attacksBefore = nation.outgoingAttacks().length; nation.addTroops(50_000); - // Nation tries to attack ally (should be blocked) - nationBehavior.sendAttack(human); + // Force the attack past shouldAttack's dice gate so the alliance check + // in AttackExecution is the layer under test, regardless of RNG outcome. + nationBehavior.sendAttack(human, true); // Execute a few ticks to process the attacks for (let i = 0; i < 5; i++) { diff --git a/tests/PlayerUpdateDiff.test.ts b/tests/PlayerUpdateDiff.test.ts new file mode 100644 index 000000000..049ddae5b --- /dev/null +++ b/tests/PlayerUpdateDiff.test.ts @@ -0,0 +1,191 @@ +import { AttackExecution } from "../src/core/execution/AttackExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { GameUpdateType, PlayerUpdate } from "../src/core/game/GameUpdates"; +import { GameID } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +let game: Game; +const gameID: GameID = "game_id"; +let alice: Player; +let bob: Player; + +describe("Player update diffing (toUpdate)", () => { + beforeEach(async () => { + game = await setup("plains", { infiniteTroops: true }); + const aliceInfo = new PlayerInfo( + "alice", + PlayerType.Human, + "alice_client", + "alice_id", + ); + const bobInfo = new PlayerInfo( + "bob", + PlayerType.Human, + "bob_client", + "bob_id", + ); + game.addPlayer(aliceInfo); + game.addPlayer(bobInfo); + game.addExecution( + new SpawnExecution(gameID, aliceInfo, game.ref(10, 10)), + new SpawnExecution(gameID, bobInfo, game.ref(16, 10)), + ); + game.executeNextTick(); + game.executeNextTick(); + alice = game.player("alice_id"); + bob = game.player("bob_id"); + }); + + test("first toUpdate returns a full snapshot with empty collections", () => { + // executeNextTick calls toUpdate() for every player, so use a freshly + // added player whose update has never been built. + const charlieInfo = new PlayerInfo( + "charlie", + PlayerType.Human, + "charlie_client", + "charlie_id", + ); + game.addPlayer(charlieInfo); + const charlie = game.player("charlie_id"); + + const full = charlie.toUpdate(); + expect(full).not.toBeNull(); + expect(full!.id).toBe("charlie_id"); + expect(full!.name).toBe("charlie"); + expect(full!.smallID).toBe(charlie.smallID()); + expect(full!.allies).toEqual([]); + expect(full!.targets).toEqual([]); + expect(full!.embargoes).toEqual(new Set()); + expect(full!.outgoingAttacks).toEqual([]); + expect(full!.incomingAttacks).toEqual([]); + expect(full!.outgoingAllianceRequests).toEqual([]); + expect(full!.alliances).toEqual([]); + expect(full!.outgoingEmojis).toEqual([]); + }); + + test("toUpdate returns null when nothing changed", () => { + alice.toUpdate(); // first full snapshot + expect(alice.toUpdate()).toBeNull(); + expect(alice.toUpdate()).toBeNull(); + }); + + test("primitive changes appear in the diff without unchanged collections", () => { + alice.toUpdate(); + alice.addGold(123n); + const diff = alice.toUpdate(); + expect(diff).not.toBeNull(); + expect(diff!.gold).toBe(alice.gold()); + // Unchanged collection fields must be absent from the diff. + expect(diff!.allies).toBeUndefined(); + expect(diff!.embargoes).toBeUndefined(); + expect(diff!.outgoingAttacks).toBeUndefined(); + expect(diff!.alliances).toBeUndefined(); + }); + + test("adding and removing an embargo shows up in consecutive diffs", () => { + alice.toUpdate(); + alice.addEmbargo(bob, false); + let diff = alice.toUpdate(); + expect(diff).not.toBeNull(); + expect(diff!.embargoes).toEqual(new Set(["bob_id"])); + + expect(alice.toUpdate()).toBeNull(); // stable until something changes + + alice.stopEmbargo(bob); + diff = alice.toUpdate(); + expect(diff).not.toBeNull(); + expect(diff!.embargoes).toEqual(new Set()); + }); + + test("an alliance shows up in allies and alliance views", () => { + alice.toUpdate(); + bob.toUpdate(); + const request = alice.createAllianceRequest(bob); + expect(request).not.toBeNull(); + request!.accept(); + + const aliceDiff = alice.toUpdate(); + expect(aliceDiff).not.toBeNull(); + expect(aliceDiff!.allies).toEqual([bob.smallID()]); + expect(aliceDiff!.alliances).toHaveLength(1); + expect(aliceDiff!.alliances![0].other).toBe("bob_id"); + + const bobDiff = bob.toUpdate(); + expect(bobDiff).not.toBeNull(); + expect(bobDiff!.allies).toEqual([alice.smallID()]); + }); + + test("targeting a player appears in the diff", () => { + alice.toUpdate(); + alice.target(bob); + const diff = alice.toUpdate(); + expect(diff).not.toBeNull(); + expect(diff!.targets).toEqual([bob.smallID()]); + }); + + test("attacks appear for attacker and defender through the tick pipeline", () => { + // Expand alice into terra nullius until she borders bob — a land attack + // on a non-adjacent player retreats immediately. + game.addExecution( + new AttackExecution(2000, alice, game.terraNullius().id()), + ); + for (let i = 0; i < 30 && !alice.sharesBorderWith(bob); i++) { + game.executeNextTick(); + } + expect(alice.sharesBorderWith(bob)).toBe(true); + + game.addExecution(new AttackExecution(5000, alice, bob.id())); + // executeNextTick integrates toUpdate(), so read the emitted updates. + const updates = game.executeNextTick(); // attack initializes + const playerUpdates = updates[GameUpdateType.Player] as PlayerUpdate[]; + + const attackerUpdate = playerUpdates.find((u) => u.id === "alice_id"); + expect(attackerUpdate).toBeDefined(); + // The terra nullius expansion attack may still be running; assert on the + // attack against bob specifically. + const bobAttack = attackerUpdate!.outgoingAttacks!.find( + (a) => a.targetID === bob.smallID(), + ); + expect(bobAttack).toBeDefined(); + + const defenderUpdate = playerUpdates.find((u) => u.id === "bob_id"); + expect(defenderUpdate).toBeDefined(); + expect(defenderUpdate!.incomingAttacks).toHaveLength(1); + expect(defenderUpdate!.incomingAttacks![0].attackerID).toBe( + alice.smallID(), + ); + + // As the attack progresses, troop counts change and must keep flowing + // through subsequent diffs. + const nextUpdates = game.executeNextTick(); + const nextPlayerUpdates = nextUpdates[ + GameUpdateType.Player + ] as PlayerUpdate[]; + const next = nextPlayerUpdates.find((u) => u.id === "alice_id"); + expect(next).toBeDefined(); + expect( + next!.outgoingAttacks!.some((a) => a.targetID === bob.smallID()), + ).toBe(true); + }); + + test("in-worker mutation of shared empty collections fails loudly", () => { + const charlieInfo = new PlayerInfo( + "charlie2", + PlayerType.Human, + "charlie2_client", + "charlie2_id", + ); + game.addPlayer(charlieInfo); + const full = game.player("charlie2_id").toUpdate()!; + // Empty collections are shared frozen singletons; a sloppy in-worker + // consumer must throw instead of silently corrupting every player's + // updates. (Updates crossing to the main thread are structured-cloned, + // so real consumers get mutable copies.) + expect(() => full.allies!.push(999)).toThrow(); + expect(() => full.outgoingAttacks!.pop()).toThrow(); + + // And other players see no spurious changes. + expect(bob.toUpdate()).toBeNull(); + }); +}); diff --git a/tests/PseudoRandom.test.ts b/tests/PseudoRandom.test.ts new file mode 100644 index 000000000..f0545a68d --- /dev/null +++ b/tests/PseudoRandom.test.ts @@ -0,0 +1,155 @@ +import { PseudoRandom } from "../src/core/PseudoRandom"; + +describe("PseudoRandom", () => { + test("same seed produces an identical sequence", () => { + const a = new PseudoRandom(42); + const b = new PseudoRandom(42); + for (let i = 0; i < 1000; i++) { + expect(a.next()).toBe(b.next()); + } + }); + + test("same seed produces identical derived values", () => { + const a = new PseudoRandom(987654); + const b = new PseudoRandom(987654); + for (let i = 0; i < 100; i++) { + expect(a.nextInt(0, 1000)).toBe(b.nextInt(0, 1000)); + } + expect(a.nextID()).toBe(b.nextID()); + const arr = [1, 2, 3, 4, 5, 6, 7, 8]; + expect(a.shuffleArray(arr)).toEqual(b.shuffleArray(arr)); + }); + + test("different seeds produce different sequences", () => { + const a = new PseudoRandom(1); + const b = new PseudoRandom(2); + let same = 0; + for (let i = 0; i < 100; i++) { + if (a.next() === b.next()) same++; + } + expect(same).toBeLessThan(5); + }); + + test("consecutive integer seeds are not correlated", () => { + // Weak seeding schemes make adjacent seeds (common: tick numbers, + // sequential hashes) produce similar streams. + const values: number[] = []; + for (let seed = 1000; seed < 1100; seed++) { + values.push(new PseudoRandom(seed).nextInt(0, 100)); + } + const distinct = new Set(values).size; + expect(distinct).toBeGreaterThan(50); + }); + + test("next() stays within [0, 1)", () => { + const r = new PseudoRandom(7); + for (let i = 0; i < 10000; i++) { + const v = r.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + test("next() is roughly uniform", () => { + const r = new PseudoRandom(1234); + const n = 20000; + let sum = 0; + const buckets = new Array(10).fill(0); + for (let i = 0; i < n; i++) { + const v = r.next(); + sum += v; + buckets[Math.floor(v * 10)]++; + } + expect(sum / n).toBeGreaterThan(0.48); + expect(sum / n).toBeLessThan(0.52); + for (const count of buckets) { + // Expected 2000 per bucket; allow generous slack. + expect(count).toBeGreaterThan(1700); + expect(count).toBeLessThan(2300); + } + }); + + test("nextInt returns integers in [min, max)", () => { + const r = new PseudoRandom(99); + const seen = new Set(); + for (let i = 0; i < 10000; i++) { + const v = r.nextInt(3, 8); + expect(Number.isInteger(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(3); + expect(v).toBeLessThan(8); + seen.add(v); + } + expect([...seen].sort()).toEqual([3, 4, 5, 6, 7]); + }); + + test("nextInt with a single-value range always returns it", () => { + const r = new PseudoRandom(5); + for (let i = 0; i < 100; i++) { + expect(r.nextInt(4, 5)).toBe(4); + } + }); + + test("nextInt floors non-integer bounds", () => { + const r = new PseudoRandom(5); + for (let i = 0; i < 100; i++) { + const v = r.nextInt(1.9, 4.7); + expect(v).toBeGreaterThanOrEqual(1); + expect(v).toBeLessThan(4); + } + }); + + test("nextFloat stays within [min, max)", () => { + const r = new PseudoRandom(11); + for (let i = 0; i < 1000; i++) { + const v = r.nextFloat(2.5, 3.5); + expect(v).toBeGreaterThanOrEqual(2.5); + expect(v).toBeLessThan(3.5); + } + }); + + test("nextID returns 8 alphanumeric characters", () => { + const r = new PseudoRandom(123); + for (let i = 0; i < 100; i++) { + expect(r.nextID()).toMatch(/^[0-9a-z]{8}$/); + } + }); + + test("randElement picks members and throws on empty", () => { + const r = new PseudoRandom(77); + const arr = ["a", "b", "c"]; + for (let i = 0; i < 100; i++) { + expect(arr).toContain(r.randElement(arr)); + } + expect(() => r.randElement([])).toThrow(); + }); + + test("randFromSet picks members and throws on empty", () => { + const r = new PseudoRandom(78); + const set = new Set(["x", "y", "z"]); + for (let i = 0; i < 100; i++) { + expect(set.has(r.randFromSet(set))).toBe(true); + } + expect(() => r.randFromSet(new Set())).toThrow(); + }); + + test("chance(1) is always true, chance(large) is mostly false", () => { + const r = new PseudoRandom(31); + for (let i = 0; i < 100; i++) { + expect(r.chance(1)).toBe(true); + } + let hits = 0; + for (let i = 0; i < 1000; i++) { + if (r.chance(1000)) hits++; + } + expect(hits).toBeLessThan(10); + }); + + test("shuffleArray returns a permutation and leaves the input unchanged", () => { + const r = new PseudoRandom(55); + const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const copy = [...input]; + const shuffled = r.shuffleArray(input); + expect(input).toEqual(copy); + expect([...shuffled].sort((a, b) => a - b)).toEqual(copy); + }); +});