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

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const karmas = baseDeck().filter((c) => c.effect === 'karama');
function fixture() {
let g = createGame('GUILDST2', newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'e', { type: 'start' });
g = applyAction(g, 'b', { type: 'predict', faction: 'emperor', 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 = 'e';
g.order = ['e', 'g', 'b'];
g.movementRemaining = [...g.order];
g.storm = 18;
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
p.hand = [];
}
g.players[0].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
g.players[1].hand = [karmas[0]];
g.discard = [];
return g;
}
const declaration = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 4,
elite: 2,
};
const stop = (g: Game) =>
applyAction(g, 'g', { type: 'card', mode: 'special', card: karmas[0].id });
const permit = (g: Game) =>
applyAction(g, 'g', { type: 'decision', allow: true });
function responses(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;
}
void test('Guild decision precedes all payment, elite placement, advisor and intrusion effects', () => {
const before = fixture();
before.players[2].forces = { 'arrakeen:10': 1 };
before.players[2].reserves = 19;
const g = applyAction(before, 'e', declaration);
assert.deepEqual(g.players, before.players);
assert.equal(g.response, null);
assert.deepEqual(g.decision, {
kind: 'guildShipment',
player: 'g',
shipper: 'e',
territory: 'arrakeen',
sector: 10,
amount: 4,
});
assert.equal(g.active, 'e');
assert.equal(g.pendingShipment?.elite, 2);
assert.equal(before.pendingShipment, undefined);
});
void test('stopping retains forces, elite tokens, spice and allied credit without Guild income or advisor followup', () => {
const before = fixture();
before.players[0].ally = 'b';
before.players[2].ally = 'e';
before.aid.b = { recipient: 'e', amount: 3 };
const pending = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
const g = stop(pending);
assert.equal(g.players[0].spice, 20);
assert.deepEqual(g.players[0].forces, {});
assert.deepEqual(g.players[0].elites, before.players[0].elites);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].shipped, true);
assert.equal(g.players[1].specialKaramaUsed, true);
assert.equal(g.players[1].spice, 20);
assert.equal(g.players[2].reserves, 20);
assert.deepEqual(g.aid, before.aid);
assert.equal(g.pendingShipment, null);
assert.equal(g.decision, null);
assert.equal(g.response, null);
assert.equal(g.active, 'e');
assert.equal(g.discard.filter((c) => c.id === karmas[0].id).length, 1);
assert.throws(() => applyAction(g, 'e', declaration), /once/);
});
void test('a stopped shipment leaves ordinary movement available and cannot be countered by normal Karama', () => {
const before = fixture();
before.players[0].forces = { 'arrakeen:10': 3 };
before.players[0].reserves = 17;
let g = stop(
applyAction(before, 'e', {
...declaration,
territory: 'carthag',
sector: 11,
}),
);
g.players[0].hand = [karmas[1]];
assert.throws(() =>
applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karmas[1].id }),
);
g = applyAction(g, 'e', {
type: 'move',
from: 'arrakeen:10',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
assert.equal(g.players[0].forces['polar_sink:0'], 1);
});
void test('allowing commits once and resumes Guild income before Bene Gesserit accompaniment', () => {
const initial = fixture();
initial.players[0].hand = [karmas[1]];
let g = permit(applyAction(initial, 'e', declaration));
assert.equal(g.pendingShipment, null);
assert.equal(g.players[0].spice, 16);
assert.equal(g.players[0].reserves, 16);
assert.equal(g.players[0].forces['arrakeen:10'], 4);
assert.equal(g.players[0].elites?.forces['arrakeen:10'], 2);
assert.equal(g.players[0].elites?.reserves, 3);
assert.equal(g.response?.kind, 'guildIncome');
assert.equal(g.players[1].specialKaramaUsed, undefined);
assert.equal(g.decision?.kind, 'advisor');
assert.throws(() => permit(g));
g = responses(g);
assert.equal(g.players[1].spice, 24);
g = applyAction(g, 'b', { type: 'decision', accept: false });
assert.equal(g.decision, null);
});
void test('allowing Guild-funded allied shipment pays only the shipper’s share to Guild', () => {
const before = fixture();
before.players[0].ally = 'g';
before.players[1].ally = 'e';
before.aid.g = { recipient: 'e', amount: 2 };
let g = permit(applyAction(before, 'e', { ...declaration, allyPayment: 1 }));
assert.equal(g.players[0].spice, 19);
assert.equal(g.aid.g.amount, 1);
assert.equal(g.response, null);
g = responses(g);
assert.equal(g.players[1].spice, 21);
});
void test('shipment rate card returns to its original owner on stop, including another player’s gift', () => {
const before = fixture();
before.players[2].hand = [karmas[1]];
const benefit = applyAction(before, 'b', {
type: 'card',
mode: 'shipment',
target: 'e',
card: karmas[1].id,
});
const pending = applyAction(benefit, 'e', declaration);
assert.equal(pending.pendingShipment?.cost, 2);
const g = stop(pending);
assert.deepEqual(g.players[2].hand, [karmas[1]]);
assert.equal(
g.discard.some((c) => c.id === karmas[1].id),
false,
);
assert.equal(g.karamaShipping, null);
const allowed = permit(pending);
assert.equal(allowed.players[0].spice, 18);
assert.equal(allowed.response, null);
assert.equal(allowed.players[2].hand.length, 0);
assert.equal(allowed.discard.filter((c) => c.id === karmas[1].id).length, 1);
});
void test('hidden Guild cards cannot change the offered decision or expose pending funding details', () => {
const before = fixture();
before.players[0].ally = 'b';
before.players[2].ally = 'e';
before.aid.b = { recipient: 'e', amount: 3 };
const pending = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
before.players[1].hand = [];
const empty = applyAction(before, 'e', { ...declaration, allyPayment: 3 });
assert.deepEqual(empty.decision, pending.decision);
const view = viewGame(JSON.parse(JSON.stringify(pending)), 'g');
assert.equal('pendingShipment' in view, false);
assert.equal('allyPayment' in view.decision!, false);
assert.equal(view.players[0].hand, undefined);
assert.throws(() => stop(empty), /Karama card/);
assert.ok(permit(empty).players[0].shipped);
});
void test('invalid shipments and forged bypasses cannot spend the Guild power or alter state', () => {
const before = fixture();
const copy = structuredClone(before);
for (const extra of [
{ amount: 21 },
{ elite: 5 },
{ sector: 18 },
{ allyPayment: 1 },
])
assert.throws(() => applyAction(before, 'e', { ...declaration, ...extra }));
assert.deepEqual(before, copy);
assert.throws(() => stop(before), /declaration/);
const pending = applyAction(before, 'e', {
...declaration,
allow: true,
skipGuild: true,
});
assert.equal(pending.decision?.kind, 'guildShipment');
for (const [id, action] of [
['e', { type: 'decision', allow: true }],
['g', { type: 'decision', allow: false }],
['e', { type: 'endMovement' }],
['g', { type: 'card', mode: 'cancel', card: karmas[0].id }],
] as const)
assert.throws(() => applyAction(pending, id, action));
assert.equal(pending.players[0].shipped, false);
});
void test('the special power remains used on later turns, while ordinary Karama remains available', () => {
let g = stop(applyAction(fixture(), 'e', declaration));
g.turn++;
g.players[0].shipped = false;
g.players[1].hand = [karmas[1]];
g = applyAction(g, 'g', {
type: 'card',
mode: 'shipment',
card: karmas[1].id,
target: 'e',
});
g = applyAction(g, 'e', declaration);
assert.equal(g.decision?.kind, 'advisor');
assert.equal(g.players[0].forces['arrakeen:10'], 4);
assert.equal(g.players[1].specialKaramaUsed, true);
assert.throws(() => stop(g), /already been used/);
});
void test('basic play and Fremen southern reinforcements do not open Guild special decisions', () => {
let g = fixture();
g.advanced = false;
const shipped = applyAction(g, 'e', declaration);
assert.equal(shipped.pendingShipment, undefined);
assert.equal(shipped.response, null);
assert.equal(shipped.players[1].spice, 24);
g = fixture();
g.players[0].faction = 'fremen';
g.players[0].elites = undefined;
g = applyAction(g, 'e', {
type: 'ship',
territory: 'sietch_tabr',
sector: 14,
amount: 3,
});
assert.equal(g.decision, null);
assert.equal(g.pendingShipment, undefined);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].forces['sietch_tabr:14'], 3);
});
void test('cross-planet shipments bypass the off-planet stop decision', () => {
const before = fixture();
before.players[0].ally = 'g';
before.players[1].ally = 'e';
before.players[0].forces = { 'arrakeen:10': 3 };
before.players[0].reserves = 17;
const g = applyAction(before, 'e', {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'polar_sink',
sector: 0,
amount: 2,
});
assert.notEqual(g.decision?.kind, 'guildShipment');
assert.equal(g.players[0].forces['polar_sink:0'], 2);
assert.equal(g.players[1].specialKaramaUsed, undefined);
});
void test('AI resolves the shipment window from its own hand at every difficulty, protecting allies', () => {
for (const difficulty of DIFFICULTIES) {
const before = fixture();
before.players[1].bot = difficulty;
const pending = applyAction(before, 'e', declaration);
const actions = botActions(viewGame(pending, 'g'));
assert.ok(actions.length > 0, difficulty);
const resolved = applyAction(pending, 'g', actions[0]);
assert.equal(resolved.pendingShipment, null);
const allied = structuredClone(pending);
allied.players[1].ally = 'e';
assert.deepEqual(botActions(viewGame(allied, 'g'))[0], {
type: 'decision',
allow: true,
});
const empty = structuredClone(pending);
empty.players[1].hand = [];
assert.deepEqual(botActions(viewGame(empty, 'g'))[0], {
type: 'decision',
allow: true,
});
}
});
void test('a persisted allowed shipment resumes intrusion before accompanying advisor choice', () => {
const before = fixture();
before.players[0].hand = [karmas[1]];
before.players[2].forces = { 'arrakeen:10': 1 };
before.players[2].reserves = 19;
const pending = applyAction(before, 'e', declaration);
let g = permit(JSON.parse(JSON.stringify(pending)));
assert.equal(g.response?.kind, 'guildIncome');
assert.equal(g.decision?.kind, 'intrusion');
g = responses(g);
g = applyAction(g, 'b', { type: 'decision', accept: true });
assert.equal(g.response?.kind, 'advisorFlip');
g = responses(g);
assert.equal(g.decision?.kind, 'advisor');
assert.ok(g.players[2].advisors?.arrakeen);
assert.equal(g.players[0].forces['arrakeen:10'], 4);
assert.equal(g.players[1].spice, 24);
});
void test('a converted Worthless shipment-rate card is restored only after its conversion was allowed', () => {
const before = fixture();
const worthless = baseDeck().find((c) => c.kind === 'worthless')!;
before.players[2].hand = [worthless];
let g = applyAction(before, 'b', {
type: 'card',
mode: 'shipment',
card: worthless.id,
target: 'e',
});
assert.equal(g.response?.kind, 'worthlessKarama');
assert.throws(() => applyAction(g, 'e', declaration), /response window/);
g = responses(g);
g = stop(applyAction(g, 'e', declaration));
assert.deepEqual(g.players[2].hand, [worthless]);
assert.equal(g.pendingKarama, null);
assert.equal(
g.discard.some((c) => c.id === worthless.id),
false,
);
});