Compose native Ix and CHOAM Skills with independent Treachery Cards

This commit is contained in:
Quintine committed 2026-10-17 19:29:37 +11:00
1 parent 7182502b16
commit 2ad947af54
17 files changed
+1376 -22

No files matched your search

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { forceRevivalLimit, forceRevivalQuote } from '../game/revival';
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import {
assertChoamIndependentSkillsCustody, choamIndependentSkillsPlayer,
createChoamIndependentSkillsFixture,
finishChoamIndependentSkillsBattle, openChoamIndependentSkillsBattle,
sealChoamIndependentSkillsBoth, type ChoamIndependentSkillsFixture,
} from './fixture-choam-independent-skills';
const owner = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
choamIndependentSkillsPlayer(game, fixture.richese);
const rival = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
choamIndependentSkillsPlayer(game, fixture.opponent);
/** The quote side belongs to whichever combatant the order-based label names. */
const side = (game: Game, actor: string) =>
game.battle!.attacker === actor ? 'attacker' as const : 'defender' as const;
const bonusOf = (game: Game, quote: BattleResolutionQuote, actor: string) =>
quote.leaderSkillBonuses[side(game, actor)];
const scoreOf = (game: Game, quote: BattleResolutionQuote, actor: string) => {
assert.ok(quote.scores, 'A resolved battle carries totals');
return quote.scores[side(game, actor)];
};
const held = (game: Game, id: string) =>
[...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)]
.filter((card) => card.id === id);
for (const advanced of [false, true] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM/Richese independent all14 funds Reinforcements after the concealed marker materializes and returns the real undialed army`, () => {
const fixture = createChoamIndependentSkillsFixture(advanced ? { advanced: true } : {});
// The independent three-card marker rides the selected E2 ordinary deck, and
// Richese keeps its own separate ten-card cache alongside all fourteen skills.
assert.equal(fixture.offered.richeseCache!.length, 10);
// Live plan window: the preview already forecasts the capped materialization
// and the three reserves the fee must find afterwards.
const open = openChoamIndependentSkillsBattle(fixture);
assert.deepEqual(open.battle!.plans, {});
assert.equal(owner(fixture, open).reserves, advanced ? 20 : 6);
const preview = viewGame(open, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.noField!.materializes, 3);
assert.equal(preview.noField!.reservesAfterReveal, advanced ? 17 : 3);
assert.equal(preview.normal, 3);
const withdraw = viewGame(open, fixture.richese).battle!.harassWithdraw!;
assert.equal(withdraw.blocked, null);
assert.equal(withdraw.forces.normal, 3);
assert.equal(withdraw.forces.elite, 0);
assert.deepEqual(withdraw.locations, { [fixture.location]: { normal: 3, elite: 0 } });
// The reveal materializes the capped physical counters; the fee follows them.
const revealed = sealChoamIndependentSkillsBoth(fixture, 'both', open);
assert.equal(revealed.battle!.revealed, true);
const shown = owner(fixture, revealed);
assert.equal(shown.forces[fixture.location], 3);
assert.equal(shown.reserves, advanced ? 17 : 3);
assert.equal(shown.tanks, 0);
assert.equal(shown.noField!.deployed, null);
assert.equal(shown.noField!.lastShipped, fixture.token.id);
assert.equal(new Set(shown.noField!.tokens.map((token) => token.id)).size, 3);
// The revealed totals: dial + Reinforcements + trained Mentat + physical leader.
const quote = quoteIndependentSkillsBattle(revealed, { returns: fixture.returns });
assert.deepEqual(quote.reinforcements,
[{ player: fixture.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw?.map((entry) => ({ returned: entry.returned, locations: entry.locations })),
[{ returned: { normal: advanced ? 2 : 1, elite: 0 },
locations: { [fixture.location]: { normal: advanced ? 2 : 1, elite: 0 } } }]);
assert.deepEqual(bonusOf(revealed, quote, fixture.richese),
{ bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
assert.deepEqual(bonusOf(revealed, quote, fixture.opponent), { bonus: 0, applied: [] });
assert.equal(scoreOf(revealed, quote, fixture.richese) - scoreOf(revealed, quote, fixture.opponent), 1);
// The whole undialed army lies in the marker's own sector, so the native
// return is unambiguous and needs no typed allocation.
const spice = owner(fixture, revealed).spice;
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
assert.equal(done.lastBattleContext?.winner, fixture.richese);
assert.equal(done.lastBattleContext?.result, 'normal');
const after = owner(fixture, done);
assert.equal(after.reserves, advanced ? 16 : 1,
'Revealed reserves, minus the three-reserve fee, plus the returned undialed counter');
assert.equal(after.tanks, advanced ? 4 : 5);
assert.deepEqual(after.forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
assert.equal(after.spice, spice - Number(fixture.plans.both.action.support ?? 0));
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
assert.equal(held(done, card).length, 1, card);
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
assert.ok(done.players.every((player) => !player.hand.some((face) => face.id === card)), card);
}
assertChoamIndependentSkillsCustody(fixture, done);
// The same physical dial without Reinforcements loses: its +2 score,
// rather than another force or support payment, supplies the winning margin.
const plain = finishChoamIndependentSkillsBattle(
sealChoamIndependentSkillsBoth(fixture, 'plain', open), { return: fixture.returns });
assert.equal(plain.lastBattleContext?.winner, fixture.opponent);
assert.equal(owner(fixture, plain).reserves, advanced ? 17 : 3);
assert.equal(owner(fixture, plain).tanks, 3);
assert.deepEqual(owner(fixture, plain).forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
assertChoamIndependentSkillsCustody(fixture, plain);
});
void test('A reserve-capped marker five at three reserves refuses the funded plan before the reveal and still plays the real Harass fallback', () => {
const fixture = createChoamIndependentSkillsFixture({ marker: 5, reserves: 3, stopBeforePlans: true });
const preview = viewGame(fixture.beforePlans, fixture.richese).battle!.reinforcements!;
assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves');
assert.equal(preview.noField!.reservesAfterReveal, 0);
assert.equal(preview.normal + preview.elite, 0);
const saved = structuredClone(fixture.beforePlans);
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
assert.throws(() => applyAction(fixture.beforePlans, fixture.richese, plan.action));
assert.deepEqual(fixture.beforePlans, saved, 'A refused plan reserves no counter, card or window');
}
const revealed = sealChoamIndependentSkillsBoth(fixture, 'harass');
assert.equal(revealed.battle!.revealed, true);
const shown = owner(fixture, revealed);
assert.equal(shown.forces[fixture.location], 3, 'The capped marker still materializes its three counters');
assert.equal(shown.reserves, 0);
assert.equal(shown.tanks, 0);
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
const after = owner(fixture, done);
assert.equal(after.reserves, 1, 'Only the undialed counter returned; no fee was charged');
assert.equal(after.tanks, 2, 'The two dialed counters reached the Tanks');
assert.deepEqual(after.forces, { 'broken_land:12': 5, 'carthag:11': 12 });
assertChoamIndependentSkillsCustody(fixture, done);
});
void test('The independent CHOAM/Richese setup keeps the separate cache and each seat printed revival exception', () => {
const fixture = createChoamIndependentSkillsFixture({});
assert.equal(fixture.offered.richeseCache!.length, 10);
assert.ok(fixture.offered.richeseCache!.every((card) => card.id.startsWith('richese-')),
'The cache holds only Richese faces, never ordinary deck cards');
for (const card of fixture.offered.richeseCache!)
assert.ok(!fixture.offered.deck.some((face) => face.id === card.id),
`${card.id} stays out of the ordinary deck`);
const choam = choamIndependentSkillsPlayer(fixture.afterSetup, fixture.choam);
assert.equal(forceRevivalLimit(fixture.afterSetup, choam), 20);
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, choam, 5),
{ free: 0, normalCost: 10, cost: 5 },
'CHOAM pays one spice per returned force with no free revival and no normal limit');
for (const actor of [fixture.richese, fixture.classic]) {
const seat = choamIndependentSkillsPlayer(fixture.afterSetup, actor);
assert.equal(forceRevivalLimit(fixture.afterSetup, seat), 3);
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, seat, 5),
{ free: 2, normalCost: 6, cost: 6 });
}
const done = finishChoamIndependentSkillsBattle(
sealChoamIndependentSkillsBoth(fixture, 'both'), { return: fixture.returns });
assertChoamIndependentSkillsCustody(fixture, done);
// Every seat keeps its own counters and the printed hand limit end to end.
for (const player of done.players) {
const pool = player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, count) => sum + count, 0);
assert.equal(pool, 20, player.faction);
}
assert.equal(rival(fixture, done).tanks, rival(fixture, fixture.beforePlans).tanks +
(rival(fixture, fixture.beforePlans).forces[fixture.location] ?? 0),
'The defeated garrison, not armies elsewhere, reached the Tanks');
});
+631
View File
@@ -0,0 +1,631 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import type { Card } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { NoFieldValue } from '../game/richese-no-field';
import { nextRicheseReinforcementsStep } from './fixture-richese-reinforcements';
export type ChoamIndependentSkillsStep = { actor: string; action: Action };
/**
* 'immediate' ships the final concealed marker straight from twenty reserves on
* turn one (the only deterministic Advanced timeline: later turns depend on the
* original storm card). 'tuning' is the Basic route whose two real reveals plus
* one real paid tuning shipment leave an exact reserve budget off the one
* deterministic opening Spice Blow.
*/
export type ChoamIndependentSkillsRoute = 'tuning' | 'immediate';
export type ChoamIndependentSkillsPolicy = {
/** Real posture decision per seat: hide the trained leader behind the shield. */
hide?: Record<string, boolean>;
stone?: 'kill' | 'ignore';
guildTiming?: 'first' | 'last';
};
export type ChoamIndependentSkillsOptions = {
/** Continue only the original fresh undealt lobby or its own post-start setup. */
initial?: Game;
advanced?: boolean;
route?: ChoamIndependentSkillsRoute;
marker?: NoFieldValue;
/** Own physical reserves the Basic 'tuning' route must leave before shipping. */
reserves?: number;
/** Stop at the live pre-plan battle window so the caller seals the plans. */
stopBeforePlans?: boolean;
seatIds?: [string, string, string];
};
export type ChoamIndependentSkillsSetup = {
/** The original fresh undealt lobby before any staged module selection. */
initial: Game;
/** Immediately after the host's real module-selected start: skill offers dealt. */
offered: Game;
afterSetup: Game;
richese: string;
choam: string;
classic: string;
/** Requested and actually assigned skill per seat id. */
skills: Record<string, LeaderSkillId>;
/** The leader disc each seat attached its skill to. */
trainers: Record<string, string>;
actions: ChoamIndependentSkillsStep[];
staging: string[];
};
export type ChoamIndependentSkillsFixture = ChoamIndependentSkillsSetup & {
advanced: boolean;
route: ChoamIndependentSkillsRoute;
/** Real battle stronghold the concealed marker was shipped into. */
location: string;
opponent: string;
beforeShipment: Game;
shipmentStep: ChoamIndependentSkillsStep;
afterShipment: Game;
beforeBattle: Game;
battleStep: ChoamIndependentSkillsStep;
/** Live plan window: battle chosen, Mentat question and postures resolved. */
beforePlans: Game;
/** Revealed battle: both original plans sealed, postures committed. With
* stopBeforePlans this is instead the live pre-plan window awaiting a seal. */
game: Game;
/** Exact undialed complement of the commitment, for the caller's preview quote. */
returns: HarassWithdrawSelection;
token: { id: string; value: NoFieldValue };
/** Capped physical counters the concealed marker materializes on reveal. */
materializes: number;
tuning: number;
reservesBefore: Record<string, number>;
plans: {
both: ChoamIndependentSkillsStep;
reinforcements: ChoamIndependentSkillsStep;
harass: ChoamIndependentSkillsStep;
plain: ChoamIndependentSkillsStep;
opponent: ChoamIndependentSkillsStep;
};
hide: Record<string, boolean>;
/** Every real native action the programme took, in order. */
nativeSteps: ChoamIndependentSkillsStep[];
};
/** The one deterministic opening Spice Blow of the original initializer. */
export const CHOAM_INDEPENDENT_SKILLS_BLOW = { territory: 'broken_land', sector: 12 } as const;
/** Empty strongholds: cheapest to ship into and free of any starting army. */
export const CHOAM_INDEPENDENT_SKILLS_CITIES = [
{ territory: 'carthag', sector: 11 },
{ territory: 'sietch_tabr', sector: 14 },
{ territory: 'habbanya_ridge_sietch', sector: 17 },
] as const;
/** The ordinary deck of the selected E2 expansion plus the independent three. */
export const CHOAM_INDEPENDENT_SKILLS_EXPANSIONS = ['choam'] as const;
export function choamIndependentSkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find((seat) => seat.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
}
export function choamIndependentSkillsSeat(game: Game, faction: string): Player {
const player = game.players.find((seat) => seat.faction === faction);
assert.ok(player, `Missing native ${faction} seat`);
return player;
}
export const choamIndependentSkillsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Conserved original cards, pools and the printed hand limit for every seat.
* The dealt card multiset is fixed from the original start onward; each seat's
* counter total is fixed from the completed setup onward. */
export function assertChoamIndependentSkillsCustody(
fixture: { offered: Game; afterSetup: Game },
game: Game,
): void {
assert.ok(game.leaderSkills);
validateLeaderSkills(game.leaderSkills, game.players);
const cards = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []),
...(state.richeseRemoved ?? []), ...state.players.flatMap((player) => player.hand)]
.map((card) => card.id).sort();
assert.deepEqual(cards(game), cards(fixture.offered),
'Every original deck, cache, discard and hand face keeps physical custody.');
const pools = (state: Game) => state.players.map((player) => [player.id,
player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
player.elites
? player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
: 0]);
assert.deepEqual(pools(game), pools(fixture.afterSetup),
'Every physical counter stays inside its own native pool.');
for (const player of game.players)
assert.ok(player.hand.length <= nativeHandLimit(player),
`${player.faction} stays inside its printed hand limit.`);
}
/** Private two-card offers are dealt in seat order, so the first positions of
* the solved all-fourteen deck decide the requesting seat's real choice. */
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const working = [...source], draws: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296));
[working[index], working[from]] = [working[from], working[index]];
}
assert.deepEqual(working, desired);
return draws;
}
/** Solve only the original fourteen-card skill lottery. Every later native
* shuffle keeps the same fixed maximum fill the tuned opening timeline uses. */
function skillDraws<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < draws.length ? draws[cursor++] : 0xffffffff;
else if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Move one conserved physical face from wherever it still lives. No face is
* ever minted, purchased or duplicated. */
function positionCard(game: Game, actor: string, cardId: string, staging: string[]): void {
const player = choamIndependentSkillsPlayer(game, actor);
if (player.hand.some((card) => card.id === cardId)) return;
const zones: { label: string; zone: Card[] }[] = [
{ label: 'remaining deck', zone: game.deck },
{ label: 'Richese cache', zone: game.richeseCache ?? [] },
{ label: 'discard pile', zone: game.discard },
];
for (const { label, zone } of zones) {
const index = zone.findIndex((card) => card.id === cardId);
if (index < 0) continue;
assert.ok(player.hand.length < nativeHandLimit(player),
`${actor}'s printed hand limit leaves room for ${cardId}`);
player.hand.push(zone.splice(index, 1)[0]);
staging.push(`Conserved ${cardId}: ${label} → ${actor}'s hand (no purchase, payment or replacement)`);
return;
}
assert.fail(`Conserved physical ${cardId} is missing for ${actor}`);
}
function availableLeaders(game: Game, actor: string, location: string) {
return choamIndependentSkillsPlayer(game, actor).leaders
.filter((leader) => !leader.dead && (!leader.usedAt || leader.usedAt === location))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
}
/** Requested skill per native seat: only Richese's Mentat changes battle
* strength. The other two need a projectile weapon or snooper defense that the
* special slots hold, so they stay inert and need no extra card custody. */
function requestedSkills(richese: string, choam: string, classic: string): Record<string, LeaderSkillId> {
return {
[richese]: 'mentat',
[choam]: 'swordmaster-of-ginaz',
[classic]: 'killer-medic',
};
}
function skillDeckOrder(skills: Record<string, LeaderSkillId>, seats: readonly string[]): LeaderSkillId[] {
const requested = seats.map((seat) => skills[seat]);
const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id));
return requested.flatMap((id) => [id, rest.shift()!]).concat(rest);
}
/** Genuine fresh CHOAM/Richese plus one classic seat: the original ordinary E2
* deck with Richese's separate ten-card cache, all fourteen Leader Skills and
* the independent three-card Treachery marker. The initial skill lottery and
* opening native shuffles are controlled; later actions keep real entropy. */
export function createChoamIndependentSkillsSetup(
options: ChoamIndependentSkillsOptions = {},
): ChoamIndependentSkillsSetup {
const advanced = options.initial ? options.initial.advanced : options.advanced ?? false;
const ids = options.seatIds ?? ['richese', 'choam', 'atreides'];
const factions = ['richese', 'choam', 'atreides'] as const;
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' ||
(game.status === 'setup' && !!game.leaderSkills && game.turn === 1 && game.phase === 0),
'Continue only the original fresh undealt independent-card setup.');
assert.equal(game.advanced, advanced);
assert.deepEqual([...game.expansions].sort(), [...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]);
assert.ok(game.players.some((player) => player.faction === 'richese'));
} else {
game = createGame('CHOAMINDEPENDENTSKILLS', newPlayer(ids[0], 'Richese', factions[0]), advanced,
[...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]);
joinGame(game, newPlayer(ids[1], 'CHOAM', factions[1]));
joinGame(game, newPlayer(ids[2], 'Atreides', factions[2]));
}
const richese = choamIndependentSkillsSeat(game, 'richese').id;
const choam = choamIndependentSkillsSeat(game, 'choam').id;
const classic = choamIndependentSkillsSeat(game, 'atreides').id;
const skills = requestedSkills(richese, choam, classic);
const staging: string[] = [];
const actions: ChoamIndependentSkillsStep[] = [];
const initial = structuredClone(game);
if (game.status === 'lobby') {
assert.ok(!game.lobbyOptions && !game.deck.length && !game.richeseCache &&
game.players.every((player) => !player.hand.length),
'Only the original fresh undealt lobby accepts the two module selections.');
for (const option of ['leaderSkills', 'ecazTreachery'] as const) {
const action: Action = { type: 'moduleOption', module: option, enabled: true };
actions.push({ actor: game.host, action });
game = applyAction(game, game.host, action);
}
staging.push('The host selected the Leader Skills and independent Ecaz Treachery module options in the original lobby before any seat was ready.');
for (const player of game.players) if (!player.ready) {
const action: Action = { type: 'ready' };
actions.push({ actor: player.id, action });
game = applyAction(game, player.id, action);
}
const order = skillDeckOrder(skills, [richese, choam, classic]);
assert.equal(order.length, LEADER_SKILL_CARDS.length);
const action: Action = advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
actions.push({ actor: game.host, action });
game = skillDraws(permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), order),
() => applyAction(game, game.host, action));
staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws so each seat is offered its requested skill; the same start kept the tuned fixed-maximum fill for every later shuffle.');
}
assert.equal(game.status, 'setup');
assert.equal(game.ecazTreachery, true);
assert.equal(game.turn, 1);
assert.equal(game.phase, 0);
assert.ok(game.leaderSkills);
assert.equal(game.richeseCache?.length, 10, 'The separate ten-card Richese cache is dealt.');
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 400; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const wanted = skills[actor];
assert.ok(offer.cards.includes(wanted) && !view.unavailableSkills?.[wanted],
`The requested original ${wanted} skill must be offered to ${actor}`);
const leader = choamIndependentSkillsPlayer(game, actor).leaders
.filter((disc) => view.eligibleLeaders.some((eligible) => eligible.id === disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
const action: Action = { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id };
actions.push({ actor, action });
game = applyAction(game, actor, action);
continue;
}
let next: ChoamIndependentSkillsStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`);
actions.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
// The undealt start is the reference for both the dealt card multiset and the
// untouched physical counter pools the setup redistributes.
assertChoamIndependentSkillsCustody({ offered, afterSetup: offered }, game);
const assigned = (seat: string) => game.leaderSkills!.assignments
.find((assignment) => assignment.owner === seat)!;
for (const seat of [richese, choam, classic])
assert.equal(assigned(seat).skill, skills[seat]);
return {
initial, offered, afterSetup: structuredClone(game), richese, choam, classic, skills,
trainers: { [richese]: assigned(richese).leader, [choam]: assigned(choam).leader,
[classic]: assigned(classic).leader },
actions, staging,
};
}
/** Only genuine native windows; a null result with a live battle means a human
* must seal a plan, and a typed withdrawal allocation is the caller's choice. */
export function nextChoamIndependentSkillsStep(
game: Game,
policy: ChoamIndependentSkillsPolicy = {},
): ChoamIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: policy.hide?.[decision.player] ?? false } };
if (decision.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision',
take: (policy.guildTiming ?? 'last') === 'first' } };
if (decision.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
// The typed withdrawal allocation and the Suk rescue choice stay the
// caller's; a winner's legal casualty pick continues through the shared
// native continuation policy.
if (decision.kind === 'harassWithdraw' || decision.kind === 'sukRescue') return null;
}
return nextRicheseReinforcementsStep(game, policy.stone ?? 'ignore');
}
/** Real native hidden-marker shipments, real ordinary movement and both
* original plans. Only conserved card faces are staged, and the trained leader
* is the physical disc the programme commits so its skill can resolve. */
export function createChoamIndependentSkillsFixture(
options: ChoamIndependentSkillsOptions = {},
): ChoamIndependentSkillsFixture {
const setup = createChoamIndependentSkillsSetup(options);
const advanced = setup.afterSetup.advanced;
const route: ChoamIndependentSkillsRoute = options.route ?? (advanced ? 'immediate' : 'tuning');
if (route === 'tuning')
assert.equal(advanced, false,
'The Advanced storm card makes every turn after the first nondeterministic; use the immediate route');
assert.ok(route !== 'immediate' || options.reserves === undefined || options.reserves === 20,
'The immediate native route starts with twenty real reserves; tuning is a separate timeline');
const staging = [...setup.staging];
let game = setup.afterSetup;
const { richese } = setup;
const marker: NoFieldValue = options.marker ?? 3;
const reuseValue: NoFieldValue = marker === 3 ? 0 : 3;
const baseline = marker === 3 ? 15 : 12;
const target = options.reserves ?? (marker === 3 ? 6 : 8);
const tuning = route === 'tuning' ? baseline - target : 0;
assert.ok(route === 'immediate' || (target >= 0 && target <= baseline),
'The tuning route can only leave these reserve budgets');
assert.ok(tuning <= 10, 'The tuning shipment must stay inside the spice the real route collected');
const nativeSteps: ChoamIndependentSkillsStep[] = [];
const act = (step: ChoamIndependentSkillsStep) => {
nativeSteps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
};
const rich = () => choamIndependentSkillsPlayer(game, richese);
const tokenOf = (value: NoFieldValue) => rich().noField!.tokens.find((token) => token.value === value)!;
const stormFreeCity = () => {
const city = CHOAM_INDEPENDENT_SKILLS_CITIES.find((candidate) => candidate.sector !== game.storm);
assert.ok(city, 'The natural route keeps one empty stronghold outside the storm');
return city;
};
/** One untouched ordinary army holding a real stronghold outside the storm. */
const battleTarget = () => {
for (const player of game.players) {
if (player.id === richese) continue;
for (const [key, count] of Object.entries(player.forces)) {
if (count <= 0) continue;
const [stronghold, sector] = key.split(':');
if (!territory(stronghold) || territory(stronghold).type !== 'stronghold') continue;
if (stronghold === MOBILE_STRONGHOLD || Number(sector) === game.storm) continue;
return { seat: player.id, faction: player.faction, territory: stronghold, sector: Number(sector) };
}
}
assert.fail('The timeline needs one untouched starting army outside the storm');
};
const done = { shipment: {} as Record<number, boolean>, reveal: {} as Record<number, boolean> };
let open = false;
let beforeShipment: Game | undefined;
let afterShipment: Game | undefined;
let shipmentStep: ChoamIndependentSkillsStep | undefined;
let beforeBattle: Game | undefined;
let battleStep: ChoamIndependentSkillsStep | undefined;
let reservesBefore: Record<string, number> | undefined;
let army: { seat: string; faction: string; territory: string; sector: number } | undefined;
/** Both combatants hide their trained leader behind the shield. */
const hide: Record<string, boolean> = { [richese]: true };
const clear = () => choamIndependentSkillsClean(game);
// Native actions only: no counter is placed by hand and no wallet is set.
for (let count = 0; count < 2400 && !open; count++) {
assert.equal(game.status, 'playing');
if (done.shipment[4] && !afterShipment && clear()) afterShipment = structuredClone(game);
const choices = game.phase === 6 && clear()
? viewGame(game, richese).battleChoices ?? [] : [];
if (!open && choices.some((choice) => choice.territory === army?.territory)) {
const choice = choices.find((candidate) => candidate.territory === army!.territory)!;
positionCard(game, richese, REINFORCEMENTS_CARD, staging);
positionCard(game, richese, HARASS_WITHDRAW_CARD, staging);
beforeBattle = structuredClone(game);
battleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: army!.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
open = true;
break;
}
if (game.phase === 2 && game.turn === 4 && clear() &&
rich().charityTurn !== game.turn && rich().spice < 2) {
act({ actor: richese, action: { type: 'charity' } });
continue;
}
if (game.phase === 5 && game.active === richese && clear()) {
const turn = game.turn;
if (route === 'tuning' && turn === 1 && !done.shipment[1]) {
act({ actor: richese, action: { type: 'ship', noField: tokenOf(5).id,
event: rich().noFieldEvent, territory: CHOAM_INDEPENDENT_SKILLS_BLOW.territory,
sector: CHOAM_INDEPENDENT_SKILLS_BLOW.sector } });
done.shipment[1] = true;
continue;
}
if (route === 'tuning' && turn === 2 && rich().noField!.deployed && !done.reveal[2]) {
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
token: rich().noField!.deployed!.tokenId } });
done.reveal[2] = true;
continue;
}
if (route === 'tuning' && turn === 2 && !done.shipment[2] && !rich().shipped) {
const city = stormFreeCity();
act({ actor: richese, action: { type: 'ship', noField: tokenOf(reuseValue).id,
event: rich().noFieldEvent, territory: city.territory, sector: city.sector } });
done.shipment[2] = true;
continue;
}
if (route === 'tuning' && turn === 3 && rich().noField!.deployed && !done.reveal[3]) {
assert.equal(rich().noField!.deployed!.tokenId, tokenOf(reuseValue).id,
'The reuse token must still be the concealed one this timeline reveals');
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
token: rich().noField!.deployed!.tokenId } });
done.reveal[3] = true;
continue;
}
if (route === 'tuning' && turn === 3 && !done.shipment[3] && !rich().shipped) {
const city = stormFreeCity();
act({ actor: richese, action: { type: 'ship', territory: city.territory,
sector: city.sector, amount: tuning } });
done.shipment[3] = true;
continue;
}
const finalTurn = route === 'tuning' ? 4 : 1;
if (turn === finalTurn && !done.shipment[4] && !rich().shipped) {
army = battleTarget();
hide[army.seat] = true;
beforeShipment = structuredClone(game);
reservesBefore = Object.fromEntries(game.players.map((player) => [player.id, player.reserves]));
shipmentStep = { actor: richese, action: { type: 'ship', noField: tokenOf(marker).id,
event: rich().noFieldEvent, territory: army.territory, sector: army.sector } };
act(shipmentStep);
done.shipment[4] = true;
continue;
}
assert.ok(turn <= (route === 'tuning' ? 4 : 1),
'The natural timeline must reach its battle by the scheduled turn');
}
const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' });
assert.ok(step, `Native Richese continuation stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
act(step);
}
assert.ok(open, 'The final concealed shipment must reach a real ordinary battle');
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep && army && reservesBefore,
'The timeline must record its final shipment, army and battle admission');
for (let count = 0; count < 120; count++) {
const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' });
if (!step) break;
act(step);
}
assert.ok(game.battle && !game.battle.revealed && clear());
assert.deepEqual(game.battle.plans, {});
const opponent = army.seat;
const location = `${army.territory}:${army.sector}`;
const enemyForces = choamIndependentSkillsPlayer(game, opponent).forces[location] ?? 0;
assert.ok(enemyForces > 0, 'The battle army must still hold the stronghold');
const reserves = reservesBefore[richese];
if (route === 'tuning')
assert.equal(reserves, target, 'The tuning route must leave the requested reserve budget');
const materializes = Math.min(tokenOf(marker).value, reserves);
const ownLeader = setup.trainers[richese];
assert.ok(availableLeaders(game, richese, army.territory).some((disc) => disc.id === ownLeader),
'The trained Mentat disc must still be an available battle leader');
const ownDial = advanced ? Math.min(1, Math.floor(materializes / 2)) : 2;
assert.ok(ownDial <= materializes, 'The plan may not dial counters the marker cannot materialize');
const undialed = materializes - ownDial;
assert.ok(undialed >= 1, 'Harass & Withdraw needs a real undialed counter to return');
// The announced dial is the battle number in both rules sets; the opposing
// army is tuned so the trained Mentat bonus is exactly decisive: the same
// physical plan loses by one without those two strength.
const defenderLeaders = availableLeaders(game, opponent, army.territory);
const ownStrength = choamIndependentSkillsPlayer(game, richese)
.leaders.find((disc) => disc.id === ownLeader)!.strength;
const attackerWith = ownDial + 2 + ownStrength + 2;
const boundary = defenderLeaders.flatMap((leader) => {
const dial = attackerWith - 1 - leader.strength;
return dial >= 0 && dial <= enemyForces ? [{ dial, leader: leader.id }] : [];
})[0];
assert.ok(boundary, 'The real opposing roster must hold the decisive skill boundary dial');
const otherLeader = boundary.leader;
const enemyDial = boundary.dial;
const plan = (weapon: string | null, defense: string | null): ChoamIndependentSkillsStep => ({
actor: richese,
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
leader: ownLeader, weapon, defense },
});
const plans = {
both: plan(REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD),
reinforcements: plan(REINFORCEMENTS_CARD, null),
harass: plan(null, HARASS_WITHDRAW_CARD),
plain: plan(null, null),
opponent: { actor: opponent, action: { type: 'battlePlan', dial: enemyDial,
support: advanced ? enemyDial : 0, leader: otherLeader } },
} satisfies ChoamIndependentSkillsFixture['plans'];
const returns: HarassWithdrawSelection = { [location]: { normal: undialed, elite: 0 } };
const beforePlans = game;
if (options.stopBeforePlans)
return {
...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment,
beforeBattle, battleStep, beforePlans, game: beforePlans, returns,
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore, plans, hide, nativeSteps,
};
const before = applyAction(applyAction(game, richese, plans.both.action), opponent, plans.opponent.action);
assert.ok(before.battle?.revealed, 'Both original plans must reveal the marker and the machine');
assert.ok(!before.decision && !before.response && !before.phaseOpening,
`The whole undialed army is in one sector, so the native return needs no typed allocation (saw ${before.decision?.kind}).`);
return {
...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment,
beforeBattle, battleStep, beforePlans, game: before, returns, token: structuredClone(tokenOf(marker)),
materializes, tuning, reservesBefore, plans, hide, nativeSteps,
};
}
/** From the recorded pre-battle snapshot, choose the battle and consume only its
* native windows, stopping at the live window where a human seals a plan. */
export function openChoamIndependentSkillsBattle(
fixture: ChoamIndependentSkillsFixture, policy: ChoamIndependentSkillsPolicy = {},
): Game {
let state = applyAction(fixture.beforeBattle, fixture.battleStep.actor, fixture.battleStep.action);
for (let count = 0; count < 120; count++) {
const step = nextChoamIndependentSkillsStep(state, { ...policy, hide: policy.hide ?? fixture.hide });
if (!step) break;
state = applyAction(state, step.actor, step.action);
}
assert.ok(state.battle && !state.battle.revealed, 'The battle must open at its live plan window');
return state;
}
/** Seal the machine plan and the opposing plan from one real pre-plan window. */
export function sealChoamIndependentSkillsBoth(
fixture: ChoamIndependentSkillsFixture,
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'both',
state: Game = fixture.beforePlans,
policy: ChoamIndependentSkillsPolicy = { hide: fixture.hide },
): Game {
const first = sealChoamIndependentSkillsPlan(state, fixture.plans[plan], policy);
return sealChoamIndependentSkillsPlan(first, fixture.plans.opponent, policy);
}
/** Seal one real plan and consume only the native windows it opens. */
export function sealChoamIndependentSkillsPlan(
game: Game, step: ChoamIndependentSkillsStep, policy: ChoamIndependentSkillsPolicy = {},
): Game {
let state = applyAction(game, step.actor, step.action);
for (let count = 0; count < 200; count++) {
if (!state.response && !state.phaseOpening && !state.decision) return state;
const next = nextChoamIndependentSkillsStep(state, policy);
assert.ok(next, `Sealed plan left an unconsumed native window at ${state.decision?.kind}`);
state = applyAction(state, next.actor, next.action);
}
throw Error('Sealed plan did not finish its native preparation');
}
/** Resolve the real aftermath: Traitor calls, an optional typed withdrawal
* allocation, the winner's legal casualty pick and mandatory disposal, stopping
* at the original clean Collection boundary. */
export function finishChoamIndependentSkillsBattle(
game: Game,
options: { callers?: readonly string[]; return?: HarassWithdrawSelection;
actions?: ChoamIndependentSkillsStep[]; until?: (state: Game) => boolean } = {},
): Game {
const until = options.until ?? ((state: Game) => state.phase === 7 && choamIndependentSkillsClean(state));
for (let count = 0; count < 400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
let step: ChoamIndependentSkillsStep | null;
if (game.decision?.kind === 'harassWithdraw') {
assert.ok(options.return, 'Supply the genuine typed undialed return allocation');
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event,
returns: options.return } };
} else {
const next = nextChoamIndependentSkillsStep(game);
step = next && next.action.type === 'traitorCall'
? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } }
: next;
}
assert.ok(step, 'Resolve both original plans before their physical aftermath');
options.actions?.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original independent-card battle aftermath did not reach Collection');
}
+274
View File
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { forceRevivalQuote } from '../game/revival';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
export type IxIndependentSkillsStep = { actor: string; action: Action };
export type IxIndependentSkillsOptions = { advanced?: boolean };
export const ixIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
};
export const ixIndependentSkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Scoped real shuffle entropy, restored even when a native action rejects. */
function entropy<T>(run: () => T, rolls: readonly number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff;
} else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function skillRolls(): number[] {
const source = LEADER_SKILL_CARDS.map(c => c.id);
const chosen: LeaderSkillId[] = ['suk-graduate', 'killer-medic', 'master-of-assassins'];
const rest = source.filter(c => !chosen.includes(c));
const desired = chosen.flatMap(c => [c, rest.shift()!]).concat(rest);
const work = [...source], rolls: number[] = [];
for (let i = work.length - 1; i > 0; i--) {
const from = work.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
[work[i], work[from]] = [work[from], work[i]];
}
return rolls;
}
export function stepIxIndependentSkills(game: Game, step: IxIndependentSkillsStep): Game {
return entropy(() => applyAction(game, step.actor, step.action));
}
/** Original private projection and real policy; never alter the human lobby seat. */
export function ixIndependentSkillsPolicyActions(game: Game, actor: string, difficulty: Difficulty): Action[] {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
return entropy(() => botActions(view));
}
/** Stop at allocations and rescue: these remain actual projected human controls. */
export function nextIxIndependentSkillsStep(game: Game): IxIndependentSkillsStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: d.player === 'ixians' } };
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'mentatQuestion') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'rihani') return { actor: d.player,
action: { type: 'decision', event: d.event, draw: false } };
if (d?.kind === 'sukRescue' || d?.kind === 'harassWithdraw') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIxIndependentSkills(state: Game, until: (game: Game) => boolean,
actions: IxIndependentSkillsStep[] = []): Game {
let game = structuredClone(state);
for (let i = 0; i < 1600; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextIxIndependentSkillsStep(game); assert.ok(step, 'Supply the original private plan, typed return or rescue');
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
}
throw Error('Native independent programme did not reach its requested boundary');
}
/** Only physical card custody is staged; no stock, forces, wallets or receipts. */
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
const p = ixIndependentSkillsPlayer(game, actor), held = p.hand.find(c => c.id === id);
if (held) return held;
const zones = [{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
...game.players.filter(other => other.id !== actor).map(other => ({ label: other.id, cards: other.hand }))];
const source = zones.find(zone => zone.cards.some(c => c.id === id)); assert.ok(source);
if (p.hand.length >= handLimit(p)) {
const returned = p.hand.shift()!; game.deck.push(returned);
staging.push(`Conserved ${returned.id}: ${actor} hand → original remaining deck for printed hand capacity`);
}
const card = source.cards.splice(source.cards.findIndex(c => c.id === id), 1)[0];
p.hand.push(card); staging.push(`Conserved ${id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
return card;
}
export function assertIxIndependentSkillsCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(),
[...treacheryDeck(['ix']), ...ecazTreacheryCards()].map(c => c.id).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
assert.ok([p.reserves, p.tanks, ...Object.values(p.forces)].every(n => Number.isSafeInteger(n) && n >= 0));
assert.ok(p.hand.length <= handLimit(p));
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), 7);
assert.ok([p.elites.reserves, p.elites.tanks, ...Object.values(p.elites.forces)]
.every(n => Number.isSafeInteger(n) && n >= 0));
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
if (game.status === 'playing') assert.deepEqual([
...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors),
...game.players.flatMap(p => (p.faceDancers ?? []).map(c => c.leader)),
].sort(), traitorDeck(game.players, true).sort());
}
export type IxIndependentSkillsFixture = {
initial: Game; beforeIxCard: Game; ixCard: IxIndependentSkillsStep; offered: Game; afterSetup: Game;
beforeShipment: Game; shipment: IxIndependentSkillsStep; afterShipment: Game;
move: IxIndependentSkillsStep; afterMove: Game; opponentMove: IxIndependentSkillsStep;
beforeBattle: Game; battleStep: IxIndependentSkillsStep;
/** Original before-plan window, not a synthetic revealed Battle. */
game: Game; plans: IxIndependentSkillsStep[]; returns: HarassWithdrawSelection;
ixians: string; tleilaxu: string; opponent: string; trainer: string;
first: string; second: string; actions: IxIndependentSkillsStep[]; staging: string[];
};
/** Normal fresh lobby start → original Ix card-before-training → native armies →
* real desert shipment and split movement. Only the two singleton cards move zones. */
export function createIxIndependentSkillsFixture(options: IxIndependentSkillsOptions = {}): IxIndependentSkillsFixture {
const advanced = options.advanced ?? false;
let game = createGame('IXINDEPENDENTSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const initial = structuredClone(game), actions: IxIndependentSkillsStep[] = [], staging: string[] = [];
const record = (step: IxIndependentSkillsStep): Game => {
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
return game;
};
for (const option of ['leaderSkills', 'ecazTreachery'] as const)
record({ actor: game.host, action: { type: 'moduleOption', module: option, enabled: true } });
for (const p of game.players) record({ actor: p.id, action: { type: 'ready' } });
const start: IxIndependentSkillsStep = { actor: game.host,
action: { type: 'start', ...(advanced ? { advancedPreview: true } : {}) } };
actions.push(start); game = entropy(() => applyAction(game, start.actor, start.action), skillRolls());
staging.push('Scoped/restored original all14 lottery and canonical native setup shuffle; no offer or deck replacement');
assert.equal(game.setupStage, 'skillTreachery'); assert.equal(game.decision?.kind, 'ixSetup');
const beforeIxCard = structuredClone(game);
const ixCard: IxIndependentSkillsStep = { actor: 'ixians', action: { type: 'decision', card: game.ixSetupCards![0].id } };
game = record(ixCard); const offered = structuredClone(game);
const chosen: Record<string, LeaderSkillId> = { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'master-of-assassins' };
for (let i = 0; game.status === 'setup' && i < 120; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
assert.ok(offer.cards.includes(chosen[actor]));
const view = viewGame(game, actor).leaderSkills!;
const leader = ixIndependentSkillsPlayer(game, actor).leaders
.filter(l => view.eligibleLeaders.some(e => e.id === l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); record({ actor, action: { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id } });
} else { const step = nextIxIndependentSkillsStep(game); assert.ok(step); record(step); }
}
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader;
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
const beforeShipment = structuredClone(game), first = 'pasty_mesa:5', second = 'pasty_mesa:6';
const shipment: IxIndependentSkillsStep = { actor: 'ixians', action: {
type: 'ship', territory: 'pasty_mesa', sector: 5, amount: 4, elite: 2, allyPayment: 0 } };
record(shipment);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g) && !g.pendingShipment, actions);
const afterShipment = structuredClone(game);
const move: IxIndependentSkillsStep = { actor: 'ixians', action: {
type: 'move', from: first, amount: 2, elite: 1, territory: 'pasty_mesa', sector: 6 } };
record(move);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
const afterMove = structuredClone(game); record({ actor: 'ixians', action: { type: 'endMovement' } });
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
const opponentMove: IxIndependentSkillsStep = { actor: 'guild', action: {
type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } };
record(opponentMove);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
record({ actor: 'guild', action: { type: 'endMovement' } });
game = advanceIxIndependentSkills(game, g => g.phase === 6 && ixIndependentSkillsClean(g), actions);
conserveCard(game, 'ixians', REINFORCEMENTS_CARD, staging);
conserveCard(game, 'ixians', HARASS_WITHDRAW_CARD, staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'pasty_mesa'); assert.ok(choice);
const battleStep: IxIndependentSkillsStep = { actor: choice.chooser,
action: { type: 'chooseBattle', territory: 'pasty_mesa', target: choice.defender } };
record(battleStep);
game = advanceIxIndependentSkills(game, g => !!g.battle && !g.battle.revealed && ixIndependentSkillsClean(g), actions);
const enemy = ixIndependentSkillsPlayer(game, 'guild').leaders
.filter(l => !l.dead && l.id !== game.leaderSkills!.assignments.find(a => a.owner === 'guild')!.leader)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
const plans: IxIndependentSkillsStep[] = [
{ actor: 'ixians', action: { type: 'battlePlan', dial: advanced ? 1.5 : 2.5, support: 0,
leader: trainer, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
];
const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 0, elite: 1 } };
assertIxIndependentSkillsCustody(game);
return { initial, beforeIxCard, ixCard, offered, afterSetup, beforeShipment, shipment, afterShipment,
move, afterMove, opponentMove, beforeBattle, battleStep, game, plans, returns,
ixians: 'ixians', tleilaxu: 'tleilaxu', opponent: 'guild', trainer, first, second, actions, staging };
}
export function revealIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = f.game): Game {
let game = structuredClone(state);
for (const step of f.plans) game = stepIxIndependentSkills(game, step);
assert.ok(game.battle?.revealed); return game;
}
export function quoteIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = revealIxIndependentSkillsBattle(f)) {
return quoteIndependentSkillsBattle(state, { returns: f.returns });
}
export function finishIxIndependentSkillsBattle(f: IxIndependentSkillsFixture,
options: { state?: Game; rescue?: 'save' | 'none'; stop?: 'rescue' | 'collection' } = {}): Game {
let game = structuredClone(options.state ?? revealIxIndependentSkillsBattle(f));
for (let i = 0; i < 400; i++) {
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (game.phase === 7 && ixIndependentSkillsClean(game)) return game;
let step: IxIndependentSkillsStep | null;
const d = game.decision;
if (d?.kind === 'harassWithdraw') step = { actor: d.player,
action: { type: 'decision', event: d.event, returns: f.returns } };
else if (d?.kind === 'sukRescue') {
const choice = options.rescue === 'none' ? d.options.findIndex(o => o.normal + o.elite === 0)
: d.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'elite');
assert.ok(choice >= 0, 'Choose the original Cyborg/Suboid rescue');
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
} else step = nextIxIndependentSkillsStep(game);
assert.ok(step); game = stepIxIndependentSkills(game, step);
}
throw Error('Native independent battle did not reach Collection');
}
/** Real combat Tanks → real escrow bribe and Mentat → turn-two Revival. */
export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSkillsOptions = {}) {
const f = createIxIndependentSkillsFixture(options);
const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' });
const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } };
let game = stepIxIndependentSkills(afterBattle, bribe);
game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival);
const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging);
const beforeRecruits = structuredClone(game);
const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } };
game = stepIxIndependentSkills(game, priorReturn);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
const beforePlay = structuredClone(game);
const play: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'card', card: RECRUITS_CARD_ID } };
game = stepIxIndependentSkills(game, play);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
const beforeRevival = structuredClone(game);
const revive: IxIndependentSkillsStep = { actor: f.ixians,
action: { type: 'revive', amount: 3, elite: 1, freeElite: 0 } };
const price = forceRevivalQuote(game, ixIndependentSkillsPlayer(game, f.ixians), 3, 1, 0);
game = stepIxIndependentSkills(game, revive);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
return { ...f, afterBattle, bribe, beforeRecruits, priorReturn, beforePlay, play,
beforeRevival, revive, price, afterRevival: game, staging };
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { smugglerShipmentGame } from './smuggler-shipment-fixture';
import type { Action } from '../game/engine';
/** Genuine Leader Skills setup, followed by a conserved post-Nexus ride position. */
export function sandmasterWormGame(advanced = false, intrusion = false) {
const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild');
export function sandmasterWormGame(advanced = false, intrusion = false, independent = false) {
const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild', [], independent);
const own = g.players[0];
own.name = 'Sandmaster rider';
own.forces = { 'wind_pass:14': 2, 'wind_pass:15': 2 };
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
advanceIxIndependentSkills, assertIxIndependentSkillsCustody, createIxIndependentSkillsFixture,
createIxIndependentSkillsRevivalFixture, finishIxIndependentSkillsBattle,
ixIndependentSkillsClean, ixIndependentSkillsPlayer as player, ixIndependentSkillsPolicyActions,
quoteIxIndependentSkillsBattle,
revealIxIndependentSkillsBattle, stepIxIndependentSkills,
} from './fixture-ix-independent-skills';
void test('original Ix deal and paid shipment preserve Cyborg/Suboid custody through the typed Harass commitment', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsFixture({ advanced });
assert.equal(f.beforeIxCard.setupStage, 'skillTreachery');
assert.ok(f.beforeIxCard.players.every(p => p.hand.length === 0));
assert.equal(f.beforeIxCard.ixSetupCards!.length, 3);
assert.equal(f.offered.setupStage, 'leaderSkills');
assert.equal(player(f.offered, f.ixians).hand[0].id, f.ixCard.action.card);
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.ixians)!.skill, 'suk-graduate');
const before = player(f.beforeShipment, f.ixians), shipped = player(f.afterShipment, f.ixians);
assert.equal(before.reserves, 14); assert.equal(before.elites!.reserves, 4);
assert.equal(shipped.reserves, 10); assert.equal(shipped.elites!.reserves, 2);
assert.equal(shipped.spice, before.spice - 8, 'four desert shipments cost two spice per physical force');
assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice + 8);
assert.equal(shipped.forces[f.first], 4); assert.equal(shipped.elites!.forces[f.first], 2);
const moved = player(f.afterMove, f.ixians);
for (const key of [f.first, f.second]) {
assert.equal(moved.forces[key], 2); assert.equal(moved.elites!.forces[key], 1);
}
const revealed = revealIxIndependentSkillsBattle(f), quote = quoteIxIndependentSkillsBattle(f, revealed);
assert.deepEqual(quote.reinforcements,
[{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.scores, { attacker: advanced ? 8.5 : 9.5, defender: 1 },
'Suboids always fight at half strength; unsupported Advanced Cyborgs are half their Basic strength');
const withdrawal = quote.harassWithdraw![0];
assert.deepEqual(withdrawal.returned, { normal: 1, elite: 1 });
assert.deepEqual(withdrawal.locations, f.returns);
assert.equal(withdrawal.remaining.normal, 1); assert.equal(withdrawal.remaining.elite, 1);
assert.equal(revealed.decision?.kind, 'harassWithdraw');
if (revealed.decision?.kind !== 'harassWithdraw') throw Error('Missing original typed withdrawal control');
const event = revealed.decision.event;
assert.throws(() => stepIxIndependentSkills(revealed, { actor: f.ixians,
action: { type: 'decision', event,
returns: { [f.first]: { normal: 2, elite: 0 } } } }), /undialed|physical|return|allocation/i);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
const p = player(rescue, f.ixians);
assert.equal(p.reserves, 9); assert.equal(p.elites!.reserves, 3);
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0, 'only Suboids paid the reserve fee');
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
assert.equal(p.forces[f.second], 1); assert.equal(p.elites!.forces[f.second] ?? 0, 0);
assertIxIndependentSkillsCustody(rescue);
}
});
void test('the real trained Suk rescues a committed Cyborg and Suboid without refunding reserve fees', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsFixture({ advanced });
const rescue = finishIxIndependentSkillsBattle(f, { stop: 'rescue' });
assert.equal(rescue.decision?.kind, 'sukRescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original native rescue controls');
assert.equal(rescue.decision.mode, 'skilled');
assert.ok(rescue.decision.options.some(o => o.normal === 1 && o.elite === 1 &&
o.kept?.kind === 'elite' && o.kept.key === f.first));
const saved = finishIxIndependentSkillsBattle(f, { state: rescue });
assert.equal(saved.lastBattleContext!.winner, f.ixians);
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
const p = player(saved, f.ixians);
assert.equal(p.reserves, 10); assert.equal(p.elites!.reserves, 3);
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0);
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
assert.equal(p.forces[f.second] ?? 0, 0);
assert.equal(p.spice, 2, 'unsupported Cyborgs do not invent a support charge');
assert.equal(player(saved, f.opponent).tanks, 5);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(saved.discard.filter(c => c.id === id).length, 1);
assertIxIndependentSkillsCustody(saved);
const lost = finishIxIndependentSkillsBattle(f, { state: rescue, rescue: 'none' });
assert.equal(player(lost, f.ixians).reserves, 9);
assert.equal(player(lost, f.ixians).elites!.reserves, 3);
assert.equal(player(lost, f.ixians).tanks, 5);
assert.equal(player(lost, f.ixians).elites!.tanks, 1);
assert.equal(player(lost, f.ixians).forces[f.first] ?? 0, 0);
assertIxIndependentSkillsCustody(lost);
}
});
void test('Recruits counts prior native free returns, preserves the cap and charges the paid Cyborg surcharge', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsRevivalFixture({ advanced });
const original = viewGame(f.beforeRecruits, f.ixians).revival;
assert.equal(original.limit, 3); assert.equal(original.freeRemaining, 1);
assert.equal(player(f.beforeRecruits, f.ixians).tanks, 5);
assert.equal(player(f.beforeRecruits, f.ixians).elites!.tanks, 1);
assert.equal(player(f.beforeRecruits, f.ixians).spice, 5, 'real turn-one escrow settled at the real Mentat Pause');
assert.equal(viewGame(f.beforePlay, f.ixians).revival.freeRemaining, 0);
assert.equal(player(f.beforePlay, f.ixians).spice, player(f.beforeRecruits, f.ixians).spice);
assert.equal(player(f.beforePlay, f.tleilaxu).spice, player(f.beforeRecruits, f.tleilaxu).spice + 1,
'native Tleilaxu receive one spice from the Bank for the real free revival');
const active = viewGame(f.beforeRevival, f.ixians).revival;
assert.equal(active.limit, 7); assert.equal(active.freeRemaining, 1);
assert.equal(viewGame(f.beforeRevival, f.tleilaxu).revival.limit, 20,
'the global card never reduces native unlimited revival to seven');
assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 },
'one free Suboid plus one paid Suboid at two and one paid Cyborg at three');
const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians);
assert.equal(after.spice, before.spice - 5);
assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5,
'the actual paid native revival reaches Tleilaxu, not an invented Bank sink');
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.elites!.tanks, 0);
assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 2);
assert.equal(f.afterRevival.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0);
assertIxIndependentSkillsCustody(f.afterRevival);
}
});
void test('all four minimal policies can seal native paid plans and execute projected typed withdrawal and rescue controls', () => {
const f = createIxIndependentSkillsFixture({ advanced: true });
const revealed = revealIxIndependentSkillsBattle(f);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
for (const difficulty of DIFFICULTIES) {
const plan = ixIndependentSkillsPolicyActions(f.game, f.ixians, difficulty).find(a => a.type === 'battlePlan'); assert.ok(plan);
const sealed = stepIxIndependentSkills(f.game, { actor: f.ixians, action: plan });
assert.equal(player(sealed, f.ixians).reserves, player(f.game, f.ixians).reserves,
'sealing alone cannot spend the fee or dial physical counters');
assert.equal(player(sealed, f.ixians).spice, player(f.game, f.ixians).spice);
assert.equal(player(sealed, f.ixians).tanks, 0);
const returns = ixIndependentSkillsPolicyActions(revealed, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(returns);
const allocated = stepIxIndependentSkills(revealed, { actor: f.ixians, action: returns });
const committed = finishIxIndependentSkillsBattle(f, { state: allocated, stop: 'rescue' });
assert.equal(player(committed, f.ixians).reserves, 9);
assert.equal(player(committed, f.ixians).elites!.reserves, 3);
assert.equal(player(committed, f.ixians).tanks, 3);
const choice = ixIndependentSkillsPolicyActions(rescue, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(choice);
const saved = stepIxIndependentSkills(rescue, { actor: f.ixians, action: choice });
const done = advanceIxIndependentSkills(saved, g => g.phase === 7 && ixIndependentSkillsClean(g));
assert.equal(player(done, f.ixians).tanks, 3, 'policy rescue saves the commitment, never the fee');
assert.equal(player(done, f.ixians).elites!.tanks, 0);
assertIxIndependentSkillsCustody(done);
}
});
+3 -4
View File
@@ -17,9 +17,9 @@ function conserved(g: Game) {
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((s, n) => s + n, 0), 3);
}
}
void test('Sandmaster collects once at a worm destination for multiple source sectors and preserves normal movement', () => {
for (const advanced of [false, true]) {
const g = sandmasterWormGame(advanced);
void test('Sandmaster collects once at a worm destination with multiple sources, with or without independent cards', () => {
for (const advanced of [false, true]) for (const independent of [false, true]) {
const g = sandmasterWormGame(advanced, false, independent);
const action = sandmasterRide();
if (!advanced) delete action.eliteForces;
const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action);
@@ -122,7 +122,6 @@ void test('Sandmaster requires the owned living native trainer and the supported
assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null);
}
for (const mutate of [
(g: typeof view) => { g.ecazTreachery = true; },
(g: typeof view) => { g.players[1].faction = 'richese'; },
]) {
const unsupported = structuredClone(view); mutate(unsupported);
+2
View File
@@ -20,6 +20,7 @@ export function smugglerShipmentGame(
skill: 'smuggler' | 'sandmaster' = 'smuggler',
opponentFaction?: FactionId,
expansions: readonly string[] = [],
independent = false,
): Game {
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced, [...expansions]);
joinGame(
@@ -27,6 +28,7 @@ export function smugglerShipmentGame(
newPlayer('h', 'Opponent', opponentFaction ?? (faction === 'guild' ? 'emperor' : 'guild')),
);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (independent) g.ecazTreachery = true;
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill);
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {