347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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,
|
||
);
|
||
});
|