Files
Dune/tests/homeworld-revival-ambassador-engine.test.ts
T

282 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import {
revivalPlayer as p,
revivalInventory as inventory,
revivalReload as reload,
} from './fixture-homeworld-revival';
import { homeworldRevivalAmbassadorFixture as fixture } from './fixture-homeworld-revival-ambassador';
function revivalOpportunity(g: Game, actor: string) {
for (let count = 0; g.revivalOrder && g.revivalOrder.remaining[0] !== actor && count < g.players.length; count++) {
const preceding = g.revivalOrder.remaining[0]; assert.ok(preceding);
assert.equal(g.phase, 4);
assert.equal(g.response ?? null, null); assert.equal(g.decision ?? null, null);
g = applyAction(g, preceding, { type: 'ready' });
}
assert.equal(g.phase, 4);
if (g.revivalOrder) assert.equal(g.revivalOrder.remaining[0], actor);
return g;
}
function open() {
let g = revivalOpportunity(fixture(), 'f');
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
if (g.decision?.kind === 'revivalStop')
g = applyAction(g, 't', { type: 'decision', decline: true });
while (!g.homeworldRevivalReturn && g.response) g = allow(g, g.response.kind);
assert.equal(
g.homeworldRevivalReturn?.stage,
'choice',
JSON.stringify({
decision: g.decision,
response: g.response,
revival: g.pendingRevival,
}),
);
assert.equal(g.homeworldRevivalReturn.resumeResponse?.kind, 'revivalIncome');
const event = g.homeworldRevivalReturn.event;
g = applyAction(reload(g), 'f', {
type: 'decision',
event,
destination: 'arrakeen:10',
});
assert.equal(g.pendingAmbassador?.stage, 'offer');
assert.equal(g.homeworldRevivalReturn?.stage, 'arrival');
inventory(g);
return g;
}
function allow(g: Game, kind: string) {
for (let n = 0; g.response?.kind === kind && n < 40; n++) {
const player = g.players.find(
(player) => !g.response!.passed.includes(player.id),
);
assert.ok(player);
g = applyAction(reload(g), player.id, { type: 'passResponse' });
}
return g;
}
void test('actual phase-four revived arrival rejects fighter accompaniment and holds income through the Guild Polar alternative', () => {
let g = open();
const original = structuredClone(g.homeworldRevivalReturn!);
const beforeIncome = p(g, 't').spice;
const parent = g.pendingAmbassador!.event;
g = applyAction(reload(g), 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'a' });
assert.equal(g.pendingAmbassador?.stage, 'ship');
g = applyAction(reload(g), 'a', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(reload(g), 'bg', { type: 'decision', accept: false });
assert.equal(g.decision?.kind, 'advisor');
const before = reload(g);
assert.throws(() => applyAction(g, 'bg', { type: 'decision', accept: true, accompany: true }), /cannot accompany/);
assert.deepEqual(g, before);
g = applyAction(reload(g), 'bg', { type: 'decision', accept: true, accompany: false, amount: 1 });
assert.equal(g.response?.kind, 'advisor');
g = allow(g, 'advisor');
g = allow(g, 'advisorFlip');
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(g.homeworldRevivalReturn?.ambassadors?.length, 1);
assert.equal(g.homeworldRevivalReturn!.ambassadors![0].completed, true);
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.deepEqual(g.response, original.resumeResponse);
assert.equal(p(g, 't').spice, beforeIncome);
g = allow(g, 'revivalIncome');
assert.equal(p(g, 't').spice, beforeIncome + original.resumeResponse!.amount!);
assert.equal(p(g, 'f').forces['arrakeen:10'], 13);
assert.equal(p(g, 'a').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 1);
assert.equal(p(g, 'bg').forces['polar_sink:0'], 1);
assert.equal(p(g, 'ec').forces['red_chasm:7'], 1);
assert.ok(g.ecazAmbassadors!.tokens.some(token => token.effect === 'fremen' && token.zone === 'placed' && token.location === 'sietch_tabr'));
inventory(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
});
void test('a controlled native World source completes revived Tleilax Intrusion and Spiritual declines before its Fremen Ambassador relocation', () => {
let g = revivalOpportunity(fixture(), 't');
// This existing fixture conserves staged native armies/cards. The return,
// Intrusion, Spiritual choice and Ambassador continuations are real actions.
g = applyAction(g, 't', { type: 'revive', amount: 1, elite: 0 });
const event = g.homeworldRevivalReturn!.event;
const beforeIncome = p(g, 't').spice;
g = applyAction(g, 't', { type: 'decision', event, destination: 'sietch_tabr:14' });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'bg', { type: 'decision', accept: false });
assert.equal(g.decision?.kind, 'advisor');
g = applyAction(g, 'bg', { type: 'decision', accept: false });
assert.equal(g.pendingAmbassador?.stage, 'offer');
assert.equal(g.pendingAmbassador?.entrant, 't');
const entry = g.pendingAmbassador!;
assert.equal(entry.revivalEvent, event);
g = applyAction(g, 'ec', { type: 'decision', event: entry.event, trigger: true, beneficiary: 'ec' });
assert.equal(g.pendingAmbassador?.stage, 'move');
g = applyAction(g, 'ec', { type: 'decision', event: entry.event,
forces: { 'red_chasm:7': 1 }, territory: 'carthag', sector: 11 });
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(p(g, 'ec').forces['carthag:11'], 1);
assert.equal(p(g, 't').forces['sietch_tabr:14'], 1);
assert.equal(p(g, 't').spice, beforeIncome);
g = allow(g, 'revivalIncome');
assert.equal(p(g, 't').spice, beforeIncome + 1);
inventory(g);
});
void test('a controlled conserved Southern return permits Intrusion Karama without inventing Spiritual accompaniment', () => {
let g = revivalOpportunity(fixture(), 'f');
// Move existing Fremen/BG counters to a non-stronghold in this controlled
// fixture. No revival, entry, response or continuation is installed.
p(g, 'f').forces['arrakeen:10']--;
p(g, 'f').forces['imperial_basin:9'] = 1;
delete p(g, 'bg').forces['sietch_tabr:14'];
p(g, 'bg').forces['imperial_basin:9'] = 1;
inventory(g);
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
if (g.decision?.kind === 'revivalStop')
g = applyAction(g, 't', { type: 'decision', decline: true });
while (g.homeworldRevivalReturn?.stage !== 'choice' && g.response) g = allow(g, g.response.kind);
assert.equal(g.homeworldRevivalReturn?.stage, 'choice');
const event = g.homeworldRevivalReturn!.event;
g = applyAction(g, 'f', { type: 'decision', event, destination: 'imperial_basin:9' });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'bg', { type: 'decision', accept: true });
assert.equal(g.response?.kind, 'advisorFlip');
assert.equal(g.response?.advisorRevivalEvent, event);
assert.equal(g.response?.advisorFollowup, undefined);
const karama = p(g, 'ec').hand.find(card => card.effect === 'karama')!;
g = applyAction(g, 'ec', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.equal(g.decision, null);
assert.equal(p(g, 'bg').advisors?.imperial_basin, undefined);
assert.equal(p(g, 'f').forces['imperial_basin:9'], 2);
assert.equal(g.discard.filter(card => card.id === karama.id).length, 1);
inventory(g);
});
void test('an actual revival Ambassador offer rejects edited original arrival identities before view or action', () => {
const original = open();
for (const edit of [
(g: Game) => {
g.pendingAmbassador!.entrant = 'a';
},
(g: Game) => {
g.pendingAmbassador!.territory = 'carthag';
g.pendingAmbassador!.sector = 11;
},
(g: Game) => {
delete g.pendingAmbassador!.revivalEvent;
},
]) {
const g = reload(original);
edit(g);
const saved = reload(g);
assert.throws(() => viewGame(g, 'ec'));
assert.throws(() =>
applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: false,
}),
);
assert.deepEqual(g, saved);
}
});
void test('deleting a still-open revival Ambassador cannot silently finish its retained income', () => {
const g = open();
g.pendingAmbassador = null;
g.decision = null;
const saved = reload(g);
assert.throws(() => viewGame(g, 'ec'));
assert.throws(() => applyAction(g, 'ec', { type: 'ready' }));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, saved);
g.homeworldRevivalReturn!.stage = 'complete';
const falselyCompleted = reload(g);
assert.throws(() => viewGame(g, 'ec'));
assert.throws(() => applyAction(g, 'ec', { type: 'ready' }));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, falselyCompleted);
});
void test('a real revival arrival cannot rewind and spend an older Fedaykin still in native reserves', () => {
const original = open();
assert.equal(
p(original, 'f').elites!.reserves,
1,
'an older elite remains physically available',
);
assert.equal(original.homeworldRevivalProgress?.stage, 'arrival');
const g = reload(original);
const frame = g.homeworldRevivalReturn!;
frame.stage = 'choice';
delete frame.destination;
delete frame.ambassadors;
delete frame.arrivalSignature;
g.pendingAmbassador = null;
g.decision = {
kind: 'homeworldRevivalDeployment',
player: 'f',
event: frame.event,
};
const before = reload(g);
assert.throws(() => viewGame(g, 'f'));
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() =>
applyAction(g, 'f', {
type: 'decision',
event: frame.event,
destination: 'arrakeen:10',
}),
);
assert.deepEqual(g, before);
});
void test('new revival progress and its signed version cannot be deleted or downgraded during a live child', () => {
const original = open();
for (const edit of [
(g: Game) => {
delete g.homeworldRevivalProgress;
},
(g: Game) => {
delete g.homeworldRevivalReturn!.progressVersion;
},
(g: Game) => {
delete g.homeworldRevivalProgress;
delete g.homeworldRevivalReturn!.progressVersion;
},
(g: Game) => {
g.homeworldRevivalReturn!.progressVersion = 0 as 1;
},
(g: Game) => {
g.homeworldRevivalProgress!.stage = 'choice';
},
(g: Game) => {
delete g.homeworldRevivalReturn;
},
]) {
const g = reload(original);
edit(g);
const before = reload(g);
assert.throws(() => viewGame(g, 'f'));
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() =>
applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
decline: true,
}),
);
assert.deepEqual(g, before);
}
});