282 lines
11 KiB
TypeScript
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);
|
|
}
|
|
});
|