Connect revived force arrivals to Bene Gesserit responses
This commit is contained in:
1 parent
33523d1926
commit
ab69f97cfa
22 files changed
+1269
-121
No files matched your search
@@ -78,7 +78,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi
|
||||
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
|
||||
for (const patch of [
|
||||
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 4 }, { phase: 7 },
|
||||
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 7 },
|
||||
]) {
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationInventory, advisorPreparationPlayer,
|
||||
} from './fixture-advisor-preparation';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type RevivalArrivalReactionsStep = { actor: string; action: Action };
|
||||
export type RevivalArrivalReactionsTransition = {
|
||||
before: Game; steps: RevivalArrivalReactionsStep[]; after: Game;
|
||||
};
|
||||
export const actRevivalArrivalReactions = actAdvisorPreparation;
|
||||
export const revivalArrivalReactionsPlayer = advisorPreparationPlayer;
|
||||
export const revivalArrivalReactionsInventory = advisorPreparationInventory;
|
||||
export const revivalArrivalReactionsClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.pendingRevival && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Owned placement, Intrusion and Spiritual choices remain caller controls.
|
||||
* Both actual Storms advance through native dials/readiness, never a clock edit. */
|
||||
export function nextRevivalArrivalReactions(game: Game): RevivalArrivalReactionsStep | null {
|
||||
if (game.truthtrance || game.response?.automaticAdvisorFlip ||
|
||||
game.response?.kind === 'advisor' || game.response?.kind === 'advisorFlip') return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision && ['homeworldRevivalDeployment', 'advisor', 'intrusion', 'revivalStop'].includes(decision.kind))
|
||||
return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'tleilaxu', turn: 10 } };
|
||||
if (game.advanced && game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
!revivalArrivalReactionsPlayer(game, 'beneGesserit').advisorSetup)
|
||||
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1
|
||||
? game.stormDialers.indexOf(actor) === 0 ? game.advanced ? 11 : 10 : 0
|
||||
: 1 } };
|
||||
}
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceRevivalArrivalReactions(game: Game, until: (state: Game) => boolean,
|
||||
actions: RevivalArrivalReactionsStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original revival-arrival source ended before its next control');
|
||||
const next = nextRevivalArrivalReactions(game);
|
||||
assert.ok(next, `Choose the original arrival/response at ${game.turn}/${game.phase}/${game.decision?.kind}/${game.response?.kind}`);
|
||||
game = actRevivalArrivalReactions(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine revival-arrival programme did not reach its finite boundary');
|
||||
}
|
||||
/** Calling this explicitly allows ordinary responses, including a declared
|
||||
* Spiritual shipment/Intrusion flip. A new automatic conversion stays owned. */
|
||||
export function passRevivalArrivalReactions(game: Game, actions: RevivalArrivalReactionsStep[] = []): Game {
|
||||
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 100; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actRevivalArrivalReactions(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
|
||||
}
|
||||
/** Earn the original returned-group decision; no destination or BG reaction is chosen. */
|
||||
export function openRevivalArrivalReactions(game: Game, fixture: { owner: string; revivalAction: Action },
|
||||
actions: RevivalArrivalReactionsStep[] = []): Game {
|
||||
game = actRevivalArrivalReactions(game, fixture.owner, fixture.revivalAction, actions);
|
||||
game = advanceRevivalArrivalReactions(game, state =>
|
||||
state.decision?.kind === 'homeworldRevivalDeployment' && !state.response && !state.phaseOpening, actions);
|
||||
return game;
|
||||
}
|
||||
export function commitRevivalArrivalReactions(game: Game,
|
||||
fixture: { owner: string; destination: string }, actions: RevivalArrivalReactionsStep[] = []) {
|
||||
const before = structuredClone(game), start = actions.length;
|
||||
const choice = homeworldRevivalDeploymentChoice(viewGame(game, fixture.owner), fixture.destination);
|
||||
assert.equal(choice.blocked, null); assert.ok(choice.action);
|
||||
game = actRevivalArrivalReactions(game, fixture.owner, choice.action, actions);
|
||||
return { game, before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
}
|
||||
export function finishRevivalArrivalReactions(game: Game, actions: RevivalArrivalReactionsStep[] = []): Game {
|
||||
return advanceRevivalArrivalReactions(game, state => state.homeworldRevivalReturn?.stage === 'complete' &&
|
||||
revivalArrivalReactionsClean(state), actions);
|
||||
}
|
||||
|
||||
/** Actual public Basic/Advanced BG/Tleilaxu + Homeworlds/E1. A genuine paid
|
||||
* one-counter sand shipment is destroyed by the next native Storm. No phase,
|
||||
* force, Tank, card, wallet, stance, population or pending receipt is installed. */
|
||||
export function createRevivalArrivalReactionsFixture(options: { advanced?: boolean; resident?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? true, resident = options.resident ?? false;
|
||||
const bg = 'beneGesserit', owner = 'tleilaxu', holder = owner;
|
||||
const territory = 'carthag', sector = 11, destination = `${territory}:${sector}`;
|
||||
const casualtyTerritory = 'hagga_basin', casualtyLocation = 'hagga_basin:13';
|
||||
let game = createGame('REVIVALARRIVALREACTIONS', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer(owner, 'Tleilaxu', 'tleilaxu'));
|
||||
const initial = structuredClone(game), actions: RevivalArrivalReactionsStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actRevivalArrivalReactions(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceRevivalArrivalReactions(game, until, actions); };
|
||||
const transition = (run: () => void): RevivalArrivalReactionsTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const homeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
for (const seat of game.players) act(seat.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.players.every(seat => !seat.hand.length));
|
||||
assert.deepEqual(game.expansions, ['ix']); assert.ok(game.homeworlds);
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.strongholdCards && !game.techTokens && !game.discoveryEnabled);
|
||||
const originalInventory = revivalArrivalReactionsInventory(game); assert.equal(originalInventory.length, 47);
|
||||
const ordinary = game.deck.find(card => card.kind === 'projectile');
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
|
||||
const selected = [ordinary, karama];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(seat => seat.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.deepEqual(revivalArrivalReactionsPlayer(game, bg).forces, { 'polar_sink:0': 1 });
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, 19);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 20);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).faceDancers?.length, 3);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = Array.from({ length: advanced ? 4 : 2 }, (_, position) => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
|
||||
!card.discovery && ![territory, casualtyTerritory].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
const firstStorm = transition(() => {
|
||||
reach(state => state.turn === 1 && state.phase === 1);
|
||||
});
|
||||
assert.equal(game.storm, advanced ? 12 : 11);
|
||||
assert.deepEqual(firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [advanced ? 11 : 10, 0]);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && revivalArrivalReactionsClean(state));
|
||||
let residentShipment: RevivalArrivalReactionsTransition | null = null;
|
||||
if (resident) residentShipment = transition(() => {
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, bg), 1, 0); assert.ok(homeworldSources);
|
||||
act(bg, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0, homeworldSources });
|
||||
reach(revivalArrivalReactionsClean);
|
||||
});
|
||||
act(bg, { type: 'endMovement' });
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && revivalArrivalReactionsClean(state));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const homeworldSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(homeworldSources);
|
||||
const shipmentStep: RevivalArrivalReactionsStep = { actor: owner, action: {
|
||||
type: 'ship', territory: casualtyTerritory, sector: 13, amount: 1, elite: 0, allyPayment: 0, homeworldSources,
|
||||
} };
|
||||
const shipment = transition(() => {
|
||||
act(shipmentStep.actor, shipmentStep.action);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
});
|
||||
const originalSpiritualDecline = transition(() => { act(bg, { type: 'decision', accept: false }); });
|
||||
assert.equal(revivalArrivalReactionsPlayer(beforeShipment, owner).spice - revivalArrivalReactionsPlayer(game, owner).spice, 2);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], 1);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
|
||||
const beforeNextStorm = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && !state.phaseOpening && !state.response &&
|
||||
state.stormPending === (advanced ? 1 : 2));
|
||||
const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource);
|
||||
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(game.storm, 13);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], undefined);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state));
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, resident ? 18 : 19);
|
||||
assert.equal(revivalArrivalReactionsPlayer(game, bg).forces[destination] ?? 0, resident ? 1 : 0);
|
||||
assert.ok(revivalArrivalReactionsPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.equal(homeworldTable(game)!.find(home => home.native === owner)!.side, 'high');
|
||||
assert.equal(homeworldTable(game)!.find(home => home.native === bg)!.side, 'high');
|
||||
assert.deepEqual(revivalArrivalReactionsInventory(game), originalInventory); homeworldGameIntegrity(game);
|
||||
const source = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 };
|
||||
return {
|
||||
game, source, beforeRequest: source, initial, lobby, setup, beforeStartingDeal, afterSetup, homeworlds,
|
||||
bg, owner, holder, tleilaxu: owner, target: owner, actor: owner, karama: karama.id,
|
||||
advanced, resident, territory, sector, destination, location: destination,
|
||||
casualtyTerritory, casualtyLocation, beforeShipment, shipmentStep, shipment, residentShipment,
|
||||
originalSpiritualDecline, beforeNextStorm, beforeStormMovement, stormSource, storm, firstStorm,
|
||||
revivalAction, action: revivalAction,
|
||||
cancelAction: { type: 'card', card: karama.id, mode: 'cancel' } as Action,
|
||||
declineAction: { type: 'decision', accept: false } as Action,
|
||||
sinkAction: { type: 'decision', accept: true, accompany: false, amount: 1 } as Action,
|
||||
highSinkAction: { type: 'decision', accept: true, accompany: false, amount: 2 } as Action,
|
||||
accompanyAction: { type: 'decision', accept: true, accompany: true, amount: 1, sector } as Action,
|
||||
dealOrder, spiceOrder, originalSpice, originalInventory, originalCards: originalInventory,
|
||||
steps: actions, actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type RevivalArrivalReactionsFixture = ReturnType<typeof createRevivalArrivalReactionsFixture>;
|
||||
@@ -52,77 +52,104 @@ function allow(g: Game, kind: string) {
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('actual phase-four revived arrival holds income through Guild shipment, BG child and Fremen relocation across JSON', () => {
|
||||
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',
|
||||
});
|
||||
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,
|
||||
});
|
||||
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');
|
||||
g = applyAction(reload(g), 'bg', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
accompany: true,
|
||||
});
|
||||
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');
|
||||
assert.equal(g.pendingAmbassador?.stage, 'offer');
|
||||
assert.equal(g.pendingAmbassador?.entrant, 'bg');
|
||||
assert.equal(g.homeworldRevivalReturn?.ambassadors?.length, 2);
|
||||
assert.equal(g.homeworldRevivalReturn!.ambassadors![1].parent, parent);
|
||||
assert.equal(p(g, 't').spice, beforeIncome);
|
||||
assert.deepEqual(
|
||||
g.homeworldRevivalReturn!.resumeResponse,
|
||||
original.resumeResponse,
|
||||
);
|
||||
inventory(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
||||
g = applyAction(reload(g), 'ec', {
|
||||
type: 'decision',
|
||||
event: g.pendingAmbassador!.event,
|
||||
trigger: true,
|
||||
beneficiary: 'ec',
|
||||
});
|
||||
assert.equal(g.pendingAmbassador?.stage, 'move');
|
||||
g = applyAction(reload(g), 'ec', {
|
||||
type: 'decision',
|
||||
event: g.pendingAmbassador!.event,
|
||||
forces: { 'red_chasm:7': 1 },
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
});
|
||||
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, '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'], 2);
|
||||
assert.equal(p(g, 'ec').forces['carthag:11'], 1);
|
||||
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 = fixture();
|
||||
// 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 = fixture();
|
||||
// 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 [
|
||||
|
||||
@@ -40,11 +40,11 @@ void test('Southern lists existing territories only and withdraws the entire typ
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('BG accompaniment uncertainty applies to Tleilax arrival on Arrakis, never Homeworld destinations', () => {
|
||||
void test('Tleilax SEND destinations remain available before their original Spiritual Advisor continuation', () => {
|
||||
const { g, grant } = fixture('tleilaxu');
|
||||
g.players[1].reserves = 20;
|
||||
const choices = homeworldRevivalDestinations(g, 'p', grant);
|
||||
assert.match(choices.find((c) => c.id === 'polar_sink:0')!.blocked!, /Spiritual Advisors/);
|
||||
assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null);
|
||||
const home = choices.filter((c) => c.homeworld);
|
||||
assert.ok(home.length > 0);
|
||||
assert.ok(home.every((c) => c.blocked === null));
|
||||
@@ -93,11 +93,11 @@ void test('storm uses the selected actual sector and mobile stronghold pointer;
|
||||
}
|
||||
});
|
||||
|
||||
void test('BG Intrusion gate requires Advanced fighters at the destination, not advisors or another location', () => {
|
||||
void test('BG fighters do not refuse SEND destinations before their original Intrusion continuation', () => {
|
||||
const { g, grant } = fixture('fremen');
|
||||
g.players[1].forces = { 'arrakeen:10': 1 };
|
||||
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
|
||||
assert.match(target().blocked!, /Intrusion/);
|
||||
assert.equal(target().blocked, null);
|
||||
g.advanced = false;
|
||||
assert.equal(target().blocked, null);
|
||||
g.advanced = true;
|
||||
@@ -105,18 +105,18 @@ void test('BG Intrusion gate requires Advanced fighters at the destination, not
|
||||
assert.equal(target().blocked, null);
|
||||
});
|
||||
|
||||
void test('Terror boundary reads public placement and original physical count, never a secret token face', () => {
|
||||
void test('the unfinished Revival Terror continuation stays fenced without reading a token face', () => {
|
||||
const { g, grant } = fixture('tleilaxu');
|
||||
g.moritaniTerror = createTerrorState(() => 0);
|
||||
const token = g.moritaniTerror.tokens[0];
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
||||
for (const t of g.moritaniTerror.tokens) Object.defineProperty(t, 'kind', { get() { throw new Error('private Terror face read'); } });
|
||||
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
|
||||
assert.match(target().blocked!, /Terror/);
|
||||
assert.match(target().blocked!, /Terror.*continuation/);
|
||||
g.players[2].reserves = 7;
|
||||
assert.equal(target().blocked, null);
|
||||
grant.normal = 3;
|
||||
assert.match(target().blocked!, /Terror/);
|
||||
assert.match(target().blocked!, /Terror.*continuation/);
|
||||
g.players[2].ally = 'p'; g.players[0].ally = 'm';
|
||||
assert.equal(target().blocked, null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
appendHomeworldArrivalAmbassador,
|
||||
completeHomeworldArrivalAmbassador,
|
||||
homeworldArrivalSignature,
|
||||
validateHomeworldArrival,
|
||||
} from '../game/homeworld-arrival';
|
||||
import {
|
||||
makeHomeworldRevivalReturn,
|
||||
makeHomeworldRevivalProgress,
|
||||
homeworldRevivalResumeSignature,
|
||||
validateHomeworldRevivalReturn,
|
||||
} from '../game/homeworld-revival-return';
|
||||
import { ambassadorPhaseAllowed } from '../game/ambassador-phase';
|
||||
import type { SpiritualAdvisorSource } from '../game/spiritual-advisor';
|
||||
|
||||
type RevivalSpiritual = SpiritualAdvisorSource & { revivalEvent: string };
|
||||
type Entry = NonNullable<Game['pendingAmbassador']>;
|
||||
|
||||
/** Controlled public receipt/continuation context. Actual placement and the
|
||||
* original revival entitlement remain the native engine producer's duty. */
|
||||
function fixture() {
|
||||
const g = createGame('REVIVAL-SPIRITUAL', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix', 'ecaz']);
|
||||
g.players.push(newPlayer('b', 'BG', 'beneGesserit'), newPlayer('e', 'Ecaz', 'ecaz'), newPlayer('i', 'Ixians', 'ixians'));
|
||||
g.status = 'playing';
|
||||
g.turn = 2;
|
||||
g.phase = 4;
|
||||
g.players[0].reserves = 11;
|
||||
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
|
||||
const frame = makeHomeworldRevivalReturn({
|
||||
event: 'revival-t-2', player: 't', turn: 2, phase: 4, source: 'normal',
|
||||
group: { amount: 2, elite: 0, free: 2 },
|
||||
quote: { kind: 'tleilax', normal: 2, elite: 0, beforePopulation: 9, afterPopulation: 11, blocked: null },
|
||||
});
|
||||
frame.stage = 'arrival';
|
||||
frame.resumeResponse = null;
|
||||
frame.resumeSignature = homeworldRevivalResumeSignature(null);
|
||||
frame.destination = 'arrakeen:10';
|
||||
frame.ambassadors = [];
|
||||
frame.arrivalSignature = homeworldArrivalSignature(frame);
|
||||
g.homeworldRevivalReturn = frame;
|
||||
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
|
||||
g.pendingAmbassador = null;
|
||||
return { g, frame };
|
||||
}
|
||||
|
||||
function entry(event = 'ambassador-bg-1', entrant = 'b', territory = 'arrakeen', sector = 10): Entry {
|
||||
return { event, owner: 'e', entrant, territory, sector, turn: 2, phase: 4,
|
||||
revivalEvent: 'revival-t-2', token: 'token-guild', stage: 'offer', copyChoices: [], resume: 'none' };
|
||||
}
|
||||
|
||||
function source(patch: Partial<RevivalSpiritual> = {}): RevivalSpiritual {
|
||||
return { event: 'spiritual-1', owner: 'b', turn: 2, phase: 4, territory: 'arrakeen', sector: 10,
|
||||
amount: 1, revivalEvent: 'revival-t-2', ...patch };
|
||||
}
|
||||
|
||||
void test('a committed Spiritual root opens phase-four Ambassador before the original Tleilax entry', () => {
|
||||
const { g, frame } = fixture();
|
||||
const bg = entry(), actual = source();
|
||||
appendHomeworldArrivalAmbassador(frame, bg, undefined, actual);
|
||||
g.pendingAmbassador = bg;
|
||||
validateHomeworldRevivalReturn(g, frame);
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
assert.equal(ambassadorPhaseAllowed(JSON.parse(JSON.stringify(g)) as Game), true);
|
||||
assert.deepEqual(frame.ambassadors![0].spiritual, actual);
|
||||
assert.notEqual(frame.ambassadors![0].spiritual, actual);
|
||||
actual.amount = 2;
|
||||
assert.equal(frame.ambassadors![0].spiritual!.amount, 1, 'The accepted public placement source is copied once.');
|
||||
|
||||
completeHomeworldArrivalAmbassador(frame, bg.event);
|
||||
const original = entry('ambassador-t-original', 't');
|
||||
appendHomeworldArrivalAmbassador(frame, original);
|
||||
g.pendingAmbassador = original;
|
||||
assert.equal(frame.ambassadors![1].parent, undefined);
|
||||
assert.equal(frame.ambassadors![1].spiritual, undefined);
|
||||
validateHomeworldRevivalReturn(g, frame);
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
completeHomeworldArrivalAmbassador(frame, original.event);
|
||||
g.pendingAmbassador = null;
|
||||
frame.stage = 'complete';
|
||||
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
|
||||
validateHomeworldRevivalReturn(g, frame);
|
||||
g.pendingAmbassador = bg;
|
||||
assert.equal(ambassadorPhaseAllowed(g), false, 'A completed BG root cannot be reopened.');
|
||||
});
|
||||
|
||||
void test('legacy primary rows retain their exact signature bytes without a Spiritual attribute', () => {
|
||||
const { g, frame } = fixture();
|
||||
const original = entry('ambassador-t-original', 't');
|
||||
appendHomeworldArrivalAmbassador(frame, original);
|
||||
g.pendingAmbassador = original;
|
||||
assert.equal(frame.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', frame.signature,
|
||||
'arrakeen:10', [[original.event, 't', 'arrakeen:10', null, false]]]));
|
||||
validateHomeworldRevivalReturn(g, frame);
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
});
|
||||
|
||||
void test('Spiritual provenance binds the original revival, actual owner, public packet and exact arrival destination', () => {
|
||||
for (const patch of [
|
||||
{ event: '' }, { owner: 'i' }, { owner: 't' }, { revivalEvent: 'other-revival' },
|
||||
{ turn: 3 }, { phase: 5 }, { territory: 'carthag', sector: 11 },
|
||||
{ sector: 9 }, { amount: 0 }, { amount: 3 }, { amount: 1.5 },
|
||||
] satisfies Partial<RevivalSpiritual>[]) {
|
||||
const { frame } = fixture(), before = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined, source(patch)));
|
||||
assert.equal(JSON.stringify(frame), before);
|
||||
}
|
||||
for (const actual of [
|
||||
{ ...source(), card: 'unrelated-card' },
|
||||
{ ...source(), privateFrame: {} },
|
||||
Object.fromEntries(Object.entries(source()).filter(([key]) => key !== 'revivalEvent')),
|
||||
]) {
|
||||
const { frame } = fixture(), before = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined, actual as RevivalSpiritual));
|
||||
assert.equal(JSON.stringify(frame), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a direct Spiritual root accompanies only the original territory or Polar Sink', () => {
|
||||
const { g, frame } = fixture();
|
||||
const polar = entry('ambassador-bg-polar', 'b', 'polar_sink', 0);
|
||||
appendHomeworldArrivalAmbassador(frame, polar, undefined, source({ territory: 'polar_sink', sector: 0, amount: 2 }));
|
||||
g.pendingAmbassador = polar;
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
|
||||
const unrelated = fixture();
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(unrelated.frame,
|
||||
entry('ambassador-bg-elsewhere', 'b', 'carthag', 11), undefined, source({ territory: 'carthag', sector: 11 })));
|
||||
const home = fixture();
|
||||
home.frame.destination = 'homeworld:i';
|
||||
home.frame.arrivalSignature = homeworldArrivalSignature(home.frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(home.frame, polar, undefined,
|
||||
source({ territory: 'polar_sink', sector: 0 })));
|
||||
});
|
||||
|
||||
void test('unique Spiritual openings may precede the one original root while duplicate events and unproven roots reject', () => {
|
||||
const { g, frame } = fixture();
|
||||
const first = entry();
|
||||
appendHomeworldArrivalAmbassador(frame, first, undefined, source());
|
||||
g.pendingAmbassador = first;
|
||||
const stillOpen = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-bg-2'), undefined,
|
||||
source({ event: 'spiritual-2' })));
|
||||
assert.equal(JSON.stringify(frame), stillOpen);
|
||||
completeHomeworldArrivalAmbassador(frame, first.event);
|
||||
|
||||
const completed = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-bg-replay'), undefined, source()));
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-unproven-bg')));
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-unproven-i', 'i')));
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-wrong-original', 't', 'carthag', 11)));
|
||||
assert.equal(JSON.stringify(frame), completed);
|
||||
|
||||
const second = entry('ambassador-bg-2');
|
||||
appendHomeworldArrivalAmbassador(frame, second, undefined, source({ event: 'spiritual-2' }));
|
||||
g.pendingAmbassador = second;
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
completeHomeworldArrivalAmbassador(frame, second.event);
|
||||
const original = entry('ambassador-t-original', 't');
|
||||
appendHomeworldArrivalAmbassador(frame, original);
|
||||
completeHomeworldArrivalAmbassador(frame, original.event);
|
||||
const before = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-original-replay', 't')));
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined,
|
||||
source({ event: 'spiritual-3' })));
|
||||
assert.equal(JSON.stringify(frame), before);
|
||||
});
|
||||
|
||||
void test('a Guild accompaniment suffix retains its original parent independently of the later primary root', () => {
|
||||
const { g, frame } = fixture();
|
||||
const bg = entry();
|
||||
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
|
||||
completeHomeworldArrivalAmbassador(frame, bg.event);
|
||||
const child: Entry = { ...entry('ambassador-bg-guild-child', 'b', 'carthag', 11),
|
||||
guildAdvisorOrigin: { event: bg.event, player: 'i', territory: 'carthag', sector: 11 } };
|
||||
const before = JSON.stringify(frame);
|
||||
assert.throws(() => appendHomeworldArrivalAmbassador(frame, child, bg.event, source({ event: 'spiritual-child', territory: 'carthag', sector: 11 })));
|
||||
assert.equal(JSON.stringify(frame), before);
|
||||
appendHomeworldArrivalAmbassador(frame, child, bg.event);
|
||||
g.pendingAmbassador = child;
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
child.guildAdvisorOrigin!.event = 'unrelated';
|
||||
assert.equal(ambassadorPhaseAllowed(g), false);
|
||||
child.guildAdvisorOrigin!.event = bg.event;
|
||||
completeHomeworldArrivalAmbassador(frame, child.event);
|
||||
const original = entry('ambassador-t-original', 't');
|
||||
appendHomeworldArrivalAmbassador(frame, original);
|
||||
g.pendingAmbassador = original;
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
});
|
||||
|
||||
void test('phase permission requires the current native BG source and revival tag, without reading hidden inventories', () => {
|
||||
const { g, frame } = fixture();
|
||||
const bg = entry();
|
||||
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
|
||||
g.pendingAmbassador = bg;
|
||||
for (const mutate of [
|
||||
(copy: Game) => { copy.players.find(player => player.id === 'b')!.faction = 'atreides'; },
|
||||
(copy: Game) => { copy.players.push(newPlayer('b', 'Duplicate BG', 'beneGesserit')); },
|
||||
(copy: Game) => { copy.pendingAmbassador!.revivalEvent = 'another-revival'; },
|
||||
(copy: Game) => { copy.pendingAmbassador!.guildAdvisorOrigin = { event: 'unrelated', player: 'i', territory: 'arrakeen', sector: 10 }; },
|
||||
(copy: Game) => { copy.phase = 5; },
|
||||
]) {
|
||||
const copy = structuredClone(g);
|
||||
mutate(copy);
|
||||
const before = JSON.stringify(copy);
|
||||
assert.equal(ambassadorPhaseAllowed(copy), false);
|
||||
assert.equal(JSON.stringify(copy), before);
|
||||
}
|
||||
const wrongTag = structuredClone(g);
|
||||
wrongTag.pendingAmbassador!.victoryEvent = frame.event;
|
||||
delete wrongTag.pendingAmbassador!.revivalEvent;
|
||||
assert.throws(() => validateHomeworldArrival(wrongTag, wrongTag.homeworldRevivalReturn!, 'victoryEvent'));
|
||||
for (const player of g.players)
|
||||
for (const field of ['hand', 'spice', 'forces', 'reserves', 'tanks', 'traitors', 'faceDancers'])
|
||||
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
|
||||
validateHomeworldArrival(g, frame, 'revivalEvent');
|
||||
assert.equal(ambassadorPhaseAllowed(g), true);
|
||||
});
|
||||
|
||||
void test('the signed Spiritual row is canonical after JSON reordering and rejects changed public evidence', () => {
|
||||
const { g, frame } = fixture();
|
||||
const bg = entry();
|
||||
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
|
||||
g.pendingAmbassador = bg;
|
||||
const signature = frame.arrivalSignature;
|
||||
frame.ambassadors![0].spiritual = Object.fromEntries(Object.entries(frame.ambassadors![0].spiritual!).reverse()) as RevivalSpiritual;
|
||||
assert.equal(homeworldArrivalSignature(frame), signature);
|
||||
validateHomeworldArrival(g, frame, 'revivalEvent');
|
||||
frame.ambassadors![0].spiritual!.amount = 2;
|
||||
assert.throws(() => validateHomeworldArrival(g, frame, 'revivalEvent'));
|
||||
assert.equal(ambassadorPhaseAllowed(g), false);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
|
||||
import {
|
||||
createRevivalArrivalReactionsFixture as fixture,
|
||||
actRevivalArrivalReactions as act,
|
||||
openRevivalArrivalReactions as open,
|
||||
commitRevivalArrivalReactions as commit,
|
||||
passRevivalArrivalReactions as pass,
|
||||
finishRevivalArrivalReactions as finish,
|
||||
revivalArrivalReactionsPlayer as player,
|
||||
revivalArrivalReactionsInventory as inventory,
|
||||
type RevivalArrivalReactionsFixture,
|
||||
} from './fixture-revival-arrival-reactions';
|
||||
|
||||
function ownEvent(game: Game, owner: string) {
|
||||
const p = player(game, owner);
|
||||
return {
|
||||
reserves: p.reserves, tanks: p.tanks, forces: structuredClone(p.forces), spice: p.spice,
|
||||
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived,
|
||||
};
|
||||
}
|
||||
function bgOffer(f: RevivalArrivalReactionsFixture) {
|
||||
const returned = open(f.game, f);
|
||||
assert.equal(returned.homeworldRevivalReturn?.stage, 'choice');
|
||||
assert.equal(returned.decision?.kind, 'homeworldRevivalDeployment');
|
||||
assert.equal(returned.homeworldRevivalReturn?.resumeResponse?.kind, 'revivalIncome');
|
||||
assert.equal(returned.homeworldRevivalReturn?.resumeResponse?.amount, 1);
|
||||
assert.equal(player(returned, f.owner).tanks, 0); assert.equal(player(returned, f.owner).reserves, 20);
|
||||
assert.equal(player(returned, f.owner).revived, 1);
|
||||
assert.equal(player(returned, f.owner).freeForcesRevived, 1);
|
||||
const placed = commit(returned, f).game;
|
||||
assert.equal(placed.homeworldRevivalReturn?.event, returned.homeworldRevivalReturn!.event);
|
||||
assert.equal(placed.homeworldRevivalReturn?.stage, 'arrival');
|
||||
assert.equal(placed.decision?.kind, 'advisor'); assert.equal(placed.decision?.player, f.bg);
|
||||
assert.equal(player(placed, f.owner).forces[f.destination], 1);
|
||||
assert.equal(player(placed, f.owner).reserves, 19);
|
||||
assert.equal(player(placed, f.bg).reserves, player(f.game, f.bg).reserves);
|
||||
return { returned, game: placed };
|
||||
}
|
||||
function originalAccounting(game: Game, f: RevivalArrivalReactionsFixture) {
|
||||
const before = player(f.game, f.owner), after = player(game, f.owner);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 4);
|
||||
assert.equal(after.spice, before.spice + 1, 'The original free-revival bank income settles once after arrival choices');
|
||||
assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
|
||||
assert.equal(after.tanks, 0); assert.equal(after.revived, 1); assert.equal(after.freeForcesRevived, 1);
|
||||
assert.equal(after.reserves, 19); assert.equal(after.forces[f.destination], 1);
|
||||
assert.equal(after.reserves + after.tanks + Object.values(after.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
|
||||
assert.deepEqual(inventory(game), f.originalInventory); homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`${mode} genuine High Tleilax revival arrival opens BG Spiritual choice and decline finishes the original free return`, () => {
|
||||
const f = fixture({ advanced });
|
||||
assert.deepEqual(f.game.expansions, ['ix']); assert.ok(f.game.homeworlds);
|
||||
assert.equal(f.originalInventory.length, 47); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(player(f.afterSetup, f.owner).reserves, 20);
|
||||
assert.equal(player(f.afterSetup, f.owner).tanks, 0);
|
||||
assert.equal(player(f.shipment.after, f.owner).forces[f.casualtyLocation], 1);
|
||||
assert.equal(player(f.beforeShipment, f.owner).spice - player(f.shipment.after, f.owner).spice, 2);
|
||||
assert.equal(f.beforeStormMovement.storm, advanced ? 12 : 11);
|
||||
assert.equal(f.beforeStormMovement.stormPending, advanced ? 1 : 2);
|
||||
assert.equal(f.storm.after.storm, 13);
|
||||
assert.equal(player(f.storm.after, f.owner).tanks, 1);
|
||||
assert.equal(player(f.game, f.owner).tanks, 1); assert.equal(player(f.game, f.owner).revived, 0);
|
||||
assert.equal(player(f.game, f.owner).reserves, 19); assert.equal(player(f.game, f.bg).reserves, 19);
|
||||
assert.ok(homeworldTable(f.game)!.filter(home => home.native === f.owner || home.native === f.bg)
|
||||
.every(home => home.side === 'high'));
|
||||
const { game } = bgOffer(f), before = ownEvent(game, f.owner);
|
||||
const done = finish(act(game, f.bg, f.declineAction));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
|
||||
assert.equal(player(done, f.bg).reserves, 19); assert.equal(player(done, f.bg).forces['polar_sink:0'], 1);
|
||||
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
|
||||
originalAccounting(done, f);
|
||||
});
|
||||
|
||||
void test(`${mode} genuine revival-triggered BG can send one or High Wallach two to Polar without a second revival or shipment fee`, () => {
|
||||
const f = fixture({ advanced }), { game } = bgOffer(f);
|
||||
for (const amount of [1, 2]) {
|
||||
const before = ownEvent(game, f.owner), bgBefore = ownEvent(game, f.bg);
|
||||
const declared = act(game, f.bg, amount === 1 ? f.sinkAction : f.highSinkAction);
|
||||
assert.equal(declared.response?.kind, 'advisor'); assert.equal(declared.response?.owner, f.bg);
|
||||
assert.equal(player(declared, f.bg).reserves, bgBefore.reserves);
|
||||
assert.deepEqual(ownEvent(declared, f.owner), before);
|
||||
const done = finish(pass(declared));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
|
||||
assert.equal(player(done, f.bg).reserves, bgBefore.reserves - amount);
|
||||
assert.equal(player(done, f.bg).forces['polar_sink:0'], 1 + amount);
|
||||
assert.equal(player(done, f.bg).spice, bgBefore.spice);
|
||||
assert.equal(player(done, f.bg).shipped, bgBefore.shipped);
|
||||
assert.equal(player(done, f.bg).moved, bgBefore.moved);
|
||||
assert.equal(player(done, f.bg).revived, bgBefore.revived);
|
||||
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
|
||||
originalAccounting(done, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${mode} original Tleilaxu Karama independently cancels BG accompaniment after the revived force is already placed`, () => {
|
||||
const f = fixture({ advanced }), { game } = bgOffer(f);
|
||||
const before = ownEvent(game, f.owner), bgBefore = ownEvent(game, f.bg);
|
||||
const declared = act(game, f.bg, f.highSinkAction);
|
||||
assert.equal(declared.response?.kind, 'advisor');
|
||||
const done = finish(act(declared, f.holder, f.cancelAction));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 }); assert.deepEqual(ownEvent(done, f.bg), bgBefore);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), false);
|
||||
originalAccounting(done, f);
|
||||
});
|
||||
|
||||
void test(`${mode} all four minimal BG policies and original Karama responders act legally on the revival arrival`, () => {
|
||||
const f = fixture({ advanced }), { game } = bgOffer(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(game, f.bg); own.players.find(seat => seat.id === f.bg)!.bot = difficulty;
|
||||
const actions = botActions(own); assert.ok(actions.length, difficulty);
|
||||
const declared = act(game, f.bg, actions[0]);
|
||||
if (declared.response) {
|
||||
const counter = viewGame(declared, f.holder);
|
||||
counter.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const replies = botActions(counter); assert.ok(replies.length, difficulty);
|
||||
assert.doesNotThrow(() => act(declared, f.holder, replies[0]), difficulty);
|
||||
}
|
||||
const done = finish(pass(declared)); originalAccounting(done, f);
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced genuine revived Tleilaxu arrival permits one advisor to accompany into its original Carthag destination', () => {
|
||||
const f = fixture(), { game } = bgOffer(f), before = ownEvent(game, f.owner);
|
||||
const done = finish(pass(act(game, f.bg, f.accompanyAction)));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
|
||||
assert.equal(player(done, f.bg).reserves, 18);
|
||||
assert.equal(player(done, f.bg).forces[f.destination], 1);
|
||||
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
|
||||
assert.equal(player(done, f.bg).forces['polar_sink:0'], 1);
|
||||
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
|
||||
originalAccounting(done, f);
|
||||
});
|
||||
|
||||
void test('Advanced actual BG resident fighters receive Intrusion before the revived arrival Spiritual followup', () => {
|
||||
const f = fixture({ resident: true }); assert.ok(f.residentShipment);
|
||||
assert.equal(player(f.residentShipment.before, f.bg).spice - player(f.residentShipment.after, f.bg).spice, 1);
|
||||
assert.equal(player(f.game, f.bg).reserves, 18); assert.equal(isAdvisor(player(f.game, f.bg), f.territory), false);
|
||||
const returned = open(f.game, f), placed = commit(returned, f).game;
|
||||
assert.equal(placed.decision?.kind, 'intrusion'); assert.equal(placed.decision?.player, f.bg);
|
||||
assert.equal(player(placed, f.owner).forces[f.destination], 1);
|
||||
const before = ownEvent(placed, f.owner);
|
||||
const converting = act(placed, f.bg, { type: 'decision', accept: true });
|
||||
assert.equal(converting.response?.kind, 'advisorFlip'); assert.equal(converting.response?.advisors, true);
|
||||
const offered = pass(converting);
|
||||
assert.equal(isAdvisor(player(offered, f.bg), f.territory), true);
|
||||
assert.equal(offered.decision?.kind, 'advisor'); assert.equal(offered.decision?.player, f.bg);
|
||||
const done = finish(pass(act(offered, f.bg, f.accompanyAction)));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
|
||||
assert.equal(player(done, f.bg).forces[f.destination], 2);
|
||||
assert.equal(player(done, f.bg).reserves, 17); assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
|
||||
originalAccounting(done, f);
|
||||
});
|
||||
|
||||
void test('Advanced original Karama can prevent Intrusion conversion while the revived force and later decline remain committed', () => {
|
||||
const f = fixture({ resident: true }), returned = open(f.game, f), placed = commit(returned, f).game;
|
||||
assert.equal(placed.decision?.kind, 'intrusion');
|
||||
const before = ownEvent(placed, f.owner), bgBefore = ownEvent(placed, f.bg);
|
||||
const converting = act(placed, f.bg, { type: 'decision', accept: true });
|
||||
assert.equal(converting.response?.kind, 'advisorFlip');
|
||||
const offered = act(converting, f.holder, f.cancelAction);
|
||||
assert.equal(offered.decision?.kind, 'advisor'); assert.equal(isAdvisor(player(offered, f.bg), f.territory), false);
|
||||
const done = finish(act(offered, f.bg, f.declineAction));
|
||||
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 }); assert.deepEqual(ownEvent(done, f.bg), bgBefore);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
|
||||
originalAccounting(done, f);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
validateSpiritualAdvisorSource, SpiritualAdvisorError,
|
||||
type SpiritualAdvisorContext, type SpiritualAdvisorSource,
|
||||
} from '../game/spiritual-advisor';
|
||||
import {
|
||||
quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError,
|
||||
type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource,
|
||||
} from '../game/advisor-automatic-flip';
|
||||
|
||||
/** Minimal controlled public arrival contexts. Original force-return and
|
||||
* Ambassador receipts remain the engine's separate authority. */
|
||||
function context(): SpiritualAdvisorContext {
|
||||
return { advanced: true, status: 'playing', turn: 3, phase: 4,
|
||||
players: [
|
||||
{ id: 'bg', faction: 'beneGesserit', forces: {} },
|
||||
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 } },
|
||||
],
|
||||
homeworldRevivalReturn: { event: 'original-return', turn: 3, phase: 4, stage: 'arrival', destination: 'arrakeen:10' },
|
||||
};
|
||||
}
|
||||
function source(patch: Partial<SpiritualAdvisorSource> = {}): SpiritualAdvisorSource {
|
||||
return { event: 'original-advisor-opportunity', owner: 'bg', turn: 3, phase: 4,
|
||||
territory: 'polar_sink', sector: 0, amount: 1, revivalEvent: 'original-return', ...patch };
|
||||
}
|
||||
|
||||
void test('provisional Revival SEND permits native Spiritual Advisors only against the original board arrival parent', () => {
|
||||
for (const advanced of [false, true]) for (const amount of [1, 2]) {
|
||||
const ctx = context(); ctx.advanced = advanced;
|
||||
for (const receipt of [source({ amount }), source({ amount, territory: 'arrakeen', sector: 10 })]) {
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing, foreign, stale or completed Revival parents cannot authorize a phase-four source', () => {
|
||||
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
|
||||
ctx => { delete ctx.homeworldRevivalReturn; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.turn = 2; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.phase = 5; },
|
||||
...(['waiting', 'choice', 'complete'] as const).map(stage =>
|
||||
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.stage = stage; }),
|
||||
...[undefined, 'decline', 'reserves', 'homeworld:tleilaxu', 'missing:0', 'arrakeen:9', 'arrakeen:010'].map(destination =>
|
||||
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.destination = destination; }),
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context(), receipt = source(); change(ctx);
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('non-Polar accompaniment must use the original arrival territory while Polar remains the fallback', () => {
|
||||
const ctx = context();
|
||||
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'carthag', sector: 11 })), SpiritualAdvisorError);
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })));
|
||||
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'homeworld:tleilaxu', sector: 0 })), SpiritualAdvisorError);
|
||||
});
|
||||
|
||||
void test('Revival source keeps current native BG ownership and exact optional-field timing', () => {
|
||||
const ctx = context();
|
||||
for (const receipt of [source({ owner: 't' }), source({ revivalEvent: 'foreign-return' }),
|
||||
source({ revivalEvent: '' }), source({ revivalEvent: undefined }), source({ turn: 2 }), source({ phase: 5 })])
|
||||
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
|
||||
for (const phase of [1, 3, 5, 8]) {
|
||||
const old = source({ phase }); delete old.revivalEvent;
|
||||
const original = { ...ctx, phase };
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(original, old));
|
||||
assert.deepEqual(Object.keys(old).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
|
||||
assert.throws(() => validateSpiritualAdvisorSource(original, { ...old, revivalEvent: 'original-return' }), SpiritualAdvisorError);
|
||||
}
|
||||
});
|
||||
|
||||
function guildChildContext(): SpiritualAdvisorContext {
|
||||
const ctx = context();
|
||||
ctx.pendingSpiritualAdvisor = { event: 'original-advisor-opportunity', turn: 3, phase: 4,
|
||||
decision: { player: 'bg', destination: 'carthag:11', ambassadorEvent: 'original-ambassador', revivalEvent: 'original-return' } };
|
||||
ctx.pendingAmbassador = { event: 'original-ambassador', revivalEvent: 'original-return', turn: 3, phase: 4, stage: 'arrival' };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
void test('actual Guild Ambassador child provenance binds accompaniment to its own captured board destination', () => {
|
||||
const ctx = guildChildContext(), receipt = source({ territory: 'carthag', sector: 11 });
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
|
||||
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
|
||||
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })), SpiritualAdvisorError);
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
|
||||
ctx => { delete ctx.pendingSpiritualAdvisor; },
|
||||
ctx => { delete ctx.pendingAmbassador; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.event = 'foreign-opportunity'; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.turn = 2; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.phase = 5; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.decision.player = 't'; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent = 'foreign-ambassador'; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.decision.revivalEvent = 'foreign-return'; },
|
||||
ctx => { ctx.pendingSpiritualAdvisor!.decision.destination = 'homeworld:tleilaxu'; },
|
||||
ctx => { ctx.pendingAmbassador!.revivalEvent = 'foreign-return'; },
|
||||
ctx => { ctx.pendingAmbassador!.turn = 2; },
|
||||
ctx => { ctx.pendingAmbassador!.phase = 5; },
|
||||
ctx => { ctx.pendingAmbassador!.stage = 'offer'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const original = guildChildContext(); change(original);
|
||||
assert.throws(() => validateSpiritualAdvisorSource(original, receipt), SpiritualAdvisorError);
|
||||
}
|
||||
});
|
||||
|
||||
function automatic() {
|
||||
const ctx: AutomaticAdvisorFlipContext = { advanced: true, status: 'playing', turn: 3, phase: 4,
|
||||
players: [
|
||||
{ id: 'bg', faction: 'beneGesserit', forces: { 'polar_sink:0': 1 }, advisors: { polar_sink: {} } },
|
||||
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 }, moved: 1 },
|
||||
], homeworldRevivalReturn: context().homeworldRevivalReturn,
|
||||
};
|
||||
const receipt: Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }> = {
|
||||
event: 'original-automatic-occurrence', owner: 'bg', territory: 'polar_sink', turn: 3, phase: 4,
|
||||
forces: { 'polar_sink:0': 1 }, producer: 'spiritualArrival', spiritual: source(),
|
||||
};
|
||||
return { ctx, receipt };
|
||||
}
|
||||
|
||||
void test('a committed phase-four Spiritual arrival can capture and validate its exact alone conversion', () => {
|
||||
const { ctx, receipt } = automatic();
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [{ player: 'bg', territory: 'polar_sink', forces: { 'polar_sink:0': 1 } }]);
|
||||
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
|
||||
const group = { event: receipt.event, owner: receipt.owner, territory: receipt.territory,
|
||||
turn: receipt.turn, phase: receipt.phase, forces: receipt.forces };
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx,
|
||||
{ ...group, producer: 'groundMovement', mover: 't', move: 1 }), AutomaticAdvisorFlipError);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, { ...group, producer: 'specialRelocation',
|
||||
relocation: { event: 'original-relocation', kind: 'ambassador', player: 't', origin: 'carthag', destination: 'arrakeen', sector: 10 } }),
|
||||
AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('phase-four automatic conversion delegates its parent, completed placement and surviving-group checks', () => {
|
||||
const changes: ((ctx: AutomaticAdvisorFlipContext) => void)[] = [
|
||||
ctx => { delete ctx.homeworldRevivalReturn; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.stage = 'complete'; },
|
||||
ctx => { ctx.homeworldRevivalReturn!.destination = 'homeworld:tleilaxu'; },
|
||||
ctx => { ctx.players[0].forces = {}; },
|
||||
ctx => { ctx.players[1].forces = { 'polar_sink:0': 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const { ctx, receipt } = automatic();
|
||||
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
|
||||
change(ctx);
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('phase-four automatic conversion forwards the actual Guild child provenance to the Spiritual validator', () => {
|
||||
const { ctx, receipt } = automatic(), child = guildChildContext();
|
||||
ctx.pendingSpiritualAdvisor = child.pendingSpiritualAdvisor;
|
||||
ctx.pendingAmbassador = child.pendingAmbassador;
|
||||
ctx.players[0].forces = { 'carthag:11': 1 };
|
||||
ctx.players[0].advisors = { carthag: { aloneFlipHold: receipt.event } };
|
||||
receipt.territory = 'carthag'; receipt.forces = { 'carthag:11': 1 };
|
||||
receipt.spiritual = source({ territory: 'carthag', sector: 11 });
|
||||
const before = structuredClone({ ctx, receipt });
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
|
||||
assert.deepEqual({ ctx, receipt }, before);
|
||||
ctx.pendingAmbassador!.revivalEvent = 'foreign-return';
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
|
||||
});
|
||||
Reference in new issue
Block a user