Files
Dune/tests/ix-prototype.test.ts
T
Quintine e05a205a47 Compose the deferred Terror arrival and green the mixed E3 programmes
Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.

Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.

Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.

Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
2026-10-07 17:24:23 +11:00

375 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { spiceDeck, treacheryDeck } from '../game/cards';
import { MOBILE_LOCATION } from '../game/board';
import type { FactionId } from '../game/catalog';
import {
applyAction,
createGame,
initializeIxGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
const ROSTER: [string, FactionId][] = [
['i', 'ixians'],
['t', 'tleilaxu'],
['a', 'atreides'],
['h', 'harkonnen'],
];
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
function generator(seed: number) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let word = Math.imul(state ^ (state >>> 15), state | 1);
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
return (word ^ (word >>> 14)) >>> 0;
};
}
function withEngineSeed<T>(seed: number, fn: () => T): T {
const cryptoObject = globalThis.crypto;
const originalRandom = Object.getOwnPropertyDescriptor(
cryptoObject,
'getRandomValues',
);
const originalUuid = Object.getOwnPropertyDescriptor(
cryptoObject,
'randomUUID',
);
const randomWord = generator(seed);
const uuidWord = generator(seed ^ 0xa5a5a5a5);
Object.defineProperty(cryptoObject, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (!(array instanceof Uint32Array))
throw new Error(
`Ix prototype seed ${seed} supports only the engine Uint32Array RNG contract.`,
);
for (let i = 0; i < array.length; i++) array[i] = randomWord();
return array;
},
});
Object.defineProperty(cryptoObject, 'randomUUID', {
configurable: true,
value: () => {
const hex = Array.from({ length: 4 }, () =>
uuidWord().toString(16).padStart(8, '0'),
)
.join('')
.split('');
hex[12] = '4';
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
const id = hex.join('');
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
},
});
try {
return fn();
} finally {
if (originalRandom)
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
if (originalUuid)
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
}
}
function lobby(advanced = false, roster = ROSTER): Game {
const [first, ...rest] = roster;
let g = createGame(
'IXPROTOTYPE',
newPlayer(first[0], first[0], first[1]),
advanced,
['ix'],
);
for (const [id, faction] of rest) joinGame(g, newPlayer(id, id, faction));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function candidates(g: Game, id: string, difficulty: Difficulty) {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = difficulty;
const before = structuredClone(view);
const actions = botActions(view);
assert.deepEqual(view, before);
for (const action of actions)
assert.doesNotThrow(
() => applyAction(g, id, action),
`${difficulty}/${g.setupStage}/${g.decision?.kind}/${id}: ${JSON.stringify(action)}`,
);
return actions;
}
function reachIxChoice(state: Game, difficulty: Difficulty): Game {
let g = state;
for (let step = 0; g.status === 'setup' && step < 24; step++) {
if (g.decision?.kind === 'ixSetup') return g;
let progressed = false;
for (const p of g.players) {
const actions = candidates(g, p.id, difficulty);
if (!actions.length) continue;
g = applyAction(g, p.id, actions[0]);
progressed = true;
break;
}
assert.ok(progressed, `Ix setup stalled at ${g.setupStage}.`);
}
assert.fail('Ix setup did not reach its private starting-card choice.');
}
function assertTreacheryInventory(g: Game) {
const cards = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []),
...(g.auction?.cards.slice(g.auction.index) ?? []),
];
const expected = treacheryDeck(['ix'])
.map((card) => card.id)
.sort();
assert.equal(cards.length, 47);
assert.equal(new Set(cards.map((card) => card.id)).size, 47);
assert.deepEqual(cards.map((card) => card.id).sort(), expected);
}
function assertSetupResult(g: Game) {
assert.equal(g.status, 'playing');
assert.equal(g.setupStage ?? null, null);
assert.equal(g.ixSetupCards, null);
assert.ok(g.phaseOpening);
assert.deepEqual(g.expansions, ['ix']);
assert.equal(g.players.find((p) => p.id === 'i')!.hand.length, 1);
assert.equal(g.players.find((p) => p.id === 't')!.hand.length, 1);
assert.equal(g.players.find((p) => p.id === 'a')!.hand.length, 1);
assert.equal(g.players.find((p) => p.id === 'h')!.hand.length, 2);
const ixians = g.players.find((p) => p.id === 'i')!;
assert.equal(ixians.spice, 10);
assert.equal(ixians.reserves, 14);
assert.deepEqual(ixians.forces, { [MOBILE_LOCATION]: 6 });
assert.equal(ixians.elites?.reserves, 4);
assert.deepEqual(ixians.elites!.forces, { [MOBILE_LOCATION]: 3 });
assert.equal(g.mobileStronghold?.location, null);
const tleilaxu = g.players.find((p) => p.id === 't')!;
assert.equal(tleilaxu.spice, 5);
assert.equal(tleilaxu.reserves, 20);
assert.deepEqual(tleilaxu.forces, {});
assert.equal(tleilaxu.traitors.length, 0);
assert.equal(tleilaxu.faceDancers?.length, 3);
assert.equal(
new Set(tleilaxu.faceDancers?.map((card) => card.leader)).size,
3,
);
for (const owner of g.players) {
const own = viewGame(g, owner.id).players.find((p) => p.id === owner.id)!;
assert.deepEqual(own.hand, owner.hand);
assert.deepEqual(own.traitors, owner.traitors);
if (owner.id === 't') assert.deepEqual(own.faceDancers, owner.faceDancers);
for (const observer of g.players.filter((p) => p.id !== owner.id)) {
const hidden = viewGame(g, observer.id).players.find(
(p) => p.id === owner.id,
)!;
assert.equal(hidden.hand, undefined);
assert.equal(hidden.traitors, undefined);
assert.equal(hidden.faceDancers, undefined);
}
}
const traitors = [
...(g.traitorReserve ?? []),
...g.players.flatMap((p) => p.traitors),
...(tleilaxu.faceDancers ?? []).map((card) => card.leader),
];
assert.equal(traitors.length, 21);
assert.equal(new Set(traitors).size, 21);
assertTreacheryInventory(g);
}
function rejectInitializer(g: Game, pattern?: RegExp) {
const before = structuredClone(g);
if (pattern) assert.throws(() => initializeIxGameForAudit(g), pattern);
else assert.throws(() => initializeIxGameForAudit(g));
assert.deepEqual(g, before);
}
function finishGame(initial: Game, seed: number): Game {
let g = initial;
for (let batch = 0; batch < 240 && g.status !== 'finished'; batch++) {
g = runBots(reload(g));
assert.ok(
g.status === 'finished' || g.botsPending,
`Ix prototype seed ${seed} stalled at turn ${g.turn}, phase ${g.phase}.`,
);
}
assert.equal(g.status, 'finished', `Ix prototype seed ${seed}.`);
assert.ok(g.winner.length > 0, `Ix prototype seed ${seed} has no winner.`);
return g;
}
void test('all AI profiles complete genuine private Ix setup through JSON continuation', () => {
for (const difficulty of DIFFICULTIES)
for (const advanced of [false, true]) {
const initial = lobby(advanced);
const before = structuredClone(initial);
let g = initializeIxGameForAudit(initial);
assert.deepEqual(initial, before);
assert.equal(g.status, 'setup');
assert.equal(g.deck.length, 47);
assert.equal(g.spiceDeck.length, 22);
assert.equal(g.spiceDeck.filter((card) => 'sandtrout' in card).length, 1);
assert.equal(g.spiceDeck.length, spiceDeck(true).length);
assert.ok(g.players.every((p) => p.hand.length === 0));
g = reachIxChoice(g, difficulty);
assert.equal(g.ixSetupCards?.length, ROSTER.length);
const ids = g.ixSetupCards!.map((card) => card.id);
assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards);
for (const observer of ['t', 'a', 'h']) {
const view = viewGame(g, observer);
assert.equal(view.ixTechnology, null);
const serialized = JSON.stringify(view);
for (const id of ids) assert.equal(serialized.includes(id), false);
}
const rejected = structuredClone(g);
assert.throws(() =>
applyAction(g, 't', { type: 'decision', card: ids[0] }),
);
assert.deepEqual(g, rejected);
assert.throws(() =>
applyAction(g, 'i', { type: 'decision', card: 'missing-card' }),
);
assert.deepEqual(g, rejected);
const restored = reload(g);
const choice = candidates(restored, 'i', difficulty)[0];
assert.equal(choice.type, 'decision');
g = applyAction(restored, 'i', choice);
assertSetupResult(g);
}
});
for (const [advanced, seed] of [
[false, 2026091301],
[true, 2026091302],
] as const)
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix prototype seed ${seed} completes with mixed AI and conserved pieces`, () =>
withEngineSeed(seed, () => {
let g = lobby(advanced);
g.players.forEach((p, index) => {
p.bot = DIFFICULTIES[index];
});
g = initializeIxGameForAudit(g);
assert.equal(g.status, 'setup');
assert.equal(g.deck.length, 47);
assert.equal(g.spiceDeck.length, 22);
g = finishGame(g, seed);
assertTreacheryInventory(g);
for (const p of g.players)
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((sum, count) => sum + count, 0),
20,
`${p.id} in seed ${seed}`,
);
const spiceCards = [...g.spiceDeck, ...g.spiceDiscard.flat()].filter(
(card) => !('worm' in card && card.thumper),
);
assert.equal(
spiceCards.length + Number(g.sandtrout),
22,
`Spice deck in seed ${seed}`,
);
assert.equal(
spiceCards.filter((card) => 'sandtrout' in card).length +
Number(g.sandtrout),
1,
`Sandtrout in seed ${seed}`,
);
}));
void test('the Ix prototype keeps public gates and rejects unsupported or nonempty lobbies without mutation', () => {
const publicLobby = lobby(false, [
['a', 'atreides'],
['h', 'harkonnen'],
]);
const publicBefore = structuredClone(publicLobby);
assert.throws(
() => applyAction(publicLobby, publicLobby.host, { type: 'start' }),
/Expansion rules are still being implemented/,
);
assert.deepEqual(publicLobby, publicBefore);
const absent = createGame('NOIXPROTO', newPlayer('a', 'a', 'atreides'));
absent.players.push(newPlayer('h', 'h', 'harkonnen'));
absent.players.forEach((p) => (p.ready = true));
rejectInitializer(absent, /requires exactly/);
const combined = createGame(
'COMBINEDPROTO',
newPlayer('a', 'a', 'atreides'),
false,
['ix', 'choam'],
);
combined.players.push(newPlayer('h', 'h', 'harkonnen'));
combined.players.forEach((p) => (p.ready = true));
rejectInitializer(combined, /requires exactly/);
const unsupported = createGame(
'FACTIONPROTO',
newPlayer('c', 'c', 'choam'),
false,
['ix'],
);
unsupported.players.push(newPlayer('a', 'a', 'atreides'));
unsupported.players.forEach((p) => (p.ready = true));
rejectInitializer(unsupported, /base, Ixian and Tleilaxu/);
const moduleFactories: Array<[string, (g: Game) => Game, RegExp]> = [
[
'tech',
(g) => applyAction(g, g.host, { type: 'techTokens', enabled: true }),
/Tech Tokens require a fresh supported three-through-six-seat lobby/,
],
[
'homeworld',
(g) => applyAction(g, g.host, { type: 'homeworlds', enabled: true }),
/without optional modules/,
],
[
'stronghold',
(g) => applyAction(g, g.host, { type: 'strongholdCards', enabled: true }),
/Stronghold Cards require a fresh supported Advanced lobby/,
],
[
'nexus',
(g) => ({ ...g, nexusCards: { cards: null, phase: null } }),
/without optional modules/,
],
];
for (const [name, enable, rejection] of moduleFactories) {
let g = enable(lobby(name === 'stronghold'));
for (const p of g.players)
if (!p.ready) g = applyAction(g, p.id, { type: 'ready' });
rejectInitializer(g, rejection);
}
const occupied = lobby();
occupied.deck.push(treacheryDeck(['ix'])[0]);
rejectInitializer(occupied, /cannot redeal/);
const initialized = initializeIxGameForAudit(lobby());
rejectInitializer(initialized, /fresh lobby/);
});