Files
Dune/tests/base-setup-order.test.ts
2026-09-08 07:37:52 +10:00

539 lines
18 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { FACTIONS, type FactionId } from '../game/catalog';
import { baseDeck } from '../game/cards';
import { location, territory } from '../game/board';
import { isAdvisor } from '../game/advisors';
import { createTechTokens } from '../game/tech-tokens';
const BASE: FactionId[] = [
'atreides',
'harkonnen',
'emperor',
'fremen',
'guild',
'beneGesserit',
];
function lobby(
roster: FactionId[] = BASE,
advanced = true,
reverse = false,
difficulty?: Difficulty,
): Game {
let g = createGame(
'BASESETUPAUDIT',
newPlayer(roster[0], roster[0], roster[0]),
advanced,
);
if (reverse)
g = applyAction(g, roster[0], { type: 'seatPosition', position: 6 });
for (let i = 1; i < roster.length; i++) {
joinGame(g, newPlayer(roster[i], roster[i], roster[i]));
const position = reverse ? 6 - i : i + 1;
if (g.playerPositions![roster[i]] !== position)
g = applyAction(g, roster[i], { type: 'seatPosition', position });
}
for (const id of roster) g = applyAction(g, id, { type: 'ready' });
if (difficulty)
g.players.forEach((p) => {
p.bot = difficulty;
});
return g;
}
function rejectUnchanged(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
function predict(g: Game) {
return applyAction(g, 'beneGesserit', {
type: 'predict',
faction: g.players.find((p) => p.faction !== 'beneGesserit')!.faction,
turn: 4,
});
}
function assertTraitorInventory(g: Game) {
const cards = [
...(g.traitorReserve ?? []),
...g.players.flatMap((p) => [...p.traitors, ...p.traitorChoices]),
];
assert.equal(new Set(cards).size, cards.length);
assert.deepEqual(
cards.slice().sort(),
g.players.flatMap((p) => p.leaders.map((l) => l.id)).sort(),
);
}
function selectTraitors(state: Game) {
let g = state;
for (let count = 0; g.setupStage === 'traitors' && count < 6; count++) {
assertTraitorInventory(g);
const p = g.players.find((p) => p.traitorChoices.length);
assert.ok(p, 'The traitor stage must have an entitled unresolved actor.');
assert.ok(g.players.every((p) => p.hand.length === 0));
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
}
assert.notEqual(g.setupStage, 'traitors');
return g;
}
function fremenPlacement(elite = 0): Action {
const key = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
return {
type: 'fremenSetup',
placements: { [key]: 10 },
elitePlacements: { [key]: elite },
};
}
function completeSetup(state: Game, difficulty: Difficulty, reverse = false) {
let g = state;
const history: string[] = [];
for (let step = 0; g.status === 'setup' && step < 30; step++) {
let progressed = false;
const ids = g.players.map((p) => p.id);
if (reverse) ids.reverse();
for (const id of ids) {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = difficulty;
for (const other of view.players.filter((p) => p.id !== id)) {
assert.equal(other.hand, undefined);
assert.equal(other.traitors, undefined);
assert.equal(other.traitorChoices, undefined);
assert.equal(other.prediction, undefined);
}
assert.equal('traitorReserve' in view, false);
assert.equal('deck' in view, false);
const beforeView = structuredClone(view);
const actions = botActions(view);
assert.deepEqual(
view,
beforeView,
'Setup candidate generation must leave the entitled projection unchanged.',
);
for (const action of actions)
assert.doesNotThrow(
() => applyAction(g, id, action),
`${difficulty}/${g.setupStage}/${id}: ${JSON.stringify(action)}`,
);
if (!actions.length) continue;
history.push(`${g.setupStage}:${actions[0].type}`);
g = applyAction(g, id, actions[0]);
progressed = true;
break;
}
assert.ok(
progressed,
`Unresolved setup stall: ${difficulty} ${g.setupStage} ${g.players.map((p) => p.faction).join(',')}`,
);
}
assert.equal(g.status, 'playing');
return { game: g, history };
}
function assertComplete(g: Game) {
assert.equal(g.status, 'playing');
assert.equal(g.setupStage ?? null, null);
assert.equal(g.turn, 1);
assert.equal(g.phase, 0);
assert.equal(g.storm, 1);
assert.equal(g.stormDialers.length, 2);
assert.ok(g.stormDialers.every((id) => g.players.some((p) => p.id === id)));
for (const p of g.players) {
assert.equal(p.spice, FACTIONS.find((f) => f.id === p.faction)!.spice);
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
assert.equal(p.traitors.length, p.faction === 'harkonnen' ? 4 : 1);
assert.equal(p.traitorChoices.length, 0);
if (g.advanced && (p.faction === 'fremen' || p.faction === 'emperor')) {
assert.ok(p.elites);
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
p.faction === 'fremen' ? 3 : 5,
);
assert.ok(p.elites.reserves <= p.reserves);
assert.ok(p.elites.tanks <= p.tanks);
for (const [key, amount] of Object.entries(p.elites.forces))
assert.ok(amount <= p.forces[key]);
} else assert.equal(p.elites, undefined);
if (p.faction === 'fremen') assert.equal(p.reserves, 10);
if (p.faction === 'beneGesserit') {
assert.ok(p.prediction);
assert.equal(p.reserves, 19);
assert.equal(
Object.values(p.forces).reduce((a, b) => a + b, 0),
1,
);
if (g.advanced) assert.equal(p.advisorSetup, true);
else assert.deepEqual(p.forces, { 'polar_sink:0': 1 });
}
const own = viewGame(g, p.id).players.find((x) => x.id === p.id)!;
assert.deepEqual(own.hand, p.hand);
assert.deepEqual(own.traitors, p.traitors);
for (const observer of g.players.filter((x) => x.id !== p.id)) {
const hidden = viewGame(g, observer.id).players.find(
(x) => x.id === p.id,
)!;
assert.equal(hidden.hand, undefined);
assert.equal(hidden.traitors, undefined);
assert.equal(hidden.traitorChoices, undefined);
assert.equal(hidden.prediction, undefined);
}
}
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
assert.deepEqual(
cards.map((c) => c.id).sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
assertTraitorInventory(g);
const heldTraitors = g.players.flatMap((p) => p.traitors);
assert.equal(new Set(heldTraitors).size, heldTraitors.length);
assert.ok(
heldTraitors.every((id) =>
g.players.some((p) => p.leaders.some((l) => l.id === id)),
),
);
}
function seeded<T>(fn: () => T): T {
const cryptoObject = globalThis.crypto;
const original = Object.getOwnPropertyDescriptor(
cryptoObject,
'getRandomValues',
);
let state = 20260922;
Object.defineProperty(cryptoObject, 'getRandomValues', {
configurable: true,
value: (array: Uint32Array) => {
assert.ok(array instanceof Uint32Array);
for (let i = 0; i < array.length; i++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
array[i] = state;
}
return array;
},
});
try {
return fn();
} finally {
if (original)
Object.defineProperty(cryptoObject, 'getRandomValues', original);
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
}
}
void test('source order locks private BG prediction before cards, traitors before placements, and treachery after every force choice in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const initial = lobby(BASE, advanced);
const original = structuredClone(initial);
let g = initializeBaseGameForAudit(initial);
assert.deepEqual(initial, original);
assert.equal(g.status, 'setup');
assert.equal(g.setupStage, 'prediction');
for (const p of g.players) {
assert.deepEqual(p.hand, []);
assert.deepEqual(p.traitors, []);
assert.deepEqual(p.traitorChoices, []);
assert.deepEqual(p.forces, {});
assert.equal(p.spice, 0);
assert.equal(p.reserves, 20);
const view = viewGame(g, p.id);
assert.equal(view.setupStage, 'prediction');
assert.deepEqual(view.setupPending, ['beneGesserit']);
assert.equal('deck' in view, false);
assert.equal('spiceDeck' in view, false);
assert.deepEqual(view.players.find((x) => x.id === p.id)!.hand, []);
}
rejectUnchanged(g, 'atreides', {
type: 'predict',
faction: 'fremen',
turn: 4,
});
rejectUnchanged(g, 'beneGesserit', {
type: 'traitor',
leader: 'atreides-0',
});
rejectUnchanged(g, 'fremen', fremenPlacement());
rejectUnchanged(g, 'beneGesserit', {
type: 'advisorSetup',
territory: 'carthag',
sector: 11,
});
g = predict(g);
assert.equal(g.setupStage, 'traitors');
assertTraitorInventory(g);
assert.deepEqual(
g.players.find((p) => p.faction === 'beneGesserit')!.prediction,
{ faction: 'atreides', turn: 4 },
);
assert.ok(
g.players.every(
(p) =>
p.hand.length === 0 &&
p.spice === 0 &&
Object.keys(p.forces).length === 0,
),
);
for (const p of g.players)
assert.equal(
p.faction === 'harkonnen' ? p.traitors.length : p.traitorChoices.length,
4,
);
for (const p of g.players) {
const view = viewGame(g, p.id);
const own = view.players.find((x) => x.id === p.id)!;
assert.deepEqual(own.traitors, p.traitors);
assert.deepEqual(own.traitorChoices, p.traitorChoices);
for (const other of view.players.filter((x) => x.id !== p.id)) {
assert.equal(other.traitors, undefined);
assert.equal(other.traitorChoices, undefined);
assert.equal(other.hand, undefined);
}
assert.equal('traitorReserve' in view, false);
const bg = view.players.find((x) => x.faction === 'beneGesserit')!;
assert.equal(!!bg.prediction, p.faction === 'beneGesserit');
}
rejectUnchanged(g, 'beneGesserit', {
type: 'predict',
faction: 'harkonnen',
turn: 9,
});
rejectUnchanged(g, 'fremen', fremenPlacement());
g = selectTraitors(g);
assert.equal(g.setupStage, 'forces');
assert.deepEqual(viewGame(g, 'fremen').setupPending, ['fremen']);
assert.ok(g.players.every((p) => p.hand.length === 0));
assert.deepEqual(g.players.find((p) => p.faction === 'atreides')!.forces, {
'arrakeen:10': 10,
});
assert.deepEqual(g.players.find((p) => p.faction === 'harkonnen')!.forces, {
'carthag:11': 10,
});
assert.deepEqual(g.players.find((p) => p.faction === 'guild')!.forces, {
'tueks_sietch:5': 5,
});
for (const p of g.players)
assert.equal(p.spice, FACTIONS.find((f) => f.id === p.faction)!.spice);
rejectUnchanged(g, 'atreides', {
type: 'traitor',
leader: g.players[0].traitors[0],
});
if (advanced)
rejectUnchanged(g, 'beneGesserit', {
type: 'advisorSetup',
territory: 'carthag',
sector: 11,
});
g = applyAction(g, 'fremen', fremenPlacement(advanced ? 3 : 0));
if (advanced) {
assert.equal(g.setupStage, 'forces');
assert.deepEqual(viewGame(g, 'beneGesserit').setupPending, [
'beneGesserit',
]);
assert.ok(g.players.every((p) => p.hand.length === 0));
g = applyAction(g, 'beneGesserit', {
type: 'advisorSetup',
territory: 'carthag',
sector: 11,
});
assert.equal(
isAdvisor(
g.players.find((p) => p.faction === 'beneGesserit')!,
'carthag',
),
true,
);
}
assertComplete(g);
rejectUnchanged(g, 'beneGesserit', {
type: 'predict',
faction: 'guild',
turn: 7,
});
rejectUnchanged(g, 'fremen', fremenPlacement());
}
});
void test('Fremen initial elite distributions zero through three conserve typed forces; invalid allocations leave the force stage and undealt hands intact', () => {
for (const elite of [0, 1, 2, 3]) {
let g = selectTraitors(
initializeBaseGameForAudit(lobby(['fremen', 'emperor'])),
);
const f = g.players[0];
assert.equal(f.elites!.reserves, 3);
assert.equal(g.players[1].elites!.reserves, 5);
const tabr = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
const south = location(
'false_wall_south',
territory('false_wall_south').sectors[0],
);
rejectUnchanged(g, f.id, {
type: 'fremenSetup',
placements: { [tabr]: 10 },
elitePlacements: { [tabr]: 4 },
});
rejectUnchanged(g, f.id, {
type: 'fremenSetup',
placements: { [tabr]: 5, [south]: 5 },
elitePlacements: { [tabr]: 2, [south]: 2 },
});
g = applyAction(g, f.id, {
type: 'fremenSetup',
placements: { [tabr]: 5, [south]: 5 },
elitePlacements: {
[tabr]: Math.min(elite, 2),
[south]: Math.max(0, elite - 2),
},
});
assertComplete(g);
assert.equal(g.players[0].elites!.reserves, 3 - elite);
}
});
void test('public Advanced start remains gated with forged bypass fields, while the audit seam shares corrected Basic initialization and leaves inputs unchanged', () => {
const advanced = lobby();
rejectUnchanged(advanced, advanced.host, { type: 'start' });
rejectUnchanged(advanced, advanced.host, {
type: 'start',
audit: true,
allowUnsupported: true,
advanced: false,
});
rejectUnchanged(advanced, 'fremen', { type: 'start' });
const basic = lobby(BASE, false);
const before = structuredClone(basic);
const normal = seeded(() =>
applyAction(basic, basic.host, { type: 'start' }),
);
const audit = seeded(() => initializeBaseGameForAudit(basic));
assert.deepEqual(normal, audit);
assert.deepEqual(basic, before);
assert.equal(audit.setupStage, 'prediction');
for (const mutate of [
(g: Game) => {
g.players[0].ready = false;
},
(g: Game) => {
g.players = g.players.slice(0, 1);
},
(g: Game) => {
g.players.push(newPlayer('seventh', 'seventh', 'atreides'));
},
(g: Game) => {
g.players[1].id = g.players[0].id;
},
(g: Game) => {
g.players[1].faction = g.players[0].faction;
},
(g: Game) => {
g.host = 'absent';
},
(g: Game) => {
g.playerPositions![g.players[1].id] = g.playerPositions![g.players[0].id];
},
(g: Game) => {
g.expansions = ['ix'];
},
(g: Game) => {
g.players[1].faction = 'ixians';
},
(g: Game) => {
g.techTokens = createTechTokens();
},
(g: Game) => {
g.players[0].hand = [baseDeck()[0]];
},
(g: Game) => {
g.players[0].spice = 1;
},
(g: Game) => {
g.players[0].forces = { 'arrakeen:10': 1 };
g.players[0].reserves = 19;
},
]) {
const malformed = structuredClone(advanced);
mutate(malformed);
const original = structuredClone(malformed);
assert.throws(() => initializeBaseGameForAudit(malformed));
assert.deepEqual(malformed, original);
}
const started = initializeBaseGameForAudit(advanced);
const snapshot = structuredClone(started);
assert.throws(() => initializeBaseGameForAudit(started));
assert.deepEqual(started, snapshot);
});
void test('legacy no-stage setup can finish its already-dealt prediction without dealing another hand', () => {
let g = completeSetup(
initializeBaseGameForAudit(lobby(BASE, false)),
'Medium',
).game;
const hands = g.players.map((p) => structuredClone(p.hand));
const deck = structuredClone(g.deck);
g.status = 'setup';
delete g.setupStage;
delete g.players.find((p) => p.faction === 'beneGesserit')!.prediction;
g = predict(g);
assert.equal(g.status, 'playing');
assert.deepEqual(
g.players.map((p) => p.hand),
hands,
);
assert.deepEqual(g.deck, deck);
});
void test(
'all57base rosters complete shared Advanced setup across four profiles and both physical/actor orders with every proposed action legal',
{ timeout: 180000 },
() => {
let count = 0;
for (let mask = 0; mask < 64; mask++) {
const roster = BASE.filter((_, i) => mask & (1 << i));
if (roster.length < 2) continue;
for (const difficulty of DIFFICULTIES)
for (const reverse of [false, true]) {
const before = lobby(roster, true, reverse, difficulty);
const original = structuredClone(before);
const initialized = initializeBaseGameForAudit(before);
assert.deepEqual(before, original);
assert.equal(initialized.advanced, true);
assert.equal(
initialized.setupStage,
roster.includes('beneGesserit') ? 'prediction' : 'traitors',
);
const completed = completeSetup(initialized, difficulty, reverse);
assertComplete(completed.game);
if (roster.includes('beneGesserit'))
assert.equal(completed.history[0], 'prediction:predict');
assert.ok(
completed.history.every(
(step, i) =>
!step.startsWith('traitors:') ||
!completed.history
.slice(0, i)
.some((previous) => previous.startsWith('forces:')),
),
);
count++;
}
}
assert.equal(count, 456);
},
);