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