Files
Dune/tests/advisors.test.ts
T
2026-09-08 07:37:52 +10:00

546 lines
18 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
battles,
type Game,
} from '../game/engine';
import { isAdvisor, fighterCount } from '../game/advisors';
import { baseDeck, spiceDeck } from '../game/cards';
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
function setBattleHand(g: Game, id: string, ids: string[]) {
const owner = g.players.find((p) => p.id === id)!;
g.deck.push(...owner.hand);
owner.hand = [];
owner.hand = ids.map((cardId) => {
assert.ok(
!g.players.some((p) => p.hand.some((card) => card.id === cardId)),
`Fixture card ${cardId} is already held by another seat`,
);
const index = g.deck.findIndex((card) => card.id === cardId);
assert.ok(
index >= 0,
`Physical fixture card ${cardId} must be in the deck`,
);
return g.deck.splice(index, 1)[0];
});
}
function fixture() {
let g = createGame('ADVISOR2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.advanced = true;
g.phase = 5;
g.active = 'a';
g.order = ['a', 'b', 'h'];
g.movementRemaining = [...g.order];
g.storm = 18;
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
g.deck.push(...p.hand);
p.hand = [];
p.traitors = [];
}
return g;
}
function army(
g: Game,
id: string,
forces: Record<string, number>,
advisors: string[] = [],
) {
const p = g.players.find((p) => p.id === id)!;
p.forces = forces;
p.reserves = 20 - Object.values(forces).reduce((s, n) => s + n, 0);
p.advisors = Object.fromEntries(advisors.map((t) => [t, {}]));
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function cancel(g: Game) {
assert.ok(g.response);
assert.equal(g.players[0].hand[0].effect, 'karama');
return applyAction(g, 'a', {
type: 'card',
mode: 'cancel',
card: g.players[0].hand[0].id,
});
}
function move(
g: Game,
id: string,
from: string,
territory: string,
sector: number,
amount: number,
extra = {},
) {
return applyAction(g, id, {
type: 'move',
from,
territory,
sector,
amount,
...extra,
});
}
void test('advisors coexist without battle, blocking another faction entry or contesting a stronghold win', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 2, 'carthag:11': 1, 'sietch_tabr:14': 1 });
army(g, 'b', { 'arrakeen:10': 1 }, ['arrakeen']);
assert.deepEqual(battles(g), []);
g.active = 'h';
const shipped = applyAction(g, 'h', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
assert.equal(shipped.players[2].forces['arrakeen:10'], 1);
g.phase = 7;
g = ready(g);
assert.deepEqual(g.winner, ['a']);
});
void test('advisors collect no ground spice, stronghold income or ornithopter benefit', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 1, 'red_chasm:7': 2 }, [
'arrakeen',
'red_chasm',
]);
g.spice = { 'red_chasm:7': 8 };
g.active = 'h';
g.movementRemaining = ['h'];
g = applyAction(g, 'h', { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.equal(g.players[1].spice, 20);
assert.equal(g.players[0].spice, 25); // two city income + three collected
assert.equal(g.spice['red_chasm:7'], 5);
g.phase = 5;
g.active = 'b';
assert.throws(
() => move(g, 'b', 'red_chasm:7', 'shield_wall', 8, 1),
/more than 1/,
);
});
void test('advisor-only forces cannot play Family Atomics', () => {
const g = fixture();
army(g, 'a', { 'imperial_basin:10': 1 });
army(g, 'b', { 'imperial_basin:10': 1 }, ['imperial_basin']);
g.phase = 0;
const atomics = baseDeck().find((c) => c.effect === 'atomics')!;
g.players[1].hand = [atomics];
assert.throws(
() => applyAction(g, 'b', { type: 'card', card: atomics.id }),
/forces on or adjacent/,
);
});
void test('direct shipments match existing advisors, while a new direct shipment enters as fighters', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'carthag:11': 1 });
army(g, 'h', { 'arrakeen:10': 1 });
army(g, 'b', { 'arrakeen:10': 1 }, ['arrakeen']);
g.active = 'b';
const joined = applyAction(g, 'b', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(joined.players[1].forces['arrakeen:10'], 3);
assert.equal(isAdvisor(joined.players[1], 'arrakeen'), true);
g = applyAction(g, 'b', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
});
assert.equal(isAdvisor(g.players[1], 'carthag'), false);
assert.equal(fighterCount(g.players[1], 'carthag'), 2);
});
void test('moving fighters into existing advisors matches their stance, and the last outsider leaving automatically promotes advisors', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1 });
army(g, 'b', { 'arrakeen:10': 1, 'imperial_basin:10': 2 }, ['arrakeen']);
g.active = 'b';
g = move(g, 'b', 'imperial_basin:10', 'arrakeen', 10, 2);
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 0);
g.active = 'a';
g = move(g, 'a', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.equal(isAdvisor(g.players[1], 'arrakeen'), false);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 3);
});
void test('advisors moving to an empty territory become fighters; optional occupied-territory flips can be canceled after movement', () => {
const g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1 });
army(g, 'h', { 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.active = 'b';
const moved = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2, {
fighters: true,
});
assert.equal(moved.response?.kind, 'advisorFlip');
const canceled = cancel(moved);
assert.equal(isAdvisor(canceled.players[1], 'imperial_basin'), true);
assert.equal(canceled.players[1].moved, 1);
assert.equal(canceled.players[1].forces['arrakeen:10'], undefined);
assert.equal(isAdvisor(allow(moved).players[1], 'imperial_basin'), false);
g.players[2].forces = {};
const empty = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), false);
assert.equal(empty.response, null);
});
void test('intrusion resolves before free accompaniment, with a separate cancelable token flip and same-turn advisor lock', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'b', { 'arrakeen:10': 1 });
g = applyAction(g, 'a', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.decision?.kind, 'intrusion');
assert.throws(
() => applyAction(g, 'a', { type: 'endMovement' }),
/pending decision/,
);
g = applyAction(g, 'b', { type: 'decision', accept: true });
assert.equal(g.response?.kind, 'advisorFlip');
const denied = cancel(g);
assert.equal(fighterCount(denied.players[1], 'arrakeen'), 1);
assert.equal(denied.decision?.kind, 'advisor');
g = allow(g);
assert.equal(g.decision?.kind, 'advisor');
g = allow(
applyAction(g, 'b', { type: 'decision', accept: true, accompany: true }),
);
assert.equal(g.players[1].forces['arrakeen:10'], 2);
assert.equal(g.players[1].advisors!.arrakeen.lockedTurn, 1);
assert.equal(g.players[1].reserves, 18);
assert.equal(viewGame(g, 'h').players[1].advisors!.arrakeen.lockedTurn, 1);
});
void test('ordinary movement and repeated entry both trigger intrusion; declining does not consume a later intrusion', () => {
let g = fixture();
army(g, 'b', { 'arrakeen:10': 2 });
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 2 });
g = move(g, 'a', 'imperial_basin:10', 'arrakeen', 10, 1);
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'b', { type: 'decision', accept: false });
g.active = 'h';
g = applyAction(g, 'h', {
type: 'ship',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
g = applyAction(g, 'b', { type: 'decision', accept: false });
// Use the existing opponent again; a third fighter faction is correctly blocked.
g.active = 'a';
g.players[0].moved = 0;
g = move(g, 'a', 'imperial_basin:10', 'arrakeen', 10, 1);
assert.equal(g.decision?.kind, 'intrusion');
});
void test('pre-shipment declaration excludes allies and storm-locked battles, and each accepted territory has a cancellation window', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1, 'carthag:11': 1 });
army(g, 'b', { 'arrakeen:10': 3, 'carthag:11': 2 }, ['arrakeen', 'carthag']);
g.storm = 11;
g.phase = 4;
g = allow(ready(g));
assert.deepEqual(g.decision, {
kind: 'advisorBattle',
player: 'b',
territories: ['arrakeen'],
});
assert.throws(
() =>
applyAction(g, 'b', {
type: 'decision',
accept: true,
territory: 'carthag',
}),
/cannot prepare/,
);
g = applyAction(g, 'b', {
type: 'decision',
accept: true,
territory: 'arrakeen',
});
g = cancel(g);
assert.equal(g.decision, null);
assert.equal(g.active, 'a');
assert.equal(isAdvisor(g.players[1], 'arrakeen'), true);
g.phase = 4;
g.ready = [];
g.players[1].ally = 'a';
g.players[0].ally = 'b';
g = allow(ready(g));
assert.equal(g.decision, null);
});
void test('a lasgun/shield explosion kills bystanding advisors but ordinary battles leave them untouched', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 2 });
army(g, 'h', { 'arrakeen:10': 2 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.phase = 6;
g = allow(
applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'h',
}),
);
g = applyAction(g, 'a', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'decision', decline: true });
const shield = baseDeck().find((c) => c.kind === 'shield')!;
const lasgun = baseDeck().find((c) => c.kind === 'lasgun')!;
setBattleHand(g, 'a', [shield.id]);
setBattleHand(g, 'h', [lasgun.id]);
const prepared = structuredClone(g);
for (const explosion of [false, true]) {
let fight = applyAction(prepared, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
defense: explosion ? shield.id : null,
});
fight = applyAction(fight, 'h', {
type: 'battlePlan',
dial: 0,
leader: 'harkonnen-0',
weapon: explosion ? lasgun.id : null,
});
fight = applyAction(fight, 'a', { type: 'traitorCall', call: false });
fight = applyAction(fight, 'h', { type: 'traitorCall', call: false });
assert.equal(fight.players[1].tanks, explosion ? 2 : 0);
assert.equal(
fight.players[1].forces['arrakeen:10'] ?? 0,
explosion ? 0 : 2,
);
}
});
void test('new advisors cannot flip into occupied fighters during their arrival turn, and the lock survives a move', () => {
let g = fixture();
army(g, 'a', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
army(g, 'b', { 'arrakeen:10': 2 }, ['arrakeen']);
g.players[1].advisors!.arrakeen.lockedTurn = g.turn;
g.active = 'b';
const before = structuredClone(g);
assert.throws(
() =>
move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1, { fighters: true }),
/cannot flip/,
);
assert.deepEqual(g, before);
g = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 1);
assert.equal(g.players[1].advisors!.imperial_basin.lockedTurn, g.turn);
const joined = structuredClone(before);
joined.players[1].forces['imperial_basin:10'] = 1;
assert.throws(
() => move(joined, 'b', 'arrakeen:10', 'imperial_basin', 10, 1),
/cannot become fighters/,
);
});
void test('advisor setup waits for Fremen, follows their placement, and becomes fighters only when alone', () => {
for (const occupied of [false, true]) {
let g = createGame('ADSETUP2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('f', 'Fremen', 'fremen'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
g.advanced = true;
g = applyAction(g, 'b', { type: 'predict', faction: 'atreides', turn: 3 });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.storm = 18;
army(g, 'a', occupied ? { 'arrakeen:10': 1 } : {});
army(g, 'h', {});
assert.equal(g.setupStage, 'forces');
const before = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'b', {
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
}),
/Wait for the current setup step: forces/,
);
assert.deepEqual(g, before);
g = applyAction(g, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(g.status, 'setup');
g = applyAction(g, 'b', {
type: 'advisorSetup',
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.status, 'playing');
assert.equal(g.players[1].reserves, 19);
assert.equal(g.players[1].forces['arrakeen:10'], 1);
assert.equal(isAdvisor(g.players[1], 'arrakeen'), occupied);
}
});
void test('worm-ride intrusion completes before the spice sequence proceeds, including a canceled flip', () => {
let g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
g.players[2].faction = 'fremen';
army(g, 'h', { 'red_chasm:7': 2 });
army(g, 'b', { 'imperial_basin:10': 2 });
g.phase = 1;
g.turn = 2;
g.nexus = true;
g.decision = { kind: 'wormRide', player: 'h', territory: 'red_chasm' };
g = applyAction(g, 'h', {
type: 'decision',
accept: true,
territory: 'imperial_basin',
sector: 10,
forces: { 'red_chasm:7': 2 },
});
assert.equal(g.phase, 1);
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'b', { type: 'decision', accept: true });
assert.equal(g.response?.advisorResume, 'wormRide');
const canceled = cancel(g);
assert.equal(canceled.phase, 2);
assert.equal(canceled.nexus, false);
assert.equal(fighterCount(canceled.players[1], 'imperial_basin'), 2);
const accepted = allow(g);
assert.equal(accepted.phase, 2);
assert.equal(isAdvisor(accepted.players[1], 'imperial_basin'), true);
});
void test('advisors die in storm and obsolete stance records are removed without altering force conservation', () => {
let g = fixture();
army(g, 'a', { 'red_chasm:7': 2 });
army(g, 'b', { 'red_chasm:7': 3 }, ['red_chasm']);
g.phase = 0;
g.storm = 6;
g.stormPending = 1;
g = ready(g);
assert.equal(g.players[1].tanks, 3);
assert.equal(g.players[1].advisors!.red_chasm, undefined);
assert.equal(g.players[1].reserves + g.players[1].tanks, 20);
assert.equal(g.players[0].tanks, 2);
});
void test('Guild cross-shipping matches existing advisors and preserves their arrival lock', () => {
let g = fixture();
g.players[2].faction = 'guild';
g.players[2].ally = 'b';
g.players[1].ally = 'h';
army(g, 'a', { 'arrakeen:10': 1, 'red_chasm:7': 1 });
army(g, 'b', { 'arrakeen:10': 2, 'red_chasm:7': 1 }, [
'arrakeen',
'red_chasm',
]);
g.players[1].advisors!.arrakeen.lockedTurn = g.turn;
g.active = 'b';
g = allow(
applyAction(g, 'b', {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'red_chasm',
sector: 7,
amount: 2,
}),
);
assert.equal(g.players[1].advisors!.red_chasm.lockedTurn, g.turn);
assert.equal(g.players[1].advisors!.arrakeen, undefined);
assert.equal(g.players[1].forces['red_chasm:7'], 3);
assert.deepEqual(
viewGame(JSON.parse(JSON.stringify(g)), 'a').players[1].advisors,
viewGame(g, 'a').players[1].advisors,
);
});
void test('accompanying advisors may select a valid sector of the shipment territory, with no reserve loss on a rejected choice', () => {
let g = fixture();
g = applyAction(g, 'a', {
type: 'ship',
territory: 'imperial_basin',
sector: 10,
amount: 1,
});
assert.equal(g.decision?.kind, 'advisor');
const before = structuredClone(g);
assert.throws(
() =>
applyAction(g, 'b', {
type: 'decision',
accept: true,
accompany: true,
sector: 17,
}),
/sector belonging/,
);
assert.deepEqual(g, before);
g = allow(
applyAction(g, 'b', {
type: 'decision',
accept: true,
accompany: true,
sector: 11,
}),
);
assert.equal(g.players[1].forces['imperial_basin:11'], 1);
assert.equal(g.players[1].reserves, 19);
assert.equal(isAdvisor(g.players[1], 'imperial_basin'), true);
});
void test('worms devour peaceful advisors and clean their territory stance', () => {
let g = fixture();
const land = spiceDeck().filter((c) => 'territory' in c);
const worm = spiceDeck().find((c) => 'worm' in c)!;
const target = land[0];
const key = `${target.territory}:${target.sector}`;
army(g, 'a', { [key]: 1 });
army(g, 'b', { [key]: 2 }, [target.territory]);
g.phase = 1;
g.turn = 2;
g.spiceDiscard = [[target], []];
g.spiceDeck = [worm, land[1]];
g = ready(g);
assert.equal(g.players[1].tanks, 2);
assert.equal(g.players[1].advisors![target.territory], undefined);
assert.equal(g.players[1].reserves, 18);
});