387 lines
14 KiB
TypeScript
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);
|
|
}
|
|
});
|