Files
Dune/tests/ambassador-terror-overlap-engine.test.ts

251 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
type Owner = 'ambassador' | 'terror';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
const movement: Action = {
type: 'move',
from: 'imperial_basin:10',
territory: 'arrakeen',
sector: 10,
amount: 2,
};
/** Both inventories are physical, legally placed in the same stronghold. No optional module competes for arrival. */
function table(first: Owner, advanced: boolean, cause: 'shipment' | 'movement' = 'shipment') {
const g = createGame('OVERLAP', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Entrant', 'harkonnen'));
Object.assign(g, {
status: 'playing', phase: 5, turn: 3, storm: 18, active: 'in',
order: first === 'ambassador' ? ['ec', 'm', 'in'] : ['m', 'ec', 'in'],
deck: baseDeck(),
});
g.movementRemaining = [...g.order];
for (const player of g.players) {
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
}
if (cause === 'movement') {
Object.assign(seat(g, 'in'), {
shipped: true,
forces: { 'imperial_basin:10': 3 },
reserves: 17,
});
}
const ambassadors = createAmbassadors(() => 0.2);
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
ambassadors.cohort = [
emperor.id,
...ambassadors.tokens.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
.slice(0, 4).map((token) => token.id),
];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
token.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
turn: 2,
availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = createTerrorState(() => 0.2);
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
return { g, ambassadorToken: emperor.id, terrorToken: robbery.id };
}
function unchangedRejection(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function overlap(g: Game, first: Owner, active: Owner) {
const pending = g.pendingArrivalOverlap;
assert.ok(pending);
assert.equal(pending.first, first);
assert.equal(pending.stage, first === active ? 'first' : 'second');
assert.equal(pending.entrant, 'in');
assert.equal(pending.territory, 'arrakeen');
assert.equal(pending.sector, 10);
assert.equal(pending.amount, 2);
assert.equal(pending.elite, 0);
assert.equal(pending.turn, 3);
assert.equal(pending.phase, 5);
assert.equal(pending.ecaz, 'ec');
assert.equal(pending.moritani, 'm');
assert.ok(pending.signature);
for (const id of ['ec', 'm', 'in']) {
const view = viewGame(g, id);
assert.deepEqual(view.arrivalOverlap, {
first, active, entrant: 'in', territory: 'arrakeen',
});
assert.equal(JSON.stringify(view.arrivalOverlap).includes(pending.signature), false);
assert.equal('pendingArrivalOverlap' in view, false);
}
return pending;
}
function privateTerror(g: Game, token: string) {
const owner = viewGame(g, 'm');
const entrant = viewGame(g, 'in');
const ecaz = viewGame(g, 'ec');
const ownToken = owner.moritaniTerror!.tokens.find((t) => t.id === token)!;
const foreignTokens = [entrant, ecaz].map((v) => v.moritaniTerror!.tokens.find((t) => t.id === token)!);
assert.equal('kind' in ownToken && ownToken.kind, 'robbery');
for (const foreignToken of foreignTokens) assert.equal('kind' in foreignToken, false);
for (const view of [entrant, ecaz]) {
assert.equal('pendingTerrorEntry' in view, false);
assert.equal(JSON.stringify(view).includes('"kind":"robbery"'), false);
}
if (g.pendingTerrorEntry) {
assert.equal(owner.terrorEntry && 'kind' in owner.terrorEntry && owner.terrorEntry.kind, 'robbery');
for (const view of [entrant, ecaz]) assert.equal('kind' in (view.terrorEntry ?? {}), false);
}
}
function decideAmbassador(g: Game, trigger: boolean) {
return applyAction(g, 'ec', {
type: 'decision', event: g.pendingAmbassador!.event,
...(trigger ? { trigger: true, beneficiary: 'ec' } : { decline: true }),
});
}
function decideTerror(g: Game, reveal: boolean) {
return applyAction(g, 'm', { type: 'decision', ...(reveal ? { reveal: true } : { decline: true }) });
}
for (const advanced of [false, true])
for (const first of ['ambassador', 'terror'] as const)
for (const cause of ['shipment', 'movement'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${cause}: ${first} first, both optional offers and original entry survive JSON`, () => {
const { g: initial, ambassadorToken, terrorToken } = table(first, advanced, cause);
const action = cause === 'shipment' ? shipment : movement;
const offered = applyAction(initial, 'in', action);
const witness = overlap(offered, first, first);
assert.equal(witness.cause, cause);
assert.equal(witness.resume, 'none');
assert.equal(witness.ambassadorToken, ambassadorToken);
assert.deepEqual(witness.terrorTokens, [terrorToken]);
assert.equal(offered.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assert.equal(offered.decision?.player, first === 'ambassador' ? 'ec' : 'm');
assert.equal(offered.pendingAmbassador?.entrant ?? offered.pendingTerrorEntry?.entrant, 'in');
assert.equal(seat(offered, 'in').forces['arrakeen:10'], 2);
assert.equal(seat(offered, 'in').reserves, cause === 'shipment' ? 18 : 17);
assert.equal(seat(offered, 'in').spice, cause === 'shipment' ? 18 : 20);
if (cause === 'shipment') assert.equal(seat(offered, 'in').shipped, true);
else {
assert.equal(seat(offered, 'in').forces['imperial_basin:10'], 1);
assert.equal(seat(offered, 'in').moved, 1);
}
privateTerror(offered, terrorToken);
unchangedRejection(offered, 'in', { type: 'endMovement' });
unchangedRejection(offered, first === 'ambassador' ? 'm' : 'ec', {
type: 'decision', decline: true,
});
let second = first === 'ambassador'
? decideAmbassador(reload(offered), false)
: decideTerror(reload(offered), false);
const next = first === 'ambassador' ? 'terror' : 'ambassador';
const secondWitness = overlap(second, first, next);
assert.equal(secondWitness.signature, witness.signature);
assert.equal(secondWitness.cause, cause);
assert.equal(secondWitness.entrant, witness.entrant);
assert.equal(second.decision?.kind, next === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assert.equal(second.decision?.player, next === 'ambassador' ? 'ec' : 'm');
assert.equal(second.pendingAmbassador?.entrant ?? second.pendingTerrorEntry?.entrant, 'in');
assert.equal(seat(second, 'in').forces['arrakeen:10'], 2);
privateTerror(second, terrorToken);
unchangedRejection(second, first === 'ambassador' ? 'ec' : 'm', {
type: 'decision',
...(first === 'ambassador' ? { event: offered.pendingAmbassador!.event } : {}),
decline: true,
});
second = next === 'ambassador'
? decideAmbassador(reload(second), false)
: decideTerror(reload(second), false);
assert.equal(second.pendingArrivalOverlap ?? null, null);
assert.equal(second.pendingAmbassador ?? null, null);
assert.equal(second.pendingTerrorEntry ?? null, null);
assert.equal(second.decision, null);
assert.equal(viewGame(second, 'in').arrivalOverlap, null);
assert.equal(seat(second, 'in').forces['arrakeen:10'], 2);
assert.equal(seat(second, 'in').spice, cause === 'shipment' ? 18 : 20);
assert.equal(second.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'placed');
assert.equal(second.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'placed');
unchangedRejection(second, 'in', action);
});
}
for (const first of ['ambassador', 'terror'] as const) {
void test(`${first} first: persisted arrival witness rejects changed entry and owner order`, () => {
const offered = applyAction(table(first, false).g, 'in', shipment);
for (const change of [
(g: Game) => { g.pendingArrivalOverlap!.amount = 3; },
(g: Game) => { g.pendingArrivalOverlap!.entrant = 'ec'; },
(g: Game) => { g.pendingArrivalOverlap!.terrorTokens = []; },
(g: Game) => { g.pendingArrivalOverlap!.first = first === 'ambassador' ? 'terror' : 'ambassador'; },
(g: Game) => { g.order.reverse(); },
]) {
const corrupt = reload(offered);
change(corrupt);
unchangedRejection(corrupt, first === 'ambassador' ? 'ec' : 'm', first === 'ambassador'
? { type: 'decision', event: corrupt.pendingAmbassador!.event, decline: true }
: { type: 'decision', decline: true });
}
});
}
for (const first of ['ambassador', 'terror'] as const) {
void test(`${first} first: Emperor and Robbery settle exactly once with original entrant and private token custody`, () => {
const { g, ambassadorToken, terrorToken } = table(first, true);
let state = applyAction(g, 'in', shipment);
const signature = overlap(state, first, first).signature;
if (first === 'ambassador') {
const event = state.pendingAmbassador!.event;
state = decideAmbassador(reload(state), true);
assert.equal(seat(state, 'ec').spice, 25);
assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used');
overlap(state, first, 'terror');
privateTerror(state, terrorToken);
unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' });
state = decideTerror(reload(state), true);
assert.equal(state.pendingTerrorEntry?.stage, 'robbery');
assert.equal(state.pendingTerrorEntry?.entrant, 'in');
assert.equal(state.pendingArrivalOverlap?.signature, signature);
state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' });
} else {
state = decideTerror(reload(state), true);
assert.equal(state.pendingTerrorEntry?.stage, 'robbery');
assert.equal(state.pendingTerrorEntry?.entrant, 'in');
assert.equal(state.pendingArrivalOverlap?.signature, signature);
state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' });
const event = state.pendingAmbassador!.event;
overlap(state, first, 'ambassador');
assert.equal(state.pendingAmbassador?.entrant, 'in');
state = decideAmbassador(reload(state), true);
unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' });
}
assert.equal(state.pendingArrivalOverlap ?? null, null);
assert.equal(state.decision, null);
assert.equal(seat(state, 'in').spice, 9);
assert.equal(seat(state, 'm').spice, 29);
assert.equal(seat(state, 'ec').spice, 25);
assert.equal(seat(state, 'in').reserves, 18);
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used');
assert.equal(state.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'removed');
assert.equal(state.log.filter((line) => line.automatic?.name === 'Emperor Ambassador').length, 1);
unchangedRejection(state, 'm', { type: 'decision', choice: 'spice' });
unchangedRejection(state, 'in', shipment);
assert.deepEqual(reload(state).players, state.players);
});
}