Files
Dune/tests/nexus-card-engine.test.ts
T

387 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import {
enterNexusSpice,
finishNexusSpice,
nexusFixture,
nexusAllow,
nexusInventory,
nexusPlayer,
nexusReady,
nexusReject,
nexusReload,
nexusTurnTwo,
orderNexusSpice,
} from './fixture-nexus-cards';
function alliance(g: Game, one = 'f', two = 'a'): Game {
g = applyAction(g, one, { type: 'alliance', target: two });
return applyAction(g, two, { type: 'alliance', target: one });
}
function negotiations(g: Game): Game {
g = nexusReady(g);
assert.ok(g.spiceWindow);
g = nexusReady(g);
assert.equal(g.nexus, true);
assert.equal(g.spiceWindow, null);
return g;
}
function choice(g: Game, id: string, value: string): Action {
return {
type: 'nexusCardChoice',
turn: g.turn,
card: g.nexusCards!.cards!.hands[id],
choice: value,
ownRedraws: 0,
};
}
function top(g: Game, ...cards: FactionId[]): void {
const deck = g.nexusCards!.cards!.deck;
assert.ok(cards.every((card) => deck.includes(card)));
g.nexusCards!.cards!.deck = [
...cards,
...deck.filter((card) => !cards.includes(card)),
];
}
function closing(advanced = false): Game {
let g = nexusTurnTwo({ advanced });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
return finishNexusSpice(g);
}
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced A/B' : 'Basic'} real phase draws once only after all spice passes and settled alliances`, () => {
let g = nexusTurnTwo({ advanced });
assert.equal(g.nexusCards!.cards!.deck.length, 12);
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
g = nexusReady(g);
if (advanced) {
assert.equal(g.spiceSequence!.pile, 1);
assert.ok(g.spiceWindow);
assert.equal(g.nexusCards!.cards!.deck.length, 12);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
}
g = finishNexusSpice(g);
assert.equal(g.phase, 1);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
assert.equal(viewGame(g, 'h').beforeSpiceDraw, false);
top(g, 'ecaz');
const draw = choice(g, 'h', 'draw');
g = applyAction(nexusReload(g), 'h', draw);
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.phase!.stage, 'complete');
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.equal(g.nexusCards!.cards!.deck.length, 11);
nexusReject(g, 'h', draw);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
nexusInventory(g);
});
void test('first-turn skipped worm, no worm, and actual Nexus without any alliance do not grant a card', () => {
let first = enterNexusSpice(nexusFixture());
orderNexusSpice(first, ['worm', 'land']);
first = finishNexusSpice(first);
assert.equal(first.nexusCards!.phase!.occurred, false);
assert.equal(first.phase, 2);
let noWorm = enterNexusSpice(first, true);
orderNexusSpice(noWorm, ['land']);
noWorm = finishNexusSpice(noWorm);
assert.deepEqual(noWorm.nexusCards!.phase!.eligible, []);
let noAlliance = enterNexusSpice(noWorm, true);
orderNexusSpice(noAlliance, ['worm', 'land']);
noAlliance = finishNexusSpice(noAlliance);
assert.equal(noAlliance.nexusCards!.phase!.occurred, true);
assert.deepEqual(noAlliance.nexusCards!.phase!.eligible, []);
assert.equal(noAlliance.nexusCards!.cards!.deck.length, 12);
nexusInventory(noAlliance);
});
void test('a real before-blow Fremen summoned Nexus resumes both Advanced piles and offers one final draw', () => {
let g = nexusTurnTwo({ advanced: true });
orderNexusSpice(g, ['land', 'land']);
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
nexusPlayer(g, 'f').hand.push(card);
const beforeSpice = nexusReload(g).spiceDeck;
g = nexusAllow(
applyAction(g, 'f', {
type: 'card',
mode: 'special',
card: card.id,
territory: 'the_great_flat',
}),
);
assert.equal(g.summonedBeforeBlow, true);
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(g.spiceDeck, beforeSpice);
assert.equal(g.discard.filter((item) => item.id === card.id).length, 1);
g = alliance(negotiations(nexusReload(g)));
g = finishNexusSpice(g);
assert.equal(
g.spiceDiscard[0].filter((item) => 'territory' in item).length,
1,
);
assert.equal(
g.spiceDiscard[1].filter((item) => 'territory' in item).length,
1,
);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
top(g, 'ecaz');
g = applyAction(nexusReload(g), 'h', choice(g, 'h', 'draw'));
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.deck.length, 11);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
nexusInventory(g);
});
void test('precommitted own-faction policy completes privately and later replacement discards once', () => {
const initial = closing();
top(initial, 'harkonnen', 'ecaz', 'moritani');
const keptOwn = applyAction(initial, 'h', choice(initial, 'h', 'draw'));
assert.equal(keptOwn.phase, 2);
assert.equal(keptOwn.nexusCards!.cards!.hands.h, 'harkonnen');
assert.deepEqual(viewGame(keptOwn, 'a').nexusCards!.waiting, []);
let g = applyAction(nexusReload(initial), 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 1,
});
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen']);
assert.deepEqual(viewGame(g, 'a').nexusCards!.waiting, []);
nexusReject(g, 'h', choice(initial, 'h', 'draw'));
const repeatedPolicy = applyAction(initial, 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 2,
});
assert.deepEqual(repeatedPolicy.nexusCards, g.nexusCards);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = finishNexusSpice(g);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, ['keep', 'replace']);
const replace = choice(g, 'h', 'replace');
g = applyAction(nexusReload(g), 'h', replace);
assert.equal(g.nexusCards!.cards!.hands.h, 'moritani');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen', 'ecaz']);
nexusReject(g, 'h', replace);
nexusInventory(g);
});
void test('closing choices fence stale owners/cards/turns and every other action; all four bots emit a legal choice', () => {
const g = closing();
for (const action of [
{ type: 'ready' },
{ type: 'alliance', target: 'f' },
{ type: 'card', card: 'invented' },
{ type: 'ship', amount: 1 },
{ type: 'decision', decline: true },
{ ...choice(g, 'h', 'draw'), turn: g.turn - 1 },
{ ...choice(g, 'h', 'draw'), card: 'atreides' },
{ ...choice(g, 'h', 'draw'), injected: true },
])
nexusReject(g, 'h', action);
nexusReject(g, 'f', choice(g, 'f', 'draw'));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'h');
view.players.find((p) => p.id === 'h')!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action?.type, 'nexusCardChoice');
assert.doesNotThrow(() => applyAction(g, 'h', action!));
}
const kept = applyAction(g, 'h', choice(g, 'h', 'keep'));
assert.equal(kept.phase, 2);
assert.equal(kept.nexusCards!.cards!.deck.length, 12);
});
void test('private card projection exposes only own identity; a real reciprocal Nexus join forfeits held card exactly once', () => {
let g = closing();
top(g, 'ecaz');
g = applyAction(g, 'h', choice(g, 'h', 'draw'));
for (const id of ['f', 'a', 'h']) {
const projected = viewGame(nexusReload(g), id).nexusCards!;
assert.equal(projected.card, id === 'h' ? 'ecaz' : null);
assert.deepEqual(projected.held, { f: false, a: false, h: true });
assert.deepEqual(Object.keys(projected).sort(), [
'card',
'choices',
'deckCount',
'discardCount',
'held',
'turn',
'waiting',
]);
if (id !== 'h')
assert.equal(JSON.stringify(projected).includes('ecaz'), false);
}
assert.equal(JSON.stringify(g.log).includes('ecaz'), false);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = applyAction(g, 'h', { type: 'alliance', target: 'a' });
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz', 'offer is not acceptance');
g = applyAction(nexusReload(g), 'a', { type: 'alliance', target: 'h' });
assert.equal(g.nexusCards!.cards!.hands.h, null);
assert.deepEqual(g.nexusCards!.cards!.discard, ['ecaz']);
assert.equal(nexusPlayer(g, 'h').ally, 'a');
nexusInventory(g);
});
void test('malformed inventory and closing state reject all views, normalization and action without repair', () => {
const g = closing();
const corruptions: ((g: Game) => void)[] = [
(x) => {
x.nexusCards!.cards!.deck.pop();
},
(x) => {
x.nexusCards!.cards!.hands.h = x.nexusCards!.cards!.deck[0];
},
(x) => {
x.nexusCards!.phase!.done = ['f'];
},
(x) => {
x.nexusCards!.phase!.occurred = false;
},
(x) => {
x.nexusCards!.phase!.turn--;
},
(x) => {
x.nexusCards!.phase = null;
},
];
for (const corrupt of corruptions) {
const bad = nexusReload(g);
corrupt(bad);
const before = nexusReload(bad);
for (const p of bad.players)
assert.throws(() => viewGame(bad, p.id), /Nexus/);
assert.throws(() => normalizeAutomaticGame(bad), /Nexus/);
assert.throws(() => applyAction(bad, 'h', choice(g, 'h', 'draw')), /Nexus/);
assert.deepEqual(bad, before);
}
});
for (const hostFaction of ['moritani', 'ecaz'] as const)
void test(`${hostFaction} actual accepted entry alliance forfeits a genuinely drawn card, but declaration and refusal retain it`, () => {
// This explicit expansion roster enters the same genuine audit setup before
// any Nexus custody exists. Base Treachery inventory remains unchanged.
let g = nexusTurnTwo({ hostFaction });
orderNexusSpice(g, ['worm', 'land']);
g = alliance(negotiations(g), 'a', 'h');
g = finishNexusSpice(g);
top(g, 'richese');
g = applyAction(g, 'f', choice(g, 'f', 'draw'));
// Stage a later unused shipment, retaining the completed real phase receipt.
// The entrant first ends its old alliance in the next genuine Nexus.
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = finishNexusSpice(g);
Object.assign(g, {
phase: 5,
active: 'a',
order: ['a', 'f', 'h'],
movementRemaining: ['a', 'f', 'h'],
ready: [],
storm: 18,
});
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {};
Object.assign(p, { spice: 20, shipped: false, moved: 0 });
}
if (hostFaction === 'moritani') {
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g.moritaniTerror = placeTerror(
g.moritaniTerror!,
token.id,
'arrakeen',
g.turn,
);
} else {
const token = g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors!, token.id, {
turn: g.turn,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
}
g = applyAction(g, 'a', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
const declare: Action =
hostFaction === 'moritani'
? { type: 'decision', alliance: true }
: {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
choice: 'alliance',
beneficiary: 'f',
};
g = applyAction(g, 'f', declare);
assert.equal(g.nexusCards!.cards!.hands.f, 'richese');
assert.equal(
g.response,
null,
'empty Treachery hands need no Karama response',
);
const reply: Action =
hostFaction === 'moritani'
? { type: 'decision', accept: true }
: { type: 'decision', event: g.pendingAmbassador!.event, accept: true };
const refused = applyAction(nexusReload(g), 'a', {
...reply,
accept: false,
});
assert.equal(refused.nexusCards!.cards!.hands.f, 'richese');
const accepted = applyAction(nexusReload(g), 'a', reply);
assert.equal(accepted.nexusCards!.cards!.hands.f, null);
assert.deepEqual(accepted.nexusCards!.cards!.discard, ['richese']);
assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
assert.equal(nexusPlayer(accepted, 'a').shipped, true);
assert.equal(accepted.pendingTerrorEntry ?? null, null);
// The Basic Ecaz Ambassador leaves an optional new-ally Duke loan open;
// declining it closes the entry with every other outcome unchanged.
if (hostFaction === 'ecaz') {
assert.equal(accepted.pendingAmbassador?.stage, 'loan');
const closed = applyAction(nexusReload(accepted), 'f', {
type: 'decision',
event: accepted.pendingAmbassador!.event,
loan: false,
});
assert.equal(closed.pendingAmbassador ?? null, null);
assert.equal(nexusPlayer(closed, 'f').ally, 'a');
nexusInventory(closed);
} else {
assert.equal(accepted.pendingAmbassador ?? null, null);
nexusInventory(accepted);
}
});