From ab69f97cfa995ece96935590e28cb79d9e464e11 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sun, 25 Oct 2026 21:36:05 +1100
Subject: [PATCH] Connect revived force arrivals to Bene Gesserit responses
---
components/game-table.tsx | 6 +-
components/homeworld-revival-deployment.tsx | 7 +
docs/CURRENT_STATUS.md | 29 +++
docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md | 84 +++++++
docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md | 11 +
docs/IMPLEMENTATION_STATUS.md | 51 ++++
docs/RULE_DECISIONS.md | 3 +-
game/advisor-automatic-flip.ts | 9 +-
game/ambassador-phase.ts | 2 +-
game/engine.ts | 66 +++--
game/homeworld-arrival.ts | 101 ++++++--
game/homeworld-revival-destinations.ts | 20 +-
game/karama-movement-cancellation.ts | 24 ++
game/reference.ts | 1 +
game/spiritual-advisor.ts | 37 ++-
tests/advisor-automatic-flip-quote.test.ts | 2 +-
tests/fixture-revival-arrival-reactions.ts | 201 +++++++++++++++
...omeworld-revival-ambassador-engine.test.ts | 127 ++++++----
tests/homeworld-revival-destinations.test.ts | 14 +-
...omeworld-revival-spiritual-arrival.test.ts | 237 ++++++++++++++++++
.../revival-arrival-reactions-runtime.test.ts | 179 +++++++++++++
tests/spiritual-advisor-revival.test.ts | 179 +++++++++++++
22 files changed, 1269 insertions(+), 121 deletions(-)
create mode 100644 tests/fixture-revival-arrival-reactions.ts
create mode 100644 tests/homeworld-revival-spiritual-arrival.test.ts
create mode 100644 tests/revival-arrival-reactions-runtime.test.ts
create mode 100644 tests/spiritual-advisor-revival.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 31ade69b..4b14f562 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2544,6 +2544,7 @@ export function GameTable({
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
{g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.>}
+ {g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.>}
)}
{g.response.kind === 'moritaniRetention' &&
@@ -3755,7 +3756,10 @@ export function GameTable({
) : g.decision.kind === 'advisor' ? (
<>
- Another faction has shipped from off-planet. You may send
+ {g.decision.revivalEvent
+ ? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send'
+ : 'Another faction has shipped from off-planet. You may send'}
+ {' '}
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
{g.advanced&&Retained fighters outside the Polar Sink require Polar Sink or decline, unless Intrusion already changed them to advisors. Polar matching and an alone conversion have separate responses.
}
diff --git a/components/homeworld-revival-deployment.tsx b/components/homeworld-revival-deployment.tsx
index c68e5678..0e60bd7a 100644
--- a/components/homeworld-revival-deployment.tsx
+++ b/components/homeworld-revival-deployment.tsx
@@ -54,6 +54,13 @@ function DeploymentChoice({ game, act, busy }: Props) {
{offer.normal} ordinary · {offer.elite} starred · {amount} total
+ {game.players.some(player => player.faction === 'beneGesserit') &&
+ {offer.kind === 'tleilax'
+ ? 'Arrakis placement is followed by the normal Bene Gesserit free-force choice.'
+ : 'In Advanced play, entry into Bene Gesserit fighters permits Intrusion.'}
+ {' '}The revived group is already placed before those responses. Preventing
+ a Bene Gesserit power does not undo your revival or use your ordinary shipment.
+
}
Revival destination
;
-/** Extra phases belong only to the recorded arrival and its actual BG suffixes. */
+/** Extra phases belong only to the recorded arrival and its actual BG arrivals. */
export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
const entry = g.pendingAmbassador;
const candidates: {
diff --git a/game/engine.ts b/game/engine.ts
index 5c3d4b6d..b7390ebc 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -144,7 +144,6 @@ import {
type HomeworldRevivalProgress,
validateHomeworldRevivalReturn,
homeworldRevivalResumeSignature,
- appendHomeworldRevivalAmbassador,
homeworldRevivalArrivalSignature,
type HomeworldRevivalReturn,
} from './homeworld-revival-return';
@@ -1026,6 +1025,7 @@ export type Decision =
shipment: string;
destination?: string;
ambassadorEvent?: string;
+ revivalEvent?: string;
}
| {
kind: 'intrusion';
@@ -1034,7 +1034,8 @@ export type Decision =
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
- followup?: { shipment: string; destination: string };
+ revivalEvent?: string;
+ followup?: { shipment: string; destination: string; revivalEvent?: string };
}
| { kind: 'advisorBattle'; player: string; territories: string[] }
| {
@@ -1110,8 +1111,9 @@ export type ResponseWindow = {
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
+ advisorRevivalEvent?: string;
advisorRemaining?: string[];
- advisorFollowup?: { shipment: string; destination: string };
+ advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
amount?: number;
elite?: number;
recipient?: string;
@@ -1208,6 +1210,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
allyPayment: number;
pools: ReturnType['sources'] | ReturnType['boardSources'];
};
+type RevivalEntryParent = { event: string; spiritual?: SpiritualAdvisorSource };
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
@@ -1977,6 +1980,7 @@ export type Game = {
resume: 'none' | 'wormRide';
turn: number;
phase: number;
+ revivalArrival?: RevivalEntryParent;
}[] | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
@@ -17547,6 +17551,7 @@ function openAmbassadorEntry(
g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string,
sector: number, resume: 'none' | 'wormRide',
arrivalCause?: NonNullable['cause'],
+ revivalArrival?: RevivalEntryParent,
): boolean {
g.pendingAmbassador = {
...(arrivalCause ? { arrivalCause } : {}),
@@ -17567,6 +17572,14 @@ function openAmbassadorEntry(
: [],
};
g.decision = { kind: 'ecazAmbassador', player: owner.id };
+ if (revivalArrival) {
+ const frame = g.homeworldRevivalReturn;
+ requireRule(frame?.stage === 'arrival' && frame.event === revivalArrival.event,
+ 'This entry has lost its original revived group.');
+ g.pendingAmbassador.revivalEvent = frame.event;
+ homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
+ revivalArrival.spiritual));
+ }
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
return true;
}
@@ -17587,6 +17600,7 @@ function openTerritoryEntry(
elite: number,
cause: NonNullable['cause'],
resume: 'none' | 'wormRide' = 'none',
+ revivalArrival?: RevivalEntryParent,
) {
// The arrival's own interaction (guild payment, advisor choice, storm
// protection) settles first; the entry then opens from the automatic pass.
@@ -17595,6 +17609,7 @@ function openTerritoryEntry(
(g.pendingArrivalReaction ??= []).push({
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
turn: g.turn, phase: g.phase,
+ ...(revivalArrival ? { revivalArrival } : {}),
});
return true;
}
@@ -17634,7 +17649,8 @@ function openTerritoryEntry(
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
'Finish the other arrival reaction before opening this entry.');
if (!terrorTokens.length)
- return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause);
+ return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
+ requireRule(!revivalArrival, 'Revival deployment with overlapping Terror and Ambassador reactions is still being implemented.');
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
@@ -18447,6 +18463,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
shipment: p.id,
destination: location(order.territory, order.sector),
ambassadorEvent: event,
+ ...(g.phase === 4 && entry.revivalEvent ? {revivalEvent:entry.revivalEvent} : {}),
};
return;
}
@@ -20524,9 +20541,9 @@ function quoteSpiritualOrder(g:Game,decision:Extract,
}
function spiritualAdvisorChoices(g:Game,id:string){
const decision=g.decision;
- if(!g.advanced||decision?.kind!=='advisor'||decision.player!==id)return null;
+ if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent))return null;
const destination=decision.destination?splitLocation(decision.destination).territory:null;
- const choices=[...(destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
+ const choices=[...(g.advanced&&destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
({accompany:true,territory:destination,sector,amount:1}))??[]:[]),
...Array.from({length:Math.max(1,spiritualAdvisorMaximum(g,id))},(_,index)=>
({accompany:false,territory:'polar_sink',sector:0,amount:index+1}))];
@@ -20546,7 +20563,7 @@ function spiritualAdvisorChoices(g:Game,id:string){
}
function spiritualFrameBody(g:Game){
const frame=g.pendingSpiritualAdvisor;
- requireRule(frame&&g.advanced&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
+ requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
getPlayer(g,frame.decision.player).faction==='beneGesserit'&&
frame.signature===JSON.stringify([frame.event,spiritualParentSignature(g,frame.decision)]),
'Spiritual Advisors lost the original committed foreign shipment.');
@@ -20598,7 +20615,9 @@ function beginSpiritualAdvisor(g:Game,decision:Extract['intent'];
@@ -22099,8 +22119,9 @@ function finishAdvisorReaction(
advisorResume?: ResponseWindow['advisorResume'];
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
+ advisorRevivalEvent?: string;
advisorRemaining?: string[];
- advisorFollowup?: { shipment: string; destination: string };
+ advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
},
) {
const bg = byFaction(g, 'beneGesserit')!;
@@ -22125,7 +22146,8 @@ function intrusion(
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
- followup?: { shipment: string; destination: string };
+ revivalEvent?: string;
+ followup?: { shipment: string; destination: string; revivalEvent?: string };
} = {},
) {
const bg = byFaction(g, 'beneGesserit');
@@ -27959,7 +27981,8 @@ function resumeHomeworldRevivalReturn(g: Game) {
if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance ||
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
- g.pendingTerrorEntry || g.pendingKarama || g.decision) return;
+ g.pendingTerrorEntry || g.pendingKarama || g.decision || g.pendingSpiritualAdvisor ||
+ g.pendingArrivalReaction?.length || g.pendingArrivalOverlap) return;
if (frame.stage === 'arrival') {
if (!g.response) completeHomeworldRevivalReturn(g);
return;
@@ -28014,11 +28037,14 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
if (quote.selected.territory) {
place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
- openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival');
- if (g.pendingAmbassador) {
- g.pendingAmbassador.revivalEvent = frame.event;
- appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador);
- }
+ const bg = byFaction(g, 'beneGesserit');
+ const followup = frame.quote.kind === 'tleilax' && bg && bg.id !== p.id &&
+ spiritualAdvisorMaximum(g, bg.id) > 0
+ ? {shipment:p.id,destination:quote.selected.id,revivalEvent:frame.event} : undefined;
+ if (!intrusion(g, p, quote.selected.territory, {revivalEvent:frame.event,...(followup ? {followup} : {})}) && followup)
+ g.decision = {kind:'advisor',player:bg!.id,...followup};
+ openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount,
+ frame.quote.elite, 'homeworldRevival', 'none', {event:frame.event});
}
log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
{ faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
@@ -33378,7 +33404,8 @@ function settleAutomaticContinuations(g: Game) {
const entrant = g.players.find(p => p.id === deferred.entrant);
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
- deferred.amount, deferred.elite, deferred.cause, deferred.resume);
+ deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
+ if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
continue;
}
const response = g.response;
@@ -35259,6 +35286,7 @@ function applyActionInner(
? ('wormRide' as const)
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
+ advisorRevivalEvent: decision.revivalEvent,
discoveryEntry: decision.discoveryEntry,
};
if (action.accept)
@@ -35297,7 +35325,7 @@ function applyActionInner(
};
} else movementTurn(g);
} else if (decision.kind === 'advisor') {
- if(g.advanced){
+ if(g.advanced||decision.revivalEvent!==undefined){
requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
if(action.accept){
beginSpiritualAdvisor(g,decision,action);
diff --git a/game/homeworld-arrival.ts b/game/homeworld-arrival.ts
index f1e70298..72a3196f 100644
--- a/game/homeworld-arrival.ts
+++ b/game/homeworld-arrival.ts
@@ -1,12 +1,14 @@
import type { Game } from './engine';
-import { location, validLocation } from './board';
+import { location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
+import type { SpiritualAdvisorSource } from './spiritual-advisor';
export type HomeworldAmbassadorArrival = {
event: string;
entrant: string;
destination: string;
parent?: string;
+ spiritual?: SpiritualAdvisorSource;
completed: boolean;
};
export type HomeworldArrivalFrame = {
@@ -42,12 +44,45 @@ function keys(value: unknown, allowed: string[]): boolean {
);
}
+/** This is the public source of an already committed Spiritual Advisor arrival,
+ * not another permission or a reconstruction from current BG reserves. */
+function spiritualSource(
+ frame: HomeworldArrivalFrame,
+ entry: HomeworldAmbassadorArrival,
+): SpiritualAdvisorSource & { revivalEvent: string } {
+ const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
+ if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail();
+ const destination = splitLocation(frame.destination!);
+ if (
+ Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' ||
+ !text(source.event) ||
+ !text(source.owner) ||
+ source.owner !== entry.entrant ||
+ source.owner === frame.player ||
+ source.revivalEvent !== frame.event ||
+ frame.phase !== 4 || source.phase !== 4 ||
+ source.turn !== frame.turn ||
+ !Number.isSafeInteger(source.turn) || source.turn < 1 ||
+ !text(source.territory) ||
+ !Number.isSafeInteger(source.sector) ||
+ !validLocation(source.territory, source.sector) ||
+ !validLocation(destination.territory, destination.sector) ||
+ (source.territory !== destination.territory && source.territory !== 'polar_sink') ||
+ (source.amount !== 1 && source.amount !== 2) ||
+ entry.parent !== undefined ||
+ entry.destination !== location(source.territory, source.sector)
+ ) fail();
+ return source as SpiritualAdvisorSource & { revivalEvent: string };
+}
+
/** Arrival provenance is fixed separately from the original revival grant. */
export function homeworldArrivalSignature(
frame: HomeworldArrivalFrame,
): string {
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
const seen = new Set();
+ const spiritualEvents = new Set();
+ let primary = false;
for (const [index, entry] of frame.ambassadors.entries()) {
if (
!keys(entry, [
@@ -55,6 +90,7 @@ export function homeworldArrivalSignature(
'entrant',
'destination',
'parent',
+ 'spiritual',
'completed',
]) ||
!text(entry.event) ||
@@ -62,14 +98,17 @@ export function homeworldArrivalSignature(
!text(entry.destination) ||
typeof entry.completed !== 'boolean' ||
(index < frame.ambassadors.length - 1 && !entry.completed) ||
- seen.has(entry.event) ||
- (index === 0
- ? entry.parent !== undefined ||
- entry.entrant !== frame.player ||
- entry.destination !== frame.destination
- : !text(entry.parent) || !seen.has(entry.parent))
+ seen.has(entry.event)
)
fail();
+ if (entry.spiritual !== undefined) {
+ const source = spiritualSource(frame, entry);
+ if (spiritualEvents.has(source.event)) fail();
+ spiritualEvents.add(source.event);
+ } else if (entry.parent === undefined) {
+ if (primary || entry.entrant !== frame.player || entry.destination !== frame.destination) fail();
+ primary = true;
+ } else if (index === 0 || !text(entry.parent) || !seen.has(entry.parent)) fail();
seen.add(entry.event);
}
if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
@@ -84,6 +123,18 @@ export function homeworldArrivalSignature(
entry.destination,
entry.parent ?? null,
entry.completed,
+ // Legacy rows remain exactly five values. Only a new independent
+ // Spiritual root adds its canonical, public placement source.
+ ...(entry.spiritual === undefined ? [] : [[
+ entry.spiritual.event,
+ entry.spiritual.owner,
+ entry.spiritual.turn,
+ entry.spiritual.phase,
+ entry.spiritual.territory,
+ entry.spiritual.sector,
+ entry.spiritual.amount,
+ spiritualSource(frame, entry).revivalEvent,
+ ]]),
]),
]);
}
@@ -100,6 +151,7 @@ export function appendHomeworldArrivalAmbassador(
phase: number;
},
parent?: string,
+ spiritual?: SpiritualAdvisorSource,
): void {
if (
frame.stage !== 'arrival' ||
@@ -110,20 +162,23 @@ export function appendHomeworldArrivalAmbassador(
(parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
)
fail();
+ const appended: HomeworldAmbassadorArrival = {
+ event: entry.event,
+ entrant: entry.entrant,
+ destination: location(entry.territory, entry.sector),
+ completed: false,
+ ...(parent === undefined ? {} : { parent }),
+ ...(spiritual === undefined ? {} : { spiritual }),
+ };
const ambassadors = [
...frame.ambassadors!,
- {
- event: entry.event,
- entrant: entry.entrant,
- destination: location(entry.territory, entry.sector),
- completed: false,
- ...(parent === undefined ? {} : { parent }),
- },
+ appended,
];
const arrivalSignature = homeworldArrivalSignature({
...frame,
ambassadors,
});
+ if (spiritual !== undefined) appended.spiritual = structuredClone(spiritual);
frame.ambassadors = ambassadors;
frame.arrivalSignature = arrivalSignature;
}
@@ -175,14 +230,16 @@ export function validateHomeworldArrival(
(frame.stage === 'arrival' &&
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
- frame.ambassadors!.some(
- (entry, index) =>
- !g.players.some(
- (seat) =>
- seat.id === entry.entrant &&
- (index === 0 || seat.faction === 'beneGesserit'),
- ),
- )
+ frame.ambassadors!.some((entry) => {
+ if (entry.spiritual !== undefined) {
+ const entrants = g.players.filter((seat) => seat.id === entry.entrant);
+ const owners = g.players.filter((seat) => seat.id === frame.player);
+ return tag !== 'revivalEvent' || entrants.length !== 1 ||
+ entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || owners[0].faction !== 'tleilaxu';
+ }
+ return !g.players.some((seat) => seat.id === entry.entrant &&
+ (entry.parent === undefined || seat.faction === 'beneGesserit'));
+ })
)
fail();
const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
diff --git a/game/homeworld-revival-destinations.ts b/game/homeworld-revival-destinations.ts
index 89b1e072..28c9d7e9 100644
--- a/game/homeworld-revival-destinations.ts
+++ b/game/homeworld-revival-destinations.ts
@@ -1,14 +1,12 @@
import type { Game } from './engine';
import { gameTerritories, location, MOBILE_STRONGHOLD, splitLocation } from './board';
import { presenceAt } from './force-presence';
-import { fighterCount } from './advisors';
import { territoryEntryBlock } from './occupancy';
import { homeworldContext } from './homeworld-game';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
-import { homeworldSpiritualAdvisorLimit } from './homeworld-mobility';
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
type Context = Pick;
@@ -22,7 +20,7 @@ export type HomeworldRevivalDestination = {
};
/** Only the complete earned group to one destination is admitted here.
- * Shipment-specific reactions remain explicit source boundaries. No token face,
+ * The engine owns provisional SEND-entry reactions and their continuation. No token face,
* opposing hand, spice balance or concealed denomination influences these lists. */
export function homeworldRevivalDestinations(
g: Context,
@@ -33,17 +31,13 @@ export function homeworldRevivalDestinations(
if (!p || !g.homeworlds?.custody)
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
const amount = grant.normal + grant.elite;
- const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
- const moritani = g.players.find((seat) => seat.faction === 'moritani');
- const advisors = grant.kind === 'tleilax' && bg && bg.reserves > 0 &&
- homeworldSpiritualAdvisorLimit(g, bg.id, 'polar_sink') > 0
- ? 'Spiritual Advisors after Tleilax revival deployment await a shipment-classification ruling.' : null;
+ const moritani = g.players.find(seat => seat.faction === 'moritani');
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
return t.sectors.map((sector) => {
const effectiveSector = t.id === MOBILE_STRONGHOLD
? splitLocation(g.mobileStronghold!.location!).sector : sector;
- let blocked = grant.blocked ?? advisors;
+ let blocked = grant.blocked;
if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm)
blocked = grant.kind === 'fedaykin'
? 'Fedaykin revival placement into storm awaits its own permission ruling.'
@@ -51,12 +45,10 @@ export function homeworldRevivalDestinations(
if (!blocked && t.id === MOBILE_STRONGHOLD)
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
- if (!blocked && g.advanced && bg && bg.id !== p.id && fighterCount(bg, t.id) > 0)
- blocked = 'Intrusion after Homeworld revival placement awaits an entry-classification ruling.';
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
- !lowGrummanRevealBlock(g, moritani.id, amount) &&
- g.moritaniTerror?.tokens.some((token) => token.status === 'placed' && token.location === t.id))
- blocked = 'Terror after Homeworld revival placement awaits an entry-classification ruling.';
+ !lowGrummanRevealBlock(g, moritani.id, amount) &&
+ g.moritaniTerror?.tokens.some(token => token.status === 'placed' && token.location === t.id))
+ blocked = 'Revival SEND into Terror awaits its original entry continuation.';
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
territory: t.id, sector, blocked };
});
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index e408d100..34dd5120 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -1,6 +1,8 @@
import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
+import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
+import { HomeworldCustodyError } from './homeworld-custody';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
@@ -512,6 +514,28 @@ export function quoteMovementCancellation(
'Only an Ambassador arrival may carry its event.',
);
if (response.advisorResume === 'wormRide') return null;
+ if (response.advisorRevivalEvent !== undefined) {
+ const parent = g.homeworldRevivalReturn;
+ try { validateHomeworldRevivalReturn(g, parent); }
+ catch (error) {
+ if (error instanceof HomeworldCustodyError) throw new MovementCancellationError(error.message);
+ throw error;
+ }
+ requireContext(g.advanced && response.advisors === true &&
+ parent?.stage === 'arrival' && parent.event === response.advisorRevivalEvent &&
+ parent.turn === g.turn && parent.phase === g.phase && parent.player !== owner.id &&
+ typeof parent.destination === 'string' && sectorKey(g, parent.destination) &&
+ response.location === splitLocation(parent.destination).territory &&
+ response.advisorResume === undefined && response.advisorRemaining === undefined,
+ 'The canceled Intrusion lost its original committed revived-force arrival.');
+ const followup = response.advisorFollowup;
+ requireContext(followup === undefined || (parent.quote.kind === 'tleilax' &&
+ followup.shipment === parent.player && followup.destination === parent.destination &&
+ followup.revivalEvent === parent.event),
+ 'The revived Intrusion lost its original Spiritual Advisor followup.');
+ return { kind: 'advisorFlip', successor: followup &&
+ homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0 ? 'advisor' : 'none' };
+ }
requireContext(
g.phase === 5,
'This advisor cancellation is outside its shipment and movement phase.',
diff --git a/game/reference.ts b/game/reference.ts
index 06051a22..6dd2ffe3 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4587,6 +4587,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
+ 'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
diff --git a/game/spiritual-advisor.ts b/game/spiritual-advisor.ts
index cd17e0eb..212dabd4 100644
--- a/game/spiritual-advisor.ts
+++ b/game/spiritual-advisor.ts
@@ -1,5 +1,6 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
+import type { Game } from './engine';
import { presenceAt, type ForcePresence } from './force-presence';
export type SpiritualAdvisorSource = {
@@ -10,6 +11,7 @@ export type SpiritualAdvisorSource = {
territory: string;
sector: number;
amount: number;
+ revivalEvent?: string;
};
export type SpiritualAdvisorPlayer = ForcePresence & {
@@ -27,6 +29,11 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
status: string;
turn: number;
phase: number;
+ homeworldRevivalReturn?: Pick, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
+ pendingSpiritualAdvisor?: Pick, 'event' | 'turn' | 'phase'> & {
+ decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
+ };
+ pendingAmbassador?: Pick, 'event' | 'revivalEvent' | 'turn' | 'phase' | 'stage'> | null;
};
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
@@ -110,9 +117,10 @@ export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): str
* checked by the original shipment parent, before any physical placement. */
export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, source: SpiritualAdvisorSource): void {
requireSpiritual(record(source) &&
- Object.keys(source).sort().join(',') === 'amount,event,owner,phase,sector,territory,turn' &&
+ Object.keys(source).sort().join(',') === (source.phase === 4
+ ? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') &&
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
- source.phase === ctx.phase && [1, 3, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
+ source.phase === ctx.phase && [1, 3, 4, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 &&
(source.amount === 1 || source.amount === 2),
'Spiritual Advisors lost their exact current-turn public shipment source.');
@@ -120,4 +128,29 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
requireSpiritual(gameTerritories(ctx).some((place) =>
place.id === source.territory && place.sectors.includes(source.sector)),
'Spiritual Advisors need their current board destination and sector.');
+ if (source.phase === 4) {
+ const parent = ctx.homeworldRevivalReturn;
+ requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
+ parent.event === source.revivalEvent && parent.turn === ctx.turn && parent.phase === 4 &&
+ parent.stage === 'arrival' && identifier(parent.destination),
+ 'Revival Spiritual Advisors need their original current-turn arrival parent.');
+ const destination = splitLocation(parent.destination);
+ requireSpiritual(parent.destination === `${destination.territory}:${destination.sector}` &&
+ gameTerritories(ctx).some(place => place.id === destination.territory && place.sectors.includes(destination.sector)),
+ 'Revival Spiritual Advisors need their original board arrival parent.');
+ const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
+ const decision = frame?.decision;
+ const childDestination = identifier(decision?.destination) ? splitLocation(decision.destination) : null;
+ const guildChild = record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 4 &&
+ record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
+ (decision.revivalEvent === undefined || decision.revivalEvent === source.revivalEvent) &&
+ record(ambassador) && ambassador.event === decision.ambassadorEvent && ambassador.revivalEvent === source.revivalEvent &&
+ ambassador.turn === ctx.turn && ambassador.phase === 4 && ambassador.stage === 'arrival' &&
+ childDestination && decision.destination === `${childDestination.territory}:${childDestination.sector}` &&
+ gameTerritories(ctx).some(place => place.id === childDestination.territory && place.sectors.includes(childDestination.sector)) &&
+ source.territory === childDestination.territory;
+ requireSpiritual(source.territory === 'polar_sink' ||
+ (decision?.ambassadorEvent !== undefined ? guildChild : source.territory === destination.territory),
+ 'Revival Spiritual Advisors need the original board arrival, its owned Guild shipment or Polar Sink.');
+ }
}
diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts
index be23df48..80add36f 100644
--- a/tests/advisor-automatic-flip-quote.test.ts
+++ b/tests/advisor-automatic-flip-quote.test.ts
@@ -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 }), []);
}
diff --git a/tests/fixture-revival-arrival-reactions.ts b/tests/fixture-revival-arrival-reactions.ts
new file mode 100644
index 00000000..ef7a9dce
--- /dev/null
+++ b/tests/fixture-revival-arrival-reactions.ts
@@ -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;
diff --git a/tests/homeworld-revival-ambassador-engine.test.ts b/tests/homeworld-revival-ambassador-engine.test.ts
index 698933fd..7ce16390 100644
--- a/tests/homeworld-revival-ambassador-engine.test.ts
+++ b/tests/homeworld-revival-ambassador-engine.test.ts
@@ -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 [
diff --git a/tests/homeworld-revival-destinations.test.ts b/tests/homeworld-revival-destinations.test.ts
index e4607ac1..252c7e13 100644
--- a/tests/homeworld-revival-destinations.test.ts
+++ b/tests/homeworld-revival-destinations.test.ts
@@ -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);
});
diff --git a/tests/homeworld-revival-spiritual-arrival.test.ts b/tests/homeworld-revival-spiritual-arrival.test.ts
new file mode 100644
index 00000000..c6aff140
--- /dev/null
+++ b/tests/homeworld-revival-spiritual-arrival.test.ts
@@ -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;
+
+/** 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 {
+ 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[]) {
+ 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);
+});
diff --git a/tests/revival-arrival-reactions-runtime.test.ts b/tests/revival-arrival-reactions-runtime.test.ts
new file mode 100644
index 00000000..e86db4ff
--- /dev/null
+++ b/tests/revival-arrival-reactions-runtime.test.ts
@@ -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);
+});
diff --git a/tests/spiritual-advisor-revival.test.ts b/tests/spiritual-advisor-revival.test.ts
new file mode 100644
index 00000000..7b01e89d
--- /dev/null
+++ b/tests/spiritual-advisor-revival.test.ts
@@ -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 {
+ 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 = {
+ 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);
+});