Files
Dune/tests/ecaz-alliance-engine.test.ts

550 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { acquireDuke, createDukeVidal } from '../game/duke-vidal';
import { richeseCards } from '../game/richese-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const json = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(advanced = false, entrant: FactionId = 'emperor') {
const g = createGame('ECAZALLY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, [
'ecaz',
]);
g.players.push(
newPlayer('in', 'Entrant', entrant),
newPlayer('a', 'Other', 'atreides'),
newPlayer('bg', 'Predictor', 'beneGesserit'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'a', 'bg'],
movementRemaining: ['in', 'ec', 'a', 'bg'],
deck: baseDeck(),
dukeVidal: createDukeVidal(),
ready: [],
});
for (const p of g.players)
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
hand: [],
traitors: [],
traitorChoices: [],
});
// No reserve advisor option interferes with the tested original shipment.
seat(g, 'bg').reserves = 0;
seat(g, 'bg').tanks = 20;
seat(g, 'bg').prediction = { faction: entrant, turn: 7 };
const state = createAmbassadors(() => 0);
const token = state.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(state, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
return g;
}
function enter(g = fixture()) {
return applyAction(g, 'in', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
}
function proposal(g: Game): Action {
return {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
choice: 'alliance',
beneficiary: 'ec',
};
}
function propose(g: Game) {
return applyAction(g, 'ec', proposal(g));
}
function answer(g: Game, accept: boolean) {
let done = applyAction(json(g), 'in', {
type: 'decision',
event: g.pendingAmbassador!.event,
accept,
});
// These scenarios exercise alliance-only continuation, declining the optional loan.
if (done.pendingAmbassador?.stage === 'loan')
done = applyAction(done, 'ec', {
type: 'decision', event: done.pendingAmbassador.event, loan: false,
});
return done;
}
function reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function conserved(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0),
20,
);
const cards = [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...g.players.flatMap((p) => p.hand),
].map((c) => c.id);
assert.equal(new Set(cards).size, cards.length);
}
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced grants the available Duke' : 'Basic can decline the loan'} while bilateral Ecaz alliance resumes the paid entrant`, () => {
const initial = fixture(advanced);
const token = initial.ecazAmbassadors!.tokens.find(
(t) => t.effect === 'ecaz',
)!.id;
const duke = json(initial).dukeVidal;
const entered = enter(initial);
assert.equal(entered.decision?.player, 'ec');
assert.equal(
viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
null,
);
const before = json(entered);
const offered = propose(entered);
assert.deepEqual(entered, before);
assert.equal(offered.pendingAmbassador?.stage, 'allianceReply');
assert.equal(offered.pendingAmbassador?.beneficiary, 'in');
assert.deepEqual(offered.decision, {
kind: 'ecazAmbassador',
player: 'in',
});
assert.equal(seat(offered, 'ec').ally, null);
assert.equal(
offered.ecazAmbassadors!.tokens.find((t) => t.id === token)!.zone,
'supply',
);
assert.deepEqual(
offered.ecazAmbassadors!.cohort,
initial.ecazAmbassadors!.cohort,
);
const done = answer(normalizeAutomaticGame(json(offered)), true);
assert.equal(seat(done, 'ec').ally, 'in');
assert.equal(seat(done, 'in').ally, 'ec');
assert.equal(seat(done, 'ec').allySinceTurn, 2);
assert.equal(seat(done, 'in').allySinceTurn, 2);
if (advanced) {
assert.equal(done.dukeVidal!.controller, 'in');
assert.equal(done.dukeVidal!.source, 'ally');
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
} else assert.deepEqual(done.dukeVidal, duke);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.decision, null);
assert.equal(done.active, 'in');
assert.equal(done.phase, 5);
assert.equal(seat(done, 'in').shipped, true);
assert.equal(seat(done, 'in').spice, 18);
assert.equal(seat(done, 'in').moved, 0);
assert.deepEqual(seat(done, 'in').forces, { 'arrakeen:10': 2 });
assert.deepEqual(normalizeAutomaticGame(json(done)), json(done));
reject(done, 'in', {
type: 'decision',
event: offered.pendingAmbassador!.event,
accept: true,
});
conserved(done);
});
void test('refusal consumes the chosen reusable trigger but does not form an alliance or fall back to Duke acquisition', () => {
const entered = enter();
const done = answer(propose(entered), false);
assert.equal(seat(done, 'ec').ally, null);
assert.equal(seat(done, 'in').ally, null);
assert.deepEqual(done.dukeVidal, entered.dukeVidal);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
'supply',
);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.active, 'in');
const untouched = applyAction(entered, 'ec', {
type: 'decision',
event: entered.pendingAmbassador!.event,
decline: true,
});
assert.equal(
untouched.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
'placed',
);
});
void test('only Ecaz proposes and only the original entrant answers the exact event with a boolean', () => {
const g = enter();
reject(g, 'in', proposal(g));
reject(g, 'ec', { ...proposal(g), event: 'stale' });
reject(g, 'ec', { ...proposal(g), beneficiary: 'in' });
const offered = propose(g);
for (const id of ['ec', 'a', 'bg'])
reject(offered, id, {
type: 'decision',
event: offered.pendingAmbassador!.event,
accept: true,
});
for (const accept of [undefined, 'yes', 1])
reject(offered, 'in', {
type: 'decision',
event: offered.pendingAmbassador!.event,
accept,
});
reject(offered, 'in', { type: 'decision', event: 'stale', accept: true });
const changed = json(offered);
changed.turn++;
reject(changed, 'in', {
type: 'decision',
event: offered.pendingAmbassador!.event,
accept: true,
});
});
for (const already of ['ec', 'in'])
void test(`existing ${already === 'ec' ? 'Ecaz' : 'entrant'} alliance cannot be replaced by this Ambassador`, () => {
const g = fixture();
seat(g, already).ally = 'a';
seat(g, 'a').ally = already;
const entered = enter(g);
assert.ok(viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked);
reject(entered, 'ec', proposal(entered));
assert.equal(
entered.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
'placed',
);
});
void test('acceptance clears obsolete offers involving the new pair and leaves unrelated offers and private prediction intact', () => {
const g = fixture();
g.allianceOffers = { ec: 'a', a: 'ec', in: 'bg', bg: 'a' };
g.ready = ['a'];
const prediction = json(g).players.find((p) => p.id === 'bg')!.prediction;
const done = answer(propose(enter(g)), true);
assert.deepEqual(done.allianceOffers, { bg: 'a' });
assert.deepEqual(done.ready, []);
assert.deepEqual(seat(done, 'bg').prediction, prediction);
for (const id of ['ec', 'in', 'a'])
assert.equal(
viewGame(done, id).players.find((p) => p.id === 'bg')!.prediction,
undefined,
);
});
void test('Advanced new-ally Duke grant transfers available shared custody while a dead Duke never blocks the alliance', () => {
for (const dead of [false, true]) {
const g = fixture(true);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'a', 1, 'moritani');
if (dead) {
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
}
const duke = json(g).dukeVidal;
const entered = enter(g);
assert.equal(
viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
null,
);
const done = answer(propose(entered), true);
if (dead) assert.deepEqual(done.dukeVidal, duke);
else {
assert.equal(done.dukeVidal!.controller, 'in');
assert.deepEqual(done.dukeVidal!.leader, duke!.leader);
}
}
const g = fixture(true);
g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
const entered = enter(g);
assert.ok(viewGame(entered, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
assert.equal(
viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
null,
);
assert.equal(seat(answer(propose(entered), true), 'ec').ally, 'in');
});
void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => {
const g = enter();
// Exchange custody between hidden hands without changing the public draw count.
seat(g, 'bg').hand.push(g.deck.shift()!);
const changed = json(g);
seat(changed, 'bg').prediction = { faction: 'atreides', turn: 9 };
seat(changed, 'a').hand.push(seat(changed, 'bg').hand.pop()!);
assert.deepEqual(g.deck, changed.deck);
assert.deepEqual(g.players.flatMap(p => p.hand).map(c => c.id).sort(), changed.players.flatMap(p => p.hand).map(c => c.id).sort());
assert.deepEqual(viewGame(g, 'ec'), viewGame(changed, 'ec'));
const offered = propose(g);
assert.equal(viewGame(offered, 'a').ambassadorEntry!.allianceOffer, null);
assert.deepEqual(viewGame(offered, 'a').decision, {
kind: 'ecazAmbassador',
player: 'in',
});
});
for (const accept of [false, true])
void test(`actual worm ride resumes its next ride once after alliance ${accept ? 'acceptance' : 'refusal'}`, () => {
const g = fixture(true, 'fremen');
Object.assign(g, {
phase: 1,
active: null,
nexus: false,
wormRides: ['hagga_basin'],
decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
});
seat(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:11': 1 };
seat(g, 'in').reserves = 16;
const arrived = applyAction(g, 'in', {
type: 'decision',
accept: true,
territory: 'arrakeen',
sector: 10,
forces: { 'imperial_basin:10': 2 },
});
assert.equal(arrived.pendingAmbassador!.resume, 'wormRide');
const done = answer(propose(arrived), accept);
assert.equal(done.phase, 1);
assert.deepEqual(done.decision, {
kind: 'wormRide',
player: 'in',
territory: 'hagga_basin',
});
assert.deepEqual(done.wormRides, []);
assert.deepEqual(seat(done, 'in').forces, seat(arrived, 'in').forces);
assert.equal(seat(done, 'in').ally, accept ? 'ec' : null);
conserved(done);
});
void test('a genuine paid Box suspends the entrant reply and restores it after JSON without repeating the token trigger', () => {
const g = fixture();
g.richeseCache = richeseCards();
const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox');
const box = g.richeseCache.splice(at, 1)[0];
seat(g, 'in').hand.push(box);
for (const name of ['Shield', 'Maula Pistol']) {
const i = g.deck.findIndex((c) => c.name === name);
g.discard.push(g.deck.splice(i, 1)[0]);
}
const offered = propose(enter(g));
let saved = applyAction(offered, 'in', { type: 'card', card: box.id });
assert.equal(saved.decision?.kind, 'nullentropy');
assert.equal(saved.pendingNullentropy!.resume.decision?.player, 'in');
assert.deepEqual(saved.pendingAmbassador, offered.pendingAmbassador);
saved = normalizeAutomaticGame(json(saved));
saved = applyAction(saved, 'in', {
type: 'decision',
event: saved.pendingNullentropy!.event,
card: saved.discard[0].id,
});
assert.equal(saved.decision?.kind, 'ecazAmbassador');
const done = answer(saved, true);
assert.equal(seat(done, 'in').spice, 16);
assert.equal(done.discard.filter((c) => c.id === box.id).length, 1);
assert.deepEqual(done.ecazAmbassadors, offered.ecazAmbassadors);
conserved(done);
});
for (const level of DIFFICULTIES)
void test(`${level} proposes from the authoritative offer and accepts its owned reply legally`, () => {
const g = enter();
const owner = viewGame(g, 'ec');
owner.players.find((p) => p.id === 'ec')!.bot = level;
const action = botActions(owner)[0];
assert.equal(action.choice, 'alliance');
const offered = applyAction(g, 'ec', action);
const entrant = viewGame(offered, 'in');
entrant.players.find((p) => p.id === 'in')!.bot = level;
const reply = botActions(entrant)[0];
assert.equal(reply.accept, true);
const done = applyAction(offered, 'in', reply);
assert.equal(seat(done, 'ec').ally, 'in');
assert.equal(done.pendingAmbassador?.stage, 'loan');
});
void test('saved alliance replies reject missing events, corrupted token custody and changed entrant bindings before any answer', () => {
const offered = propose(enter());
const changes: [string, (g: Game) => void][] = [
[
'empty event',
(g) => {
g.pendingAmbassador!.event = '';
},
],
[
'missing event',
(g) => {
Reflect.deleteProperty(g.pendingAmbassador!, 'event');
},
],
[
'missing entry',
(g) => {
g.pendingAmbassador = null;
},
],
[
'missing decision',
(g) => {
g.decision = null;
},
],
[
'foreign decision owner',
(g) => {
g.decision!.player = 'a';
},
],
[
'changed entrant',
(g) => {
g.pendingAmbassador!.entrant = 'a';
},
],
[
'foreign Ecaz owner',
(g) => {
g.pendingAmbassador!.owner = 'a';
},
],
[
'missing token',
(g) => {
g.pendingAmbassador!.token = 'missing';
},
],
[
'returned token replaced on board',
(g) => {
const token = g.ecazAmbassadors!.tokens.find(
(t) => t.effect === 'ecaz',
)!;
token.zone = 'placed';
token.location = 'arrakeen';
},
],
[
'returned token retains location',
(g) => {
g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.location =
'arrakeen';
},
],
[
'foreign token effect',
(g) => {
g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.effect =
'guild';
},
],
[
'crossed phase-five worm continuation',
(g) => {
g.pendingAmbassador!.resume = 'wormRide';
},
],
];
for (const [name, change] of changes) {
const malformed = json(offered);
change(malformed);
const before = structuredClone(malformed);
assert.throws(() => normalizeAutomaticGame(malformed), name);
assert.deepEqual(
malformed,
before,
`${name}: normalization must not mutate`,
);
reject(malformed, 'in', {
type: 'decision',
event: offered.pendingAmbassador!.event,
accept: true,
});
}
});
void test('a real Box-suspended alliance reply cannot outlive its entry or restore a different entrant', () => {
const g = fixture();
g.richeseCache = richeseCards();
const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox');
const box = g.richeseCache.splice(at, 1)[0];
seat(g, 'in').hand.push(box);
for (const name of ['Shield', 'Maula Pistol']) {
const index = g.deck.findIndex((c) => c.name === name);
g.discard.push(g.deck.splice(index, 1)[0]);
}
const saved = applyAction(propose(enter(g)), 'in', {
type: 'card',
card: box.id,
});
assert.equal(
saved.pendingNullentropy!.resume.decision!.kind,
'ecazAmbassador',
);
assert.deepEqual(normalizeAutomaticGame(json(saved)), json(saved));
const changes: [string, (g: Game) => void][] = [
[
'missing suspended parent',
(g) => {
g.pendingAmbassador = null;
},
],
[
'missing suspended event',
(g) => {
Reflect.deleteProperty(g.pendingAmbassador!, 'event');
},
],
[
'missing suspended decision',
(g) => {
g.pendingNullentropy!.resume.decision = null;
},
],
[
'foreign suspended decision owner',
(g) => {
g.pendingNullentropy!.resume.decision!.player = 'a';
},
],
];
for (const [name, change] of changes) {
const malformed = json(saved);
change(malformed);
const before = structuredClone(malformed);
assert.throws(() => normalizeAutomaticGame(malformed), name);
assert.deepEqual(malformed, before);
reject(malformed, 'in', {
type: 'decision',
event: saved.pendingNullentropy!.event,
card: saved.discard[0].id,
});
}
});