Files
Dune/tests/nexus-advisors.test.ts

323 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import {
nexusAdvisorFixture,
nexusAdvisorPlayer,
nexusAdvisorInventory,
holdAdvisorKarama,
beginNexusAdvisorFlip,
allowNexusAdvisorFlip,
} from './fixture-nexus-advisors';
import { nexusReload, nexusReject } from './fixture-nexus-cards';
import { drawNexusCard } from '../game/nexus-cards';
function action(g: Game, territories = ['arrakeen', 'pasty_mesa']) {
const owner = nexusAdvisorPlayer(g);
return {
type: 'nexusAdvisors',
event: viewGame(g, owner.id).nexusAdvisors!.offer!.event,
territories,
};
}
function resources(g: Game) {
return g.players.map((p) => ({
id: p.id,
forces: p.forces,
reserves: p.reserves,
tanks: p.tanks,
elites: p.elites,
spice: p.spice,
hand: p.hand,
shipped: p.shipped,
moved: p.moved,
}));
}
void test('Cunning converts every counter in selected multi-sector groups without consuming movement, shipment or spice', () => {
let g = nexusAdvisorFixture();
holdAdvisorKarama(g);
const original = nexusReload(g);
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
assert.deepEqual(
[...offer.territories]
.sort((a, b) => a.territory.localeCompare(b.territory))
.map((t) => [t.territory, t.count]),
[
['arrakeen', 2],
['carthag', 1],
['pasty_mesa', 3],
],
);
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
assert.deepEqual(
nexusAdvisorPlayer(g).advisors,
nexusAdvisorPlayer(original).advisors,
);
assert.equal(g.nexusCards!.cards!.hands.p, null);
g = allowNexusAdvisorFlip(g);
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
assert.deepEqual(resources(g), resources(original));
assert.equal(g.phase, 5);
assert.equal(g.active, 'p');
assert.equal(g.movementRemaining, original.movementRemaining);
assert.equal(g.pendingTerrorEntry, original.pendingTerrorEntry);
assert.equal(g.pendingAmbassador, original.pendingAmbassador);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
1,
);
nexusAdvisorInventory(g);
});
void test('one Karama cancels the entire announced advisor batch and spends both physical cards once', () => {
let g = nexusAdvisorFixture();
const karama = holdAdvisorKarama(g);
const advisors = structuredClone(nexusAdvisorPlayer(g).advisors);
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa', 'carthag']);
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
g = allowNexusAdvisorFlip(g);
assert.deepEqual(nexusAdvisorPlayer(g).advisors, advisors);
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
1,
);
assert.equal(viewGame(g, 'p').nexusAdvisors!.pending, null);
nexusAdvisorInventory(g);
});
void test('saved live response exposes only its public selected groups and resumes exactly once', () => {
let g = nexusAdvisorFixture({
seatIds: ['bg-seat', 'guild-seat', 'fremen-seat'],
});
holdAdvisorKarama(g, 'guild-seat');
const request = action(g);
g = beginNexusAdvisorFlip(g, request.territories);
const saved = nexusReload(g);
const expected = viewGame(saved, 'bg-seat').nexusAdvisors!.pending;
assert.ok(expected);
for (const id of ['bg-seat', 'guild-seat', 'fremen-seat']) {
const view = viewGame(saved, id);
assert.deepEqual(view.nexusAdvisors!.pending, expected);
for (const p of view.players)
if (p.id !== id) assert.equal(p.hand, undefined);
}
assert.deepEqual(normalizeAutomaticGame(saved), saved);
g = allowNexusAdvisorFlip(nexusReload(saved));
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
const done = nexusReload(g);
assert.deepEqual(normalizeAutomaticGame(done), done);
nexusReject(g, 'bg-seat', request);
nexusAdvisorInventory(g);
});
void test('foreign, stale, empty, duplicate, partial and non-advisor selections reject before any spend', () => {
const g = nexusAdvisorFixture();
const request = action(g);
nexusReject(g, 'q', request);
nexusReject(g, 'p', { ...request, event: 'old' });
for (const territories of [
[],
['arrakeen', 'arrakeen'],
['polar_sink'],
['pasty_mesa:5'],
['homeworld:beneGesserit'],
])
nexusReject(
g,
'p',
{ ...request, territories },
/advisor|territor|Homeworld/i,
);
nexusReject(g, 'p', { ...request, amount: 1 });
assert.equal(g.nexusCards!.cards!.hands.p, 'beneGesserit');
});
void test('Cunning belongs to the current native Advanced shipment and movement action', () => {
for (const mutate of [
(g: Game) => {
g.active = 'q';
},
(g: Game) => {
g.phase = 6;
},
(g: Game) => {
g.phase = 8;
},
]) {
const g = nexusAdvisorFixture();
mutate(g);
assert.ok(viewGame(g, 'p').nexusAdvisors!.offer!.blocked);
nexusReject(g, 'p', action(g), /own Shipment and Movement/);
}
const basic = nexusAdvisorFixture({ advanced: false });
const offer = viewGame(basic, 'p').nexusAdvisors!.offer;
assert.ok(!offer || offer.blocked || !offer.territories.length);
nexusReject(
basic,
'p',
{
type: 'nexusAdvisors',
event: offer?.event ?? 'none',
territories: ['arrakeen'],
},
/Advanced/,
);
});
void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => {
const g = nexusAdvisorFixture();
const observer = g.players.find((p) => p.id === 'r')!;
observer.forces['arrakeen:10'] = 1;
observer.reserves--;
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
assert.ok(offer.territories.find((t) => t.territory === 'arrakeen')!.blocked);
assert.equal(
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
null,
);
nexusReject(g, 'p', action(g), /three occupying factions/);
nexusAdvisorInventory(g);
});
void test('already completed shipping and moving remain completed after a legal conversion before ending the action', () => {
let g = nexusAdvisorFixture();
nexusAdvisorPlayer(g).shipped = true;
nexusAdvisorPlayer(g).moved = 1;
const before = nexusReload(g);
g = allowNexusAdvisorFlip(beginNexusAdvisorFlip(g, ['pasty_mesa']));
assert.deepEqual(resources(g), resources(before));
assert.equal(g.active, 'p');
assert.equal(g.phase, 5);
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
arrakeen: {},
carthag: {},
});
});
void test('missing response, edited original groups and altered movement-frame evidence reject reads and actions unchanged', () => {
let g = nexusAdvisorFixture();
holdAdvisorKarama(g);
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
for (const mutate of [
(bad: Game) => {
bad.response = null;
},
(bad: Game) => {
bad.response!.owner = 'q';
},
(bad: Game) => {
bad.response!.intent = 'other-event';
},
(bad: Game) => {
delete bad.nexusAdvisorHistory;
},
(bad: Game) => {
delete bad.nexusAdvisorLast;
},
(bad: Game) => {
bad.nexusAdvisorHistory![0].receipt.selections[0].count++;
},
(bad: Game) => {
bad.nexusAdvisorHistory![0].stage = 'completed';
},
(bad: Game) => {
nexusAdvisorPlayer(bad).moved++;
},
(bad: Game) => {
nexusAdvisorPlayer(bad).forces['arrakeen:10']++;
nexusAdvisorPlayer(bad).reserves--;
},
]) {
const bad = nexusReload(g);
mutate(bad);
const saved = JSON.stringify(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() => applyAction(bad, 'q', { type: 'passResponse' }));
assert.equal(JSON.stringify(bad), saved);
}
});
void test('a real Truthtrance interruption preserves the pending advisor batch and its original cancellation response', () => {
let g = nexusAdvisorFixture();
holdAdvisorKarama(g);
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
assert.ok(index >= 0);
const truth = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === 'r')!.hand.push(truth);
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
const response = structuredClone(g.response);
const record = structuredClone(g.nexusAdvisorHistory);
g = applyAction(g, 'r', { type: 'card', card: truth.id });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, 'r', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'p',
fact: { kind: 'hand', name: 'Shield' },
},
});
g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'no' });
assert.equal(g.truthtrance, null);
assert.deepEqual(g.response, response);
assert.deepEqual(g.nexusAdvisorHistory, record);
g = allowNexusAdvisorFlip(g);
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
assert.equal(g.discard.filter((card) => card.id === truth.id).length, 1);
nexusAdvisorInventory(g);
});
void test('Harkonnen private Nexus exchange cannot consume or alter a pending advisor conversion', () => {
let g = nexusAdvisorFixture({ opponentFaction: 'harkonnen' });
const cards = g.nexusCards!.cards!;
cards.deck = [
'harkonnen',
...cards.deck.filter((card) => card !== 'harkonnen'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
holdAdvisorKarama(g, 'r');
g = beginNexusAdvisorFlip(g, ['pasty_mesa']);
const response = structuredClone(g.response);
const record = structuredClone(g.nexusAdvisorHistory);
const offer = viewGame(g, 'q').nexusTraitors!.offer!;
assert.equal(offer.blocked, null);
g = applyAction(g, 'q', {
type: 'nexusTraitorDraw',
event: offer.event,
mode: offer.mode,
});
assert.deepEqual(g.nexusAdvisorHistory, record);
nexusReject(g, 'r', { type: 'passResponse' }, /private Nexus card return/);
const pending = g.nexusTraitorExchanges!.at(-1)!;
g = applyAction(normalizeAutomaticGame(nexusReload(g)), 'q', {
type: 'nexusTraitorReturn',
event: pending.event,
cards: [...pending.drawn],
});
assert.deepEqual(g.response, response);
assert.deepEqual(g.nexusAdvisorHistory, record);
g = allowNexusAdvisorFlip(g);
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
arrakeen: {},
carthag: {},
});
assert.equal(g.nexusTraitorExchanges!.length, 1);
assert.equal(g.nexusAdvisorHistory!.length, 1);
nexusAdvisorInventory(g);
});