diff --git a/components/game-table.tsx b/components/game-table.tsx
index 9feaea09..5b4a1815 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2487,7 +2487,9 @@ export function GameTable({
? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.`
: g.response.automaticAdvisorFlip.producer === 'specialRelocation'
? `${g.response.automaticAdvisorFlip.relocation.kind === 'ambassador' ? 'Fremen Ambassador relocation' : 'The free Discovery force transfer'} is complete. These advisors are now alone and must become fighters before the original arrival continues.`
- : 'The ground movement is complete. These advisors are now alone and must become fighters.'}
+ : g.response.automaticAdvisorFlip.producer === 'spiritualArrival'
+ ? 'The free Spiritual Advisor packet has arrived and its reserves and any earned income stay committed. These advisors are now alone and must become fighters before the original arrival continues.'
+ : 'The ground movement is complete. These advisors are now alone and must become fighters.'}
{' '}Karama prevents only this conversion; the completed action and its costs stay committed.
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
until another faction returns or the group leaves.
@@ -2501,10 +2503,18 @@ export function GameTable({
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
)}
+ {g.response.kind==='advisorFlip'&&g.response.spiritualAdvisorSource&&(
+
+ The free advisor packet must become fighters to match your Polar Sink residents. No free force has arrived yet.
+ {' '}Preventing matching restores your original free-shipment choice; that same packet here remains unavailable for this opportunity.
+ {' '}The other faction’s completed shipment stays committed. Choose another legal packet or decline.
+
+ )}
{g.response.kind === 'advisor' && (
{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.kind === 'moritaniRetention' &&
@@ -3719,8 +3729,9 @@ export function GameTable({
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.guildAmbassadorAdvisorChoices ? (
- g.guildAmbassadorAdvisorChoices.choices.map((choice) => {
+ {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.
}
+ {(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices) ? (
+ (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.map((choice) => {
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
return (
diff --git a/docs/ADVISOR_MATCHING.md b/docs/ADVISOR_MATCHING.md
index 0db71193..114483c3 100644
--- a/docs/ADVISOR_MATCHING.md
+++ b/docs/ADVISOR_MATCHING.md
@@ -78,7 +78,7 @@ proof. Existing-mode completeness, special relocation, Guild/Homeworld,
Planetologist gathering and Spiritual Advisor matching remain open. No saved-
room/D1, reset, restart or deployment operation was performed.
-## Spiritual Advisor source cutover still due
+## Spiritual Advisor source follow-up
Complete physical28 review also requires Polar Sink or decline when the
original shipment destination retains BG fighters, unless Intrusion changes
@@ -93,5 +93,6 @@ Polar Sink with resident fighters. The recommended provisional composition
keeps the named Polar fallback and lets the incoming advisor packet match those
fighters under its own precommit matching counter, after the distinct Spiritual
Advisor shipping counter. The other faction's shipment is already committed
-and cannot be undone. That separate parent/projection integration is pending;
-this normal movement/shipment prototype does not certify it.
+and cannot be undone. The separate parent, target projection and committed
+alone-conversion follow-up is now tracked in [Spiritual Advisors](SPIRITUAL_ADVISORS.md);
+the normal movement/shipment evidence above does not certify that follow-up.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 0f49b7a6..1cb6f7ec 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work
+**Spiritual Advisor targets and conversion — 24 October. Prototyped / Partial.**
+Supplied physical28 redirects retained non-Polar fighters to Polar Sink or
+decline unless Intrusion first changed them. Fresh Advanced declarations now
+share target legality through ordinary/Guild quotes, projected choices, controls
+and minimal bots. Distinct shipping, incoming Polar matching and committed
+alone-conversion responses retain the original foreign shipment. Matching
+prevention restores the owned chooser while blocking that same packet here;
+shipping prevention ends the free packet. An alone-conversion prevention keeps
+the deposited force, withdrawal/income and continuously-alone hold. The Polar
+fallback and matching remedy remain explicit provisional compositions.
+Basic and already-recorded legacy declarations keep their previous protocol.
+[Source, original continuation and limits](SPIRITUAL_ADVISORS.md).
+
+Disjoint helper/native-fixture lanes, bounded source/continuation review and root
+integration pass **22 files390/390**, types/lint/build and fifteen native
+ordinary/Guild declaration/response/GameTable SSR journeys. Public35/37-action
+base33 ordinary sources and45/48-action bare E3 Guild sources earn the original
+setup, actual paid foreign shipment, declined/accepted Intrusion, original
+Karama and independent free shipment. A second genuine foreign shipment joins
+an existing held advisor group without rearming conversion. Two late-stance
+cases are explicitly controlled; recorded-legacy tests are format compatibility,
+not native saved captures. Twenty-four existing-profile paths accept72 actions
+through target choice, distinct responses and restored decline. Feedback updates
+four older matching/projection expectations; no application source repair was
+needed after the frozen runtime passed. No strategic AI work or saved-room/D1/
+server/reset/restart/deployment operation. Wider combinations, other matching
+producers and final browser/deployed/mode assurance remain open.
+
**Matching incoming BG groups — 24 October. Prototyped / Partial.**
Normal opposite-type ground arrivals and reserve-to-advisor shipments now offer
Karama before physical transfer/payment. Supplied physical28 E explicitly marks
@@ -40,15 +68,10 @@ No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/
reset/restart/deployment operation. Wider transport, gathering and mode acceptance
remain open.
-Bounded complete-page review also identifies a remaining Advanced Spiritual
-Advisor source cutover: retained fighters at the original shipment destination
-require Polar Sink or decline unless Intrusion changes them first. Polar Sink
-fallback with resident fighters provisionally needs its own incoming matching
-counter after the distinct shipping counter. Ordinary/Guild Ambassador target
-quotes, projected choices and bots must share that restriction; do not apply
-it to ordinary paid shipments. That independent Spiritual-parent integration
-and special relocation/gather matching remain explicitly open after the normal
-arrival prototype. Full rule/mode acceptance is still unfinished.
+The separate Advanced Spiritual target, matching and committed-alone source
+follow-up is now prototyped above. It does not change ordinary paid shipments'
+target permissions. Special relocation/gather matching and wider combinations
+remain open; full rule/mode acceptance is still unfinished.
**Special-relocation advisor conversion — 24 October. Prototyped / Partial.**
Fremen Ambassador and Discovery free-entry transfers now capture surviving alone
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 4075d4da..70d900b2 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,48 @@
# Dune implementation status
+## 24 October 2026 — Spiritual Advisor targets, matching and alone conversion
+
+Supplied physical28 starts the free packet advisor-side and directs retained
+non-Polar fighters to Polar Sink or decline unless Intrusion changed them.
+Shared ordinary/Guild quotes, projected choices, controls and minimal bots now
+apply that source cutover. Basic and already-recorded legacy declarations retain
+their old protocol. Fresh Advanced declarations bind a public seven-field
+receipt to the private original chooser, committed foreign parent and order.
+
+Shipping prevention ends the free packet. Provisionally allow the named Polar
+fallback with fighters, then offer a distinct incoming matching counter before
+reserve withdrawal. Prevented matching restores the chooser and blocks that
+same packet/destination for the original grant. Placement then commits native
+withdrawal and income once. A separately prevented mandatory alone conversion
+retains that arrival and its continuously-alone hold, including when a later
+genuine grant joins it. Revalidate availability before matching/placement;
+never re-quote resources after withdrawal. Retire the frame before original
+ordinary/Guild arrival resumes; retain native child, worm and Homeworld suffixes.
+
+Disjoint helper/native-fixture lanes, bounded source/continuation review and root
+integration pass **22 files390/390**, types/lint/build and fifteen native
+ordinary/Guild declaration/response/GameTable SSR journeys. Genuine35/37-action
+public base33 ordinary and45/48-action bare E3 Guild sources earn setup,
+original Karama, paid foreign shipment, actual Intrusion answers and the free
+packet. Both matching and alone outcomes, shipping prevention, retained-fighter
+target rejection, accepted Intrusion and a second real grant are exercised.
+Two late-stance cases are explicitly controlled. Legacy tests are controlled
+format compatibility, not native earned actions or historical-save captures.
+Twenty-four existing-profile paths accept72 legal actions. Feedback updates
+four old matching/projection expectations; their focused repairs and the final
+union pass. The first native runtime needed no application source fix.
+
+Private proof:
+`/home/quintine/.local/share/dune/checkpoints/20261024-spiritual-advisor-flow/`.
+`frozen-integrated-2/report.json` binds tree
+`403d1a46ccdc0fbe353546a870d858fdb80340cb2c20533e87667dd414df6fe0`,
+2757 files on base `438a5692a644305360199da920760e25fee8d15f`.
+Final prose/reference overlays are reviewed separately. Full Homeworld/Tech/
+Nexus/No-Field combinations, other matching producers, browser/deployed/mode
+acceptance and final assurance remain open. No AI refinement or saved-room/D1/
+server/reset/restart/deployment operation.
+[Source, stages and continuation](SPIRITUAL_ADVISORS.md).
+
## 24 October 2026 — Matching incoming advisor packets before arrival
Supplied physical28 E explicitly marks matching shipping/moving into resident
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 483ced5a..3d1d0b30 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,26 @@
# Rule decision index
+## Spiritual Advisor targets and distinct conversions — 24 October 2026
+
+Supplied physical28 E ships an advisor and redirects retained non-Polar fighters
+to Polar Sink or decline unless Intrusion first changed them. Basic and already-
+recorded legacy declarations retain their old protocol; fresh Advanced
+declarations carry a new source/private parent. Share target legality across
+normal/Guild quotes, choices, controls and bots; keep resource quotes separate.
+Physical17's shipping and group-flip counters are distinct. An empty Advanced
+arrival remains advisor-side until its separately counterable mandatory alone
+conversion; there is no printed Polar Sink exemption from that conversion.
+
+Under the 14 October authority, provisionally permit the named Polar fallback
+even when it is the original destination with fighters, offering a separate
+incoming matching counter before placement. Prevention restores the original
+chooser while denying the same packet/destination in that grant; shipping
+prevention ends the free packet. Neither undoes the other faction's committed
+shipment. After placement, prevent only conversion while keeping forces/income
+and the existing continuously-alone hold. Preserve original ordinary/Guild
+arrivals once; never rerun reserve availability after withdrawal.
+[Source, stages, compatibility boundary and limits](SPIRITUAL_ADVISORS.md).
+
## Matching incoming advisor groups — 24 October 2026
Supplied physical28 E directly marks matching into resident BG forces as
diff --git a/docs/SPIRITUAL_ADVISORS.md b/docs/SPIRITUAL_ADVISORS.md
new file mode 100644
index 00000000..1a114f4e
--- /dev/null
+++ b/docs/SPIRITUAL_ADVISORS.md
@@ -0,0 +1,113 @@
+# Spiritual Advisor shipment, matching and conversion
+
+## Advanced source cutover — 24 October 2026
+
+**Prototyped / Partial.** Supplied revised Advanced
+physical28/printed26 ships an advisor, permits accompanying the original
+shipment or using Polar Sink, and redirects retained non-Polar fighters to
+Polar Sink or decline. Original Intrusion may first change those residents to
+advisors. The same page's mandatory alone conversion has no Polar Sink exception;
+physical17's group-flip counter remains separate from its shipping counter.
+Do not treat the free packet as an ordinary paid fighter shipment.
+
+Normal and Guild Ambassador declarations, the Guild quote, projected choices,
+human controls and minimal bots now share the target restriction. Homeworld
+reserve/population limits remain resource quotes, independently of target
+legality. Basic is unchanged. Already-recorded declarations without the new
+Spiritual source retain their original one-shot settlement protocol; fresh
+Advanced declarations use an explicit seven-field source and private frame.
+This is a source cutover, not permission to reset or recreate saved games.
+
+## Original grant and distinct responses
+
+The public source contains event, owner, turn, phase, territory, sector and
+amount. A private frame retains the original owned chooser, committed foreign
+parent, selected order and exact signatures. Current and parked responses own
+that same source. No private parent, No-Field identity, resource proof or
+signature is copied into response/Karama source selection.
+
+1. **Shipping.** Choose a legal target and ordinary reserve amount, then offer
+ the separate Spiritual Advisor shipping counter. Prevention ends this free
+ packet without a reserve withdrawal. The other faction's original physical
+ shipment and payment are already committed.
+2. **Matching.** If the named Polar fallback has BG fighters, the incoming
+ advisor packet must match them. Under the 14 October recommended policy,
+ provisionally allow that Polar fallback even if it was the original shipment
+ destination, then offer matching before placement. Residents never change.
+ Prevention restores the original owned chooser. That same packet/destination
+ is unavailable for this original foreign-shipment opportunity; changing
+ sector or accompanying/fallback labels cannot retry it. A different legal
+ amount or decline remains available. Neither page expressly resolves this
+ Polar corner or cancellation remedy; do not call it a publisher exception.
+3. **Placement and alone conversion.** Withdraw actual native reserves, place
+ the free packet, grant original Tech income and log placement once. Advanced
+ arrivals start advisor-side unless matching into resident fighters. If alone,
+ capture the whole actual group before settlement can silently make it a
+ fighter, then offer the independent conversion counter. Prevention retains
+ the actual arrival, withdrawal/income and existing provisional continuously-
+ alone hold. Joining an existing held advisor group preserves that occurrence
+ rather than rearming conversion.
+
+Revalidate target/resource availability before placement, including before
+offering matching. A no-longer-available declaration restores the original
+chooser without moving a force. Never rerun reserve or population availability
+after withdrawal; validate the committed deposit and original parent instead.
+
+## Original continuation
+
+The queued ordinary foreign arrival stays paused while the frame owns its
+choice or responses. Clear the frame and immediately open the actual BG arrival
+before draining that queue. Guild Ambassador keeps its original `advisor` stage
+until all counters finish. Its extracted suffix records `advisorArrival` and
+`finish`, then retains the existing child/outer-continuation handoff. An eligible
+BG child retires the Guild parent first; original worm/Homeworld tags and
+`guildAdvisorOrigin` follow the original suffix. No transfer, income, token use
+or original foreign entry is replayed.
+
+The legacy path is explicitly for an already-declared response format, not an
+option exposed to current callers. Controlled legacy tests preserve those
+callback checks without claiming fresh permission or a genuine saved-game
+capture. Complete recovery/custody/privacy assurance stays in the final phase.
+
+## Prototype evidence and limits
+
+[Native fixture](../tests/fixture-spiritual-advisor-flow.ts) earns ordinary
+public Advanced BG/Atreides/Emperor sources and bare E3 BG/Ecaz/Atreides Guild
+Ambassador sources with original base33 and Atreides' original Karama. Original
+BG setup in empty Tuek's or Polar Sink becomes fighters. Actual foreign shipping
+then earns declined Intrusion, accepted Intrusion/advisors, or Polar fighters.
+Guild sources earn their original packet, paid token/entrant and actual free
+child shipment. The earlier ordinary accompaniment is explicitly declined.
+No played force, hand, wallet, phase, stance or new response is installed.
+
+[Connected cases](../tests/spiritual-advisor-engine.test.ts) cover fresh target
+rejection, separate shipping prevention, both Polar matching/alone outcomes,
+accepted Intrusion, exact parent binding and a second genuine foreign shipment
+joining a held group. Explicit controlled late-stance cases test revalidation.
+The final frozen batch passes **22 files390/390**, types/lint/build, fifteen
+native declaration/response/GameTable SSR journeys and two explicitly controlled
+late-stance cases. Ordinary sources use35 actions for retained/Polar fighters or
+37 for accepted Intrusion; Guild sources use45 or48 respectively. The fifteen
+journeys cover target rejection/decline, shipping prevention, both matching and
+alone-conversion outcomes, same-type Intrusion and the second genuine foreign
+grant joining a held group. Twenty-four existing-profile paths across four
+difficulties, two parents and three stances accept72 actions through choices,
+shipping and later responses or restored decline. This is minimal legal
+participation, without strategic refinement.
+
+Feedback updates four older assertions to follow the independent matching
+response and preferred public choices. The focused four repairs pass before
+the final union. The first native runtime passed without application fixes.
+Private source proof lives in
+`/home/quintine/.local/share/dune/checkpoints/20261024-spiritual-advisor-flow/`:
+`frozen-integrated-2/report.json` binds tree
+`403d1a46ccdc0fbe353546a870d858fdb80340cb2c20533e87667dd414df6fe0`,
+2757 files on base `438a5692a644305360199da920760e25fee8d15f`.
+Only checkpoint prose/reference overlays follow the frozen proof; their links,
+evidence paths and source invariance are separately reviewed.
+
+Full Homeworld/Tech/Nexus/No-Field combinations, other matching producers,
+all valid modes and final browser/deployed assurance remain open. Standard
+controls are reused; SSR is not callback/hydration or desktop/mobile browser
+acceptance. No saved-room/D1/server/reset/restart/deployment operation was needed.
+No strategic AI work is authorized by this prototype.
diff --git a/game/advisor-automatic-flip.ts b/game/advisor-automatic-flip.ts
index b62db767..12f4c5b6 100644
--- a/game/advisor-automatic-flip.ts
+++ b/game/advisor-automatic-flip.ts
@@ -2,6 +2,7 @@ import type { FactionId } from './catalog';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
import { stormExposesTerritory, stormSectorAfter } from './disaster-rules';
+import { validateSpiritualAdvisorSource, type SpiritualAdvisorSource } from './spiritual-advisor';
export type AdvisorStance = {
lockedTurn?: number;
@@ -93,6 +94,7 @@ export type AutomaticAdvisorFlipSource = {
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
+ | { producer: 'spiritualArrival'; spiritual: SpiritualAdvisorSource }
);
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
@@ -439,6 +441,23 @@ function validateRelocationSource(
'Automatic advisor conversion lost the original relocated actor at its destination.');
}
+function validateSpiritualArrivalSource(
+ ctx: AutomaticAdvisorFlipContext,
+ source: Extract
,
+) {
+ try { validateSpiritualAdvisorSource(ctx, source.spiritual); }
+ catch (cause) {
+ throw new AutomaticAdvisorFlipError(cause instanceof Error ? cause.message : 'The public Spiritual Advisor source changed.');
+ }
+ const receipt = source.spiritual;
+ requireFlip(receipt.owner === source.owner && receipt.territory === source.territory,
+ 'Automatic advisor conversion must use the original Spiritual Advisor owner and target.');
+ const owner = ctx.players.find((player) => player.id === source.owner)!;
+ const deposited = owner.forces[`${receipt.territory}:${receipt.sector}`];
+ requireFlip(nonnegative(deposited) && deposited >= receipt.amount,
+ 'Automatic advisor conversion lost its completed Spiritual Advisor placement.');
+}
+
/** Validate the same committed producer and exact held group before either response outcome. */
export function validateAutomaticAdvisorFlip(
ctx: AutomaticAdvisorFlipContext,
@@ -460,12 +479,15 @@ export function validateAutomaticAdvisorFlip(
(source.producer === 'storm' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn') ||
(source.producer === 'specialRelocation' &&
- Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn')) &&
+ Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn') ||
+ (source.producer === 'spiritualArrival' &&
+ Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,spiritual,territory,turn')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
positive(source.turn) && source.turn === ctx.turn &&
(source.producer === 'specialRelocation'
? record(source.relocation) && (source.relocation.kind === 'discovery'
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
+ : source.producer === 'spiritualArrival' ? [1, 3, 5, 8].includes(source.phase)
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
currentWindow(ctx) && source.phase === ctx.phase,
@@ -487,7 +509,8 @@ export function validateAutomaticAdvisorFlip(
else if (source.producer === 'battleLosses') validateBattleSource(ctx, source);
else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source);
else if (source.producer === 'storm') validateStormSource(ctx, source);
- else validateRelocationSource(ctx, source);
+ else if (source.producer === 'specialRelocation') validateRelocationSource(ctx, source);
+ else validateSpiritualArrivalSource(ctx, source);
const forces = physicalGroup(owner, source.territory);
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
diff --git a/game/bots.ts b/game/bots.ts
index 443e03ae..28e80353 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -2695,8 +2695,11 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
- if (d.kind === 'advisor' && g.guildAmbassadorAdvisorChoices) {
- const choices = g.guildAmbassadorAdvisorChoices.choices.filter(
+ if(d.kind==='advisor'&&!d.ambassadorEvent&&(liveShipmentPromises(g.shipmentPromises??[],me.id,g.turn).length ||
+ liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer)))
+ return [{type:'decision',accept:false}];
+ if (d.kind === 'advisor' && (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)) {
+ const choices = (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.filter(
(choice) => !choice.blocked,
);
return [
diff --git a/game/engine.ts b/game/engine.ts
index d89efa0b..af35e6da 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -610,6 +610,8 @@ import {
import { canUseAsKaramaRole } from './shrine';
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
+import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
+ spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
import {
isAdvisor,
fighterCount,
@@ -1070,6 +1072,7 @@ export type ResponseWindow = {
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
+ spiritualAdvisorSource?: SpiritualAdvisorSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
bureaucratPayment?: BureaucratPaymentSource;
@@ -1292,6 +1295,19 @@ export type Game = {
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
};
advisorMatchingDenied?: string[];
+ pendingSpiritualAdvisor?: {
+ event: string;
+ turn: number;
+ phase: number;
+ decision: Extract;
+ signature: string;
+ stage: 'offer' | 'shipping' | 'matching' | 'arrival';
+ denied: string[];
+ source?: SpiritualAdvisorSource;
+ selection?: string;
+ order?: {player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean};
+ deposit?: {key:string;before:number};
+ };
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -14607,6 +14623,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
+ if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
try {
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
@@ -14737,6 +14754,10 @@ function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
}
}
function terminalCancellationQuote(g: Game, response: ResponseWindow) {
+ if(response.spiritualAdvisorSource){
+ currentSpiritualAdvisorResponse(g,response);
+ return response.kind==='advisor'?{kind:'advisor' as const,owner:response.owner}:null;
+ }
try {
return validateTerminalCancellation(g, response);
} catch (error) {
@@ -17677,6 +17698,12 @@ function ambassadorRelocationIntegrity(g: Game) {
'The saved Guild Ambassador arrival has an invalid continuation stage.',
);
const { order } = guildAmbassadorArrival(g, entry.event, next);
+ if(next==='advisor' && g.pendingSpiritualAdvisor?.decision.ambassadorEvent===entry.event){
+ requireRule(!relatedResponses.length && !relatedDecisions.length && !relatedTerror,
+ 'The original Guild shipment must wait for its owned Spiritual Advisor flow.');
+ spiritualAdvisorIntegrity(g);
+ return;
+ }
for (const response of relatedResponses) {
requireRule(
response.advisorResume === 'ambassador' &&
@@ -18447,7 +18474,7 @@ function finishGuildAmbassadorAdvisor(
sector: at.sector,
territory: at.territory,
amount: response.amount ?? 1,
- }),
+ },true),
);
validateGuildAdvisorEntry(g, quote);
const bg = getPlayer(g, quote.player);
@@ -18456,6 +18483,14 @@ function finishGuildAmbassadorAdvisor(
techIncome(g, 'heighliners', bg);
if (quote.advisors)
(bg.advisors ??= {})[quote.territory] = { ...bg.advisors?.[quote.territory], lockedTurn: g.turn };
+ logSpiritualAdvisorPlacement(g,quote,true);
+ finishGuildAmbassadorAdvisorArrival(g,response,quote);
+}
+function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
+ quote:ReturnType){
+ const event=response.advisorAmbassadorEvent!;
+ const {order}=guildAmbassadorArrival(g,event,'advisor');
+ const bg=getPlayer(g,quote.player);
const receipt = g.pendingAmbassador!.shipmentReceipt!;
receipt.next = 'finish';
receipt.advisorArrival = {
@@ -18465,11 +18500,6 @@ function finishGuildAmbassadorAdvisor(
amount: quote.amount,
elite: 0,
};
- log(
- g,
- `${bg.name} sent ${quote.amount} free reserve ${quote.amount === 1 ? 'force' : 'forces'} to ${territory(quote.territory).name}, sector ${quote.sector}, after the Guild Ambassador shipment${quote.amount === 2 ? ' using high-population Wallach IX' : ''}.`,
- { faction: bg.faction, name: 'Spiritual Advisors' },
- );
if (guildAdvisorAmbassador(g, quote)) {
const parent = g.pendingAmbassador!;
const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' :
@@ -20347,6 +20377,207 @@ function automaticAdvisorRule(quote: () => T): T {
try { return quote(); }
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
}
+function spiritualRule(quote:()=>T):T{
+ try{return quote();}catch(error){if(error instanceof SpiritualAdvisorError)throw new RuleError(error.message);throw error;}
+}
+function logSpiritualAdvisorPlacement(g:Game,order:{player:string;territory:string;sector:number;amount:number},ambassador:boolean){
+ const bg=getPlayer(g,order.player);
+ log(g,ambassador
+ ? `${bg.name} sent ${order.amount} free reserve ${order.amount===1?'force':'forces'} to ${territory(order.territory).name}, sector ${order.sector}, after the Guild Ambassador shipment${order.amount===2?' using high-population Wallach IX':''}.`
+ : `${bg.name} sent ${order.amount} free ${order.amount===1?'force':'forces'} to ${territory(order.territory).name}${order.amount===2?' using high-population Wallach IX':''}.`,
+ {faction:bg.faction,name:'Spiritual Advisors'});
+}
+function spiritualParentSignature(g:Game,decision:Extract){
+ return JSON.stringify([g.turn,g.phase,g.active,decision,decision.ambassadorEvent
+ ? g.pendingAmbassador : g.pendingArrivalReaction??null]);
+}
+function quoteSpiritualOrder(g:Game,decision:Extract,action:Action){
+ const bg=getPlayer(g,decision.player);
+ let order:{player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean};
+ if(decision.ambassadorEvent){
+ const {order:shipment}=guildAmbassadorArrival(g,decision.ambassadorEvent,'advisor');
+ order=guildAmbassadorRule(()=>quoteGuildAmbassadorAdvisor(g,bg.id,shipment,action));
+ }else{
+ requireRule(action.accompany===undefined || typeof action.accompany==='boolean','Choose an accompaniment option.');
+ const target=action.accompany===true&&g.advanced?decision.destination:'polar_sink:0';
+ requireRule(target,'This shipment has no accompanying destination.');
+ const point=splitLocation(target);
+ if(action.accompany===true&&g.advanced&&action.sector!==undefined)
+ point.sector=integer(action.sector,0,18,'Sector');
+ requireRule(action.territory===undefined||action.territory===point.territory,'Use the original shipment territory or Polar Sink.');
+ const amount=integer(action.amount===undefined?1:action.amount,1,spiritualAdvisorMaximum(g,bg.id,point.territory),'Spiritual Advisor forces');
+ order={player:bg.id,territory:point.territory,sector:point.sector,amount,elite:0,advisors:false};
+ }
+ const block=spiritualRule(()=>spiritualAdvisorTargetBlock(g,bg.id,order.territory));
+ requireRule(!block,block??'This Spiritual Advisor destination is unavailable.');
+ const arrival=spiritualRule(()=>spiritualAdvisorArrival(g,bg.id,order.territory));
+ order.advisors=arrival.advisors;
+ allowedEntry(g,bg,order.territory,order.sector,false,order.advisors);
+ if(decision.ambassadorEvent){
+ const quote={...order,amount:order.amount as 1|2};
+ validateGuildAdvisorEntry(g,quote);
+ if(arrival.alone&&!bg.advisors?.[order.territory]?.aloneFlipHold)
+ validateGuildAdvisorEntry(g,{...quote,advisors:false});
+ }
+ return order;
+}
+function spiritualAdvisorChoices(g:Game,id:string){
+ const decision=g.decision;
+ if(!g.advanced||decision?.kind!=='advisor'||decision.player!==id)return null;
+ const destination=decision.destination?splitLocation(decision.destination).territory:null;
+ const choices=[...(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}))];
+ return {choices:choices.map(choice=>{
+ try{
+ const order=quoteSpiritualOrder(g,decision,{type:'decision',...choice});
+ const frame=g.pendingSpiritualAdvisor;
+ if(frame&&spiritualAdvisorArrival(g,id,choice.territory).matching){
+ const source={event:frame.event,owner:id,turn:g.turn,phase:g.phase,
+ territory:order.territory,sector:order.sector,amount:order.amount};
+ requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
+ 'Matching this free packet here was prevented. Choose another legal packet or decline.');
+ }
+ return {...choice,blocked:null as string|null};
+ }catch(error){if(!(error instanceof RuleError))throw error;return {...choice,blocked:error.message};}
+ })};
+}
+function spiritualFrameBody(g:Game){
+ const frame=g.pendingSpiritualAdvisor;
+ requireRule(frame&&g.advanced&&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.');
+ if(frame.stage!=='offer'){
+ requireRule(frame.source&&frame.order&&frame.selection===JSON.stringify([frame.source,frame.order])&&
+ frame.source.event===frame.event&&frame.source.owner===frame.decision.player&&
+ frame.order.player===frame.source.owner&&frame.order.territory===frame.source.territory&&
+ frame.order.sector===frame.source.sector&&frame.order.amount===frame.source.amount,
+ 'The Spiritual packet lost its exact original declaration.');
+ spiritualRule(()=>validateSpiritualAdvisorSource(g,frame.source!));
+ }
+ if(frame.stage==='arrival'){
+ const deposit=frame.deposit,source=frame.source!,owner=getPlayer(g,source.owner);
+ requireRule(deposit&&deposit.key===location(source.territory,source.sector)&&
+ owner.forces[deposit.key]===deposit.before+source.amount,
+ 'The committed Spiritual packet lost its actual physical deposit.');
+ }
+ return frame;
+}
+function spiritualAdvisorIntegrity(g:Game){
+ if(!g.pendingSpiritualAdvisor)return;
+ const frame=spiritualFrameBody(g),continuation=g.pendingTreacheryDiscard?.continuation;
+ const controls=[g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
+ g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume,
+ continuation&&'resume'in continuation?continuation.resume:null];
+ const responses=controls.flatMap(c=>c?.response?[c.response]:[]);
+ for(const {pending}of savedKaramaContexts(g))if(pending.use.kind==='cancel')responses.push(pending.use.response);
+ if(frame.stage==='offer'){
+ requireRule(!frame.source&&!frame.order&&controls.some(c=>JSON.stringify(c?.decision)===JSON.stringify(frame.decision)),
+ 'Restore the original owned Spiritual Advisor chooser.');
+ }else if(frame.stage==='arrival'){
+ requireRule(responses.some(r=>r.automaticAdvisorFlip?.producer==='spiritualArrival'&&
+ JSON.stringify(r.automaticAdvisorFlip.spiritual)===JSON.stringify(frame.source)),
+ 'The committed Spiritual arrival lost its pending alone conversion.');
+ }else requireRule(responses.some(r=>JSON.stringify(r.spiritualAdvisorSource)===JSON.stringify(frame.source)&&
+ r.kind===(frame.stage==='shipping'?'advisor':'advisorFlip')),
+ 'The original Spiritual packet lost its owned shipping or matching response.');
+}
+function beginSpiritualAdvisor(g:Game,decision:Extract,action:Action){
+ const order=quoteSpiritualOrder(g,decision,action);
+ let frame=g.pendingSpiritualAdvisor;
+ if(frame){
+ spiritualFrameBody(g);
+ requireRule(frame.stage==='offer'&&JSON.stringify(frame.decision)===JSON.stringify(decision),
+ 'Finish the original Spiritual Advisor opportunity first.');
+ }else{
+ const event=crypto.randomUUID();
+ frame={event,turn:g.turn,phase:g.phase,decision:structuredClone(decision),
+ signature:JSON.stringify([event,spiritualParentSignature(g,decision)]),stage:'offer',denied:[]};
+ }
+ const source:SpiritualAdvisorSource={event:frame.event,owner:decision.player,turn:g.turn,phase:g.phase,
+ territory:order.territory,sector:order.sector,amount:order.amount};
+ if(spiritualAdvisorArrival(g,source.owner,source.territory).matching)
+ requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
+ 'Matching this free packet here was prevented. Choose another legal packet or decline.');
+ frame.source=source;frame.order=order;frame.selection=JSON.stringify([source,order]);frame.stage='shipping';
+ g.pendingSpiritualAdvisor=frame;
+ g.response={kind:'advisor',owner:source.owner,location:location(source.territory,source.sector),
+ amount:source.amount,passed:[],spiritualAdvisorSource:source};
+}
+function currentSpiritualAdvisorResponse(g:Game,response:ResponseWindow){
+ const frame=spiritualFrameBody(g),source=response.spiritualAdvisorSource;
+ requireRule(source&&JSON.stringify(source)===JSON.stringify(frame.source)&&response.owner===source.owner&&
+ response.location===location(source.territory,source.sector)&&response.amount===source.amount&&
+ !response.advisorResume&&!response.advisorAmbassadorEvent&&!response.automaticAdvisorFlip&&
+ !response.advisorMatchingSource&&!response.advisorPreparationSource&&
+ (frame.stage==='shipping'&&response.kind==='advisor'||
+ frame.stage==='matching'&&response.kind==='advisorFlip'&&response.advisors===false),
+ 'The Spiritual response lost its original shipping or matching packet.');
+ return frame;
+}
+function restoreSpiritualAdvisorOffer(g:Game,matchingPrevented:boolean){
+ const frame=spiritualFrameBody(g);
+ if(matchingPrevented)frame.denied.push(spiritualAdvisorMatchingKey(frame.source!));
+ delete frame.source;delete frame.order;delete frame.selection;delete frame.deposit;frame.stage='offer';
+ g.decision=structuredClone(frame.decision);
+ log(g,matchingPrevented?'Spiritual Advisor matching was prevented before the free packet arrived. Choose another legal packet or decline; the original foreign shipment stays committed.':
+ 'The free Spiritual packet is no longer available as declared. Choose a legal replacement or decline; the original foreign shipment stays committed.');
+}
+function finishSpiritualAdvisorResponse(g:Game,response:ResponseWindow,canceled:boolean){
+ const frame=currentSpiritualAdvisorResponse(g,response);
+ if(canceled){
+ if(frame.stage==='matching'){restoreSpiritualAdvisorOffer(g,true);return;}
+ delete g.pendingSpiritualAdvisor;
+ log(g,'Karama prevented the free Spiritual Advisor shipment. The original foreign shipment remains committed.');
+ if(frame.decision.ambassadorEvent)finishAmbassador(g);
+ return;
+ }
+ let order:NonNullable;
+ try{order=quoteSpiritualOrder(g,frame.decision,{type:'decision',accompany:frame.source!.territory!=='polar_sink',
+ territory:frame.source!.territory,sector:frame.source!.sector,amount:frame.source!.amount});}
+ catch(error){if(!(error instanceof RuleError))throw error;restoreSpiritualAdvisorOffer(g,false);return;}
+ if(frame.stage==='shipping'&&spiritualAdvisorArrival(g,frame.source!.owner,frame.source!.territory).matching){
+ frame.order=order;frame.selection=JSON.stringify([frame.source,order]);
+ frame.stage='matching';g.response={kind:'advisorFlip',owner:frame.source!.owner,
+ location:response.location,amount:response.amount,advisors:false,passed:[],spiritualAdvisorSource:frame.source};
+ return;
+ }
+ const bg=getPlayer(g,order.player),key=location(order.territory,order.sector);
+ frame.order=order;frame.selection=JSON.stringify([frame.source,order]);
+ frame.deposit={key,before:bg.forces[key]??0};frame.stage='arrival';
+ withdrawNativeReserves(g,bg,order.amount,0);place(bg,order.territory,order.sector,order.amount);
+ techIncome(g,'heighliners',bg);
+ if(order.advisors)(bg.advisors??={})[order.territory]={...bg.advisors?.[order.territory],lockedTurn:g.turn};
+ else if(bg.advisors)delete bg.advisors[order.territory];
+ logSpiritualAdvisorPlacement(g,order,!!frame.decision.ambassadorEvent);
+ const groups=automaticAdvisorRule(()=>quoteAutomaticAdvisorFlips(g)).filter(group=>group.territory===order.territory);
+ for(const group of groups){
+ requireRule(!g.pendingAutomaticAdvisorFlips?.length,'Finish the original advisor conversion before this Spiritual arrival.');
+ const source:AutomaticAdvisorFlipSource={event:crypto.randomUUID(),owner:group.player,
+ territory:group.territory,turn:g.turn,phase:g.phase,forces:group.forces,
+ producer:'spiritualArrival',spiritual:frame.source!};
+ getPlayer(g,group.player).advisors![group.territory].aloneFlipHold=source.event;
+ (g.pendingAutomaticAdvisorFlips??=[]).push(source);
+ }
+ if(groups.length){openAutomaticAdvisorFlip(g);return;}
+ completeSpiritualAdvisorArrival(g);
+}
+function completeSpiritualAdvisorArrival(g:Game){
+ const frame=spiritualFrameBody(g),order=frame.order!;
+ requireRule(frame.stage==='arrival'&&!g.pendingAutomaticAdvisorFlips?.length,
+ 'Finish the committed Spiritual conversion before arrival.');
+ const bg=getPlayer(g,order.player);
+ delete g.pendingSpiritualAdvisor;
+ if(frame.decision.ambassadorEvent){
+ finishGuildAmbassadorAdvisorArrival(g,{kind:'advisor',owner:bg.id,passed:[],
+ advisorAmbassadorEvent:frame.decision.ambassadorEvent},
+ {...order,amount:order.amount as 1|2,advisors:isAdvisor(bg,order.territory)});
+ }else{
+ openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
+ }
+}
function matchingAdvisorRule(quote: () => T): T {
try { return quote(); }
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
@@ -20445,6 +20676,11 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
'The mandatory conversion has lost its original completed relocation.');
validateRelocationAdvisorParent(g, parent);
}
+ if(source.producer==='spiritualArrival'){
+ const frame=spiritualFrameBody(g);
+ requireRule(frame.stage==='arrival'&&JSON.stringify(frame.source)===JSON.stringify(source.spiritual),
+ 'The mandatory conversion lost its committed Spiritual Advisor packet.');
+ }
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
return source;
}
@@ -22874,7 +23110,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
function victory(g: Game, quote?: VictoryQuote) {
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
- !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching,
+ !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingSpiritualAdvisor,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -28666,7 +28902,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
);
} else if (response.kind === 'advisorFlip') {
const bg = getPlayer(g, response.owner);
- if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
+ if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
+ else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
else if (response.advisorResume === 'planetologistGather') {
movementCancellationQuote(g, response);
const pending = response.advisorGatherMove!;
@@ -28697,7 +28934,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
if (automaticAdvisorFlip) {
if (canceled)
- log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
+ log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : automaticAdvisorFlip.producer === 'spiritualArrival' ? 'free Spiritual force placement and any income stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
g.pendingAutomaticAdvisorFlips!.shift();
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
@@ -28709,6 +28946,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
finishAutomaticAdvisorFaceDance(g);
finishAutomaticAdvisorStorm(g);
finishAutomaticAdvisorRelocation(g);
+ if(g.pendingSpiritualAdvisor?.stage==='arrival')completeSpiritualAdvisorArrival(g);
} else openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
}
@@ -29006,6 +29244,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
} else log(g, 'Karama prevented the Harkonnen bonus treachery card.');
nextAuction(g);
} else if (response.kind === 'advisor') {
+ if(response.spiritualAdvisorSource){finishSpiritualAdvisorResponse(g,response,canceled);return;}
if (response.advisorResume === 'ambassador') {
finishGuildAmbassadorAdvisor(g, response, canceled);
return;
@@ -32347,6 +32586,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
@@ -32646,7 +32886,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
if (g.pendingArrivalReaction?.length && !g.response && !g.decision &&
- !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap) {
+ !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) {
const deferred = g.pendingArrivalReaction.shift()!;
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
const entrant = g.players.find(p => p.id === deferred.entrant);
@@ -32757,6 +32997,7 @@ export function normalizeAutomaticGame(state: Game): Game {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
@@ -34563,6 +34804,31 @@ function applyActionInner(
};
} else movementTurn(g);
} else if (decision.kind === 'advisor') {
+ if(g.advanced){
+ requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
+ if(action.accept){
+ beginSpiritualAdvisor(g,decision,action);
+ if(liveShipmentPromises(g.shipmentPromises??[],p.id,g.turn).length ||
+ liveMovementPromises(g.movementPromises??[],p.id,g.turn).some(promise=>promise.answer)){
+ const afterAdvisor=structuredClone(g);
+ for(let step=0;step<4&&afterAdvisor.response&&(afterAdvisor.response.spiritualAdvisorSource ||
+ afterAdvisor.response.automaticAdvisorFlip?.producer==='spiritualArrival');step++)
+ finishResponse(afterAdvisor,false);
+ reconcileShipmentPromises(afterAdvisor,{actor:p.id,action});
+ reconcileGroundMovementPromises(afterAdvisor,p.id,action,g.phase);
+ }
+ }else{
+ if(g.pendingSpiritualAdvisor){
+ const frame=spiritualFrameBody(g);
+ requireRule(frame.stage==='offer'&&JSON.stringify(frame.decision)===JSON.stringify(decision),
+ 'Decline only the original Spiritual Advisor chooser.');
+ delete g.pendingSpiritualAdvisor;
+ }
+ log(g,`${p.name} declined the free Spiritual Advisor shipment.`);
+ if(decision.ambassadorEvent)finishAmbassador(g);
+ }
+ return g;
+ }
requireRule(
typeof action.accept === 'boolean',
'Choose whether to ship an advisor.',
@@ -36817,6 +37083,7 @@ export function viewGame(state: Game, id: string) {
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
+ spiritualAdvisorIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
@@ -36887,6 +37154,8 @@ export function viewGame(state: Game, id: string) {
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
battleOrder: projectedBattleOrder(g),
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
+ ...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id
+ ? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}),
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthMovementAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id
diff --git a/game/guild-ambassador.ts b/game/guild-ambassador.ts
index a2ad434c..56eb88f1 100644
--- a/game/guild-ambassador.ts
+++ b/game/guild-ambassador.ts
@@ -1,5 +1,6 @@
import type { Action, Game, Player } from './engine';
import { arrivalAsAdvisor } from './advisors';
+import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival } from './spiritual-advisor';
import {
gameTerritories,
location,
@@ -274,6 +275,7 @@ export function quoteGuildAmbassadorAdvisor(
bgId: string,
shipment: GuildAmbassadorShipment,
action: Action,
+ recordedLegacy = false,
): GuildAmbassadorAdvisor {
const bg = beneficiary(g, bgId);
const shipper = g.players.find((p) => p.id === shipment?.player);
@@ -343,7 +345,10 @@ export function quoteGuildAmbassadorAdvisor(
to !== MOBILE_STRONGHOLD || (accompanying && shipper.faction === 'ixians'),
'Accompany only an Ixian shipment into the mobile stronghold.',
);
- const advisors = arrivalAsAdvisor(g, bg, to, undefined, accompanying);
+ const targetBlock=recordedLegacy?null:spiritualAdvisorTargetBlock(g,bg.id,to);
+ requireShipment(!targetBlock,targetBlock??'This Spiritual Advisor target is unavailable.');
+ const advisors = recordedLegacy ? arrivalAsAdvisor(g, bg, to, undefined, accompanying)
+ : spiritualAdvisorArrival(g,bg.id,to).advisors;
validateDestinationOccupancy(g, bg, to, advisors);
requireShipment(
count((bg.forces[location(to, sector)] ?? 0) + amount) &&
diff --git a/game/karama-context.ts b/game/karama-context.ts
index b4dab339..3aa80e01 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -102,7 +102,9 @@ const cancellationSources = {
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
}),
advisor: (g, response) =>
- response.advisorResume === 'ambassador'
+ response.spiritualAdvisorSource
+ ? {source:response.spiritualAdvisorSource,pending:g.pendingSpiritualAdvisor?.source??null}
+ : response.advisorResume === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
@@ -189,9 +191,13 @@ const cancellationSources = {
storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null,
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
+ ...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
+ ? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorMatchingSource
? {matching:response.advisorMatchingSource,pending:g.pendingAdvisorMatching?.receipt??null}
+ : response.spiritualAdvisorSource
+ ? {spiritual:response.spiritualAdvisorSource,pending:g.pendingSpiritualAdvisor?.source??null}
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
: response.advisorResume === 'ambassador'
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index 02cd01dc..645309c7 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -307,6 +307,7 @@ export function quoteMovementCancellation(
// This native pre-shipment action has a phase-independent source validator.
if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
+ if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
if (
![
'guildTiming',
diff --git a/game/reference.ts b/game/reference.ts
index 85971081..0b17d915 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4324,7 +4324,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A completed Fremen Ambassador relocation or next-turn Discovery force transfer can leave advisors alone at its source. Ambassador movement can also bring advisors to an empty destination. Provisionally resolve their mandatory conversion after movement and any original collection, before the original arrival reactions continue. Karama keeps the transfer committed. Discovery free entry still admits non-advisor forces only.',
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Matching-group flip counters and wider combinations remain unfinished.',
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
- 'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
+ 'After another faction’s eligible off-planet shipment, choose a free advisor at the shipment destination or Polar Sink. Retained fighters outside Polar Sink require Polar Sink or decline unless Intrusion already changed them to advisors. The free shipping counter comes first. Provisionally, Polar fighters require a separate incoming matching counter before placement; preventing matching restores the owned choice while that same packet here stays unavailable. Empty arrivals then have a separate committed alone-conversion counter. Earlier locked advisor rules still apply while others remain.',
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or remaining automatic-flip cancellation behavior.',
],
@@ -4333,8 +4333,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
- "detail": "Normal opposite-type ground arrivals and reserve-to-advisor shipments now use a precommit matching receipt and private original order. Shared packet-scoped denial leaves residents and unused actions intact. Same-type ground movement remains direct. Special transport/gather and Spiritual source cutovers remain unfinished.",
+ "detail": "Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
"evidence": [
+ "tests/fixture-spiritual-advisor-flow.ts",
+ "tests/spiritual-advisor-engine.test.ts",
+ "tests/spiritual-advisor.test.ts",
+ "game/spiritual-advisor.ts",
+ "docs/SPIRITUAL_ADVISORS.md",
"docs/ADVISOR_MATCHING.md",
"game/advisor-automatic-flip.ts",
"game/advisors.ts",
@@ -4349,8 +4354,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
- "detail": "Standard Karama/Allow explains that matching precedes physical arrival/payment and does not flip residents. Prevention retains unused actions and original card uses, with a legal replacement. Seven native GameTable SSR journeys render standard controls; no new confirmation or timer.",
+ "detail": "Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
"evidence": [
+ "docs/SPIRITUAL_ADVISORS.md",
"docs/ADVISOR_MATCHING.md",
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"components/advisor-preparation.tsx",
@@ -4361,8 +4367,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
- "detail": "Twelve existing-profile matching responses and twelve accepted legal replacements use shared packet/opportunity denials. Earlier producers retain separate evidence. No strategy refinement or difficulty calibration.",
+ "detail": "Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
"evidence": [
+ "tests/fixture-spiritual-advisor-flow.ts",
+ "tests/spiritual-advisor-engine.test.ts",
+ "tests/spiritual-advisor.test.ts",
+ "game/spiritual-advisor.ts",
+ "docs/SPIRITUAL_ADVISORS.md",
"tests/fixture-advisor-matching-move.ts",
"tests/advisor-matching-engine.test.ts",
"tests/advisor-matching.test.ts",
@@ -4389,8 +4400,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
- "detail": "Physical28 E explicitly marks matching as counterable and physical17 identifies the incoming group. Shipment defaults and precommit remedy/packet-opportunity restraint are provisional. Spiritual destination/Polar matching cutovers and broader transports remain explicit open scope.",
+ "detail": "Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
"evidence": [
+ "docs/SPIRITUAL_ADVISORS.md",
"docs/ADVISOR_MATCHING.md",
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"docs/ADVISOR_PREPARATION.md",
@@ -4401,8 +4413,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
- "detail": "Normal matching passes frozen19-file244/244, types/lint/build, seven native declaration/response/GameTable SSR journeys, twelve profile responses and twelve accepted replacements. Genuine34/33-action base33 sources earn original advisors/fighters/Karama. Both directions and reserve shipment wait without transfer; allowance commits once and shipment pays1. Prevention keeps payment/action unused, rejects same-packet retry and permits legal replacement. Same-type movement has no counter. Special relocation, Guild/Homeworld, gathering, Spiritual matching/source cutover, full modes and final assurance remain open.",
+ "detail": "The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
"evidence": [
+ "tests/fixture-spiritual-advisor-flow.ts",
+ "tests/spiritual-advisor-engine.test.ts",
+ "tests/spiritual-advisor.test.ts",
+ "game/spiritual-advisor.ts",
+ "docs/SPIRITUAL_ADVISORS.md",
"tests/fixture-advisor-matching-move.ts",
"tests/advisor-matching-engine.test.ts",
"tests/advisor-matching.test.ts",
diff --git a/game/spiritual-advisor.ts b/game/spiritual-advisor.ts
new file mode 100644
index 00000000..cd17e0eb
--- /dev/null
+++ b/game/spiritual-advisor.ts
@@ -0,0 +1,123 @@
+import { gameTerritories, splitLocation, type MobileBoard } from './board';
+import type { FactionId } from './catalog';
+import { presenceAt, type ForcePresence } from './force-presence';
+
+export type SpiritualAdvisorSource = {
+ event: string;
+ owner: string;
+ turn: number;
+ phase: number;
+ territory: string;
+ sector: number;
+ amount: number;
+};
+
+export type SpiritualAdvisorPlayer = ForcePresence & {
+ id: string;
+ faction: FactionId;
+ advisors?: Readonly>;
+};
+
+export type SpiritualAdvisorBoardContext = {
+ advanced: boolean;
+ players: readonly SpiritualAdvisorPlayer[];
+};
+
+export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard & {
+ status: string;
+ turn: number;
+ phase: number;
+};
+
+export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
+
+export class SpiritualAdvisorError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SpiritualAdvisorError';
+ }
+}
+
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+const positive = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) > 0;
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+function requireSpiritual(value: unknown, message: string): asserts value {
+ if (!value) throw new SpiritualAdvisorError(message);
+}
+
+function nativeOwner(ctx: SpiritualAdvisorBoardContext, owner: string) {
+ const players = ctx.players.filter((player) => player.id === owner);
+ return players.length === 1 && players[0].faction === 'beneGesserit' ? players[0] : undefined;
+}
+
+function territoryId(target: SpiritualAdvisorTarget) {
+ return typeof target === 'string' ? target : target.territory;
+}
+
+function hasResidents(player: SpiritualAdvisorPlayer, territory: string) {
+ return Object.entries(player.forces).some(([key, count]) =>
+ splitLocation(key).territory === territory && count > 0);
+}
+
+/** Physical28's Advanced Spiritual exception does not change ordinary arrival.
+ * Polar Sink retains the selected provisional fallback, even with BG fighters. */
+export function spiritualAdvisorTargetBlock(
+ ctx: SpiritualAdvisorBoardContext,
+ owner: string,
+ target: SpiritualAdvisorTarget,
+): string | null {
+ if (!ctx.advanced) return null;
+ const player = nativeOwner(ctx, owner);
+ if (!player) return 'Spiritual Advisors need the current native Bene Gesserit owner.';
+ const territory = territoryId(target);
+ if (territory === 'polar_sink') return null;
+ return hasResidents(player, territory) && !player.advisors?.[territory]
+ ? 'Advanced Spiritual Advisors cannot accompany into a non-Polar territory containing your fighters; choose the Polar Sink or decline. Intrusion that already made them advisors removes this block.'
+ : null;
+}
+
+/** Describe the incoming advisor group; target and resource permission remain
+ * separate. No resident group or prior continuously-alone hold is changed. */
+export function spiritualAdvisorArrival(
+ ctx: SpiritualAdvisorBoardContext,
+ owner: string,
+ target: SpiritualAdvisorTarget,
+): { advisors: boolean; matching: boolean; alone: boolean } {
+ if (!ctx.advanced) return { advisors: false, matching: false, alone: false };
+ const player = nativeOwner(ctx, owner);
+ requireSpiritual(player, 'Spiritual Advisors need the current native Bene Gesserit owner.');
+ const territory = territoryId(target);
+ const matching = hasResidents(player, territory) && !player.advisors?.[territory];
+ return {
+ advisors: !matching,
+ matching,
+ alone: !ctx.players.some((other) => other.id !== owner && presenceAt(other, territory) > 0),
+ };
+}
+
+/** Original foreign-shipment event and packet bind a prevented matching retry.
+ * Destination sector does not create another use of the same opportunity. */
+export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): string {
+ return JSON.stringify([
+ 'spiritualAdvisorMatching', source.event, source.owner, source.turn, source.territory, source.amount,
+ ]);
+}
+
+/** Public receipt only. Native resource entitlement and target permissions are
+ * 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' &&
+ 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' &&
+ 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.');
+ requireSpiritual(nativeOwner(ctx, source.owner), 'Spiritual Advisors lost their current native Bene Gesserit owner.');
+ requireSpiritual(gameTerritories(ctx).some((place) =>
+ place.id === source.territory && place.sectors.includes(source.sector)),
+ 'Spiritual Advisors need their current board destination and sector.');
+}
diff --git a/tests/advisor-automatic-flip-quote.test.ts b/tests/advisor-automatic-flip-quote.test.ts
index 645ab1dd..be23df48 100644
--- a/tests/advisor-automatic-flip-quote.test.ts
+++ b/tests/advisor-automatic-flip-quote.test.ts
@@ -17,6 +17,7 @@ import {
type AutomaticAdvisorRelocation,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+import { spiritualAdvisorArrival, type SpiritualAdvisorSource } from '../game/spiritual-advisor';
type GroundSource = Extract;
type TransportSource = Extract;
@@ -26,6 +27,7 @@ type HomeworldSource = Extract;
type StormSource = Extract;
type RelocationSource = Extract;
+type SpiritualArrivalSource = Extract;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -1359,3 +1361,113 @@ void test('completed relocation validation never reads private parent signatures
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
}
});
+
+function spiritualArrival(amount = 1, phase = 5) {
+ const ctx = context();
+ ctx.phase = phase;
+ ctx.players[0].forces = { 'polar_sink:0': amount };
+ ctx.players[1].forces = {};
+ ctx.players[2].forces = {};
+ const receipt: SpiritualAdvisorSource = {
+ event: 'original-public-spiritual-opportunity', owner: 'b', turn: ctx.turn, phase,
+ territory: 'polar_sink', sector: 0, amount,
+ };
+ const source: SpiritualArrivalSource = {
+ event: 'automatic-after-completed-spiritual-placement', owner: 'b',
+ territory: 'polar_sink', turn: ctx.turn, phase, forces: { 'polar_sink:0': amount },
+ producer: 'spiritualArrival', spiritual: receipt,
+ };
+ ctx.players[0].advisors = { polar_sink: { aloneFlipHold: source.event } };
+ return { ctx, source };
+}
+
+void test('a committed empty Spiritual Advisor placement retains its exact public source and surviving advisor group', () => {
+ for (const phase of [1, 3, 5, 8]) {
+ for (const amount of [1, 2]) {
+ const original = spiritualArrival(amount, phase);
+ const before = structuredClone(original);
+ assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
+ assert.deepEqual(original, before);
+ assert.deepEqual(Object.keys(original.source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'spiritual', 'territory', 'turn']);
+ assert.deepEqual(Object.keys(original.source.spiritual).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
+ }
+ }
+});
+
+void test('Spiritual Arrival conversion rejects changed owner, target, phase, amount, deposit and physical group', () => {
+ const changes: ((ctx: AutomaticAdvisorFlipContext, source: SpiritualArrivalSource) => void)[] = [
+ (_ctx, source) => { source.spiritual.event = ''; },
+ (_ctx, source) => { source.spiritual.owner = 'a'; },
+ (_ctx, source) => { source.spiritual.turn++; },
+ (_ctx, source) => { source.spiritual.phase = 6; },
+ (_ctx, source) => { source.spiritual.territory = 'arrakeen'; source.spiritual.sector = 10; },
+ (_ctx, source) => { source.spiritual.sector = 1; },
+ (_ctx, source) => { source.spiritual.amount = 2; },
+ (_ctx, source) => { source.spiritual.amount = 3; },
+ (ctx) => { ctx.players[0].forces = {}; },
+ (ctx) => { ctx.players[1].forces = { 'polar_sink:0': 1 }; },
+ (_ctx, source) => { source.forces = { 'polar_sink:0': 2 }; },
+ ];
+ for (const change of changes) {
+ const original = spiritualArrival();
+ change(original.ctx, original.source);
+ const before = structuredClone(original);
+ assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
+ assert.deepEqual(original, before);
+ }
+ const { ctx, source } = spiritualArrival();
+ for (const malformed of [
+ { ...source, spiritual: null },
+ { ...source, spiritual: { ...source.spiritual, order: 'private-parent' } },
+ { ...source, spiritual: { ...source.spiritual, card: 'private-shipping-cost' } },
+ { ...source, spiritual: { ...source.spiritual, amount: '1' } },
+ { ...source, order: 'private-parent' },
+ ]) {
+ assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
+ }
+});
+
+void test('an already-held continuously-alone group stays advisors after another Spiritual Advisor joins without earning a fresh automatic response', () => {
+ const { ctx, source } = spiritualArrival();
+ ctx.players[0].forces = { 'polar_sink:0': 3 };
+ source.forces = { 'polar_sink:0': 3 };
+ ctx.players[0].advisors = { polar_sink: { aloneFlipHold: 'earlier-prevented-alone-occurrence' } };
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'polar_sink'), { advisors: true, matching: false, alone: true });
+ assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
+ assert.deepEqual(advisorStanceAfterSettlement(ctx, 'b', 'polar_sink'), {
+ aloneFlipHold: 'earlier-prevented-alone-occurrence',
+ });
+ assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
+});
+
+void test('Spiritual Arrival leaves all prior producer receipts and phases unchanged and rejects unsupported or Basic conversion windows', () => {
+ for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport(), faceDance(), storm(), relocation(), relocation('discovery')]) {
+ const before = JSON.stringify(original.source);
+ assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
+ assert.equal(JSON.stringify(original.source), before);
+ }
+ for (const phase of [0, 2, 4, 6, 7]) {
+ const original = spiritualArrival(1, phase);
+ assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
+ }
+ const basic = spiritualArrival();
+ basic.ctx.advanced = false;
+ assert.throws(() => validateAutomaticAdvisorFlip(basic.ctx, basic.source), AutomaticAdvisorFlipError);
+});
+
+void test('Spiritual Arrival conversion uses only public receipt, current deposit and held group without reading resources or the private original frame', () => {
+ const { ctx, source } = spiritualArrival(2);
+ for (const player of ctx.players) {
+ for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'moved', 'shipped']) {
+ Object.defineProperty(player, field, {
+ get() { throw new Error(`Spiritual conversion read ${player.id} private or original ${field}`); },
+ });
+ }
+ }
+ for (const field of ['pendingSpiritualAdvisor', 'pendingShipment', 'stormCard', 'stormForecast', 'leaderSkills', 'homeworlds']) {
+ Object.defineProperty(ctx, field, {
+ get() { throw new Error(`Spiritual conversion read private parent ${field}`); },
+ });
+ }
+ assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
+});
diff --git a/tests/bg-ambassador-arrival.test.ts b/tests/bg-ambassador-arrival.test.ts
index 6273e42e..fa76f357 100644
--- a/tests/bg-ambassador-arrival.test.ts
+++ b/tests/bg-ambassador-arrival.test.ts
@@ -6,7 +6,8 @@ import {
bgAmbassadorAccompanimentAction, bgAmbassadorArrivalPlayer,
bgAmbassadorIntrusionAction, bgAmbassadorOfferAction,
createBgAmbassadorArrivalFixture, finishBgAmbassadorArrival,
- settleBgAmbassadorArrivalControls, type BgAmbassadorArrivalFixture,
+ recordControlledLegacyBgAdvisorResponse, settleBgAmbassadorArrivalControls,
+ type BgAmbassadorArrivalFixture,
} from './fixture-bg-ambassador-arrival';
function assertStock(game: Game) {
@@ -70,7 +71,13 @@ function assertOriginalMovementResumes(fixture: BgAmbassadorArrivalFixture, game
} else assert.equal(game.dukeVidal?.controller ?? null, null);
const before = bgAmbassadorArrivalPlayer(game, fixture.entrant);
const bgBefore = bgAmbassadorArrivalPlayer(game, fixture.advisor);
- const moved = applyBgAmbassadorArrivalStep(game, { actor: fixture.entrant, action: fixture.movementAction });
+ let moved = applyBgAmbassadorArrivalStep(game, { actor: fixture.entrant, action: fixture.movementAction });
+ if (moved.response?.automaticAdvisorFlip) {
+ assert.equal(moved.response.automaticAdvisorFlip.producer, 'groundMovement');
+ assert.equal(moved.response.owner, fixture.advisor);
+ assert.equal(moved.response.location, fixture.territory);
+ moved = settleBgAmbassadorArrivalControls(moved);
+ }
const entrant = bgAmbassadorArrivalPlayer(moved, fixture.entrant);
assert.equal(entrant.forces[fixture.location] ?? 0, 0);
assert.equal(entrant.forces['arsunt:11'], 1);
@@ -85,8 +92,8 @@ function assertOriginalMovementResumes(fixture: BgAmbassadorArrivalFixture, game
assert.equal(moved.pendingArrivalReaction?.length ?? 0, 0);
assert.deepEqual(bgAmbassadorArrivalPlayer(moved, fixture.advisor).forces, bgBefore.forces);
assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).reserves, bgBefore.reserves);
- // If BG remains alone after the original entrant moves out, its stance changes
- // automatically without another entry event or another Ambassador prompt.
+ // Allow any actual alone-conversion counter after the original entrant moves
+ // out, without another entry event or another Ambassador prompt.
if (bgBefore.forces[fixture.location])
assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).advisors?.[fixture.territory], undefined);
assertStock(moved);
@@ -156,9 +163,14 @@ for (const becomeAdvisors of [true, false]) {
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1);
assertMainQueue(fixture, game);
- // Keeping fighters and accepting accompaniment would join that fighter
- // group, not manufacture an advisor. Here that branch legitimately declines
- // the reserve transfer; the accepted branch joins the newly peaceful group.
+ // Fresh Advanced accompaniment cannot join retained fighters. Declining the
+ // reserve transfer preserves them; accepted Intrusion permits the new advisor.
+ if (!becomeAdvisors) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
+ /cannot accompany/);
+ assert.deepEqual(game, before);
+ }
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(becomeAdvisors) });
if (becomeAdvisors) {
assert.equal(game.response?.kind, 'advisor');
@@ -177,7 +189,35 @@ for (const becomeAdvisors of [true, false]) {
});
}
-void test('declining Intrusion and accepting accompaniment preserves fighters: one BG child offer then one original shipper offer', () => {
+void test('fresh retained fighters reject destination accompaniment while Polar Sink preserves the original paid entry', () => {
+ const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
+ const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
+ let game = beginBgAmbassadorArrival(fixture);
+ game = applyBgAmbassadorArrivalStep(game, {
+ actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false),
+ });
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
+ /cannot accompany/);
+ assert.deepEqual(game, before);
+ game = applyBgAmbassadorArrivalStep(game, {
+ actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true, false),
+ });
+ assert.equal(game.response?.kind, 'advisor');
+ assert.ok(game.response?.spiritualAdvisorSource);
+ assertMainQueue(fixture, game);
+ game = settleBgAmbassadorArrivalControls(game);
+ const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor);
+ assert.equal(bg.forces[fixture.location], 1);
+ assert.equal(bg.advisors?.[fixture.territory], undefined);
+ assert.equal(bg.forces['polar_sink:0'], 1);
+ assert.equal(bg.advisors?.polar_sink, undefined);
+ assert.equal(bg.reserves, bgBefore.reserves - 1);
+ assertMainOffer(fixture, game);
+ assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false);
+});
+
+void test('controlled recorded legacy accompaniment preserves fighters: one BG child offer then one original shipper offer', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
@@ -189,9 +229,7 @@ void test('declining Intrusion and accepting accompaniment preserves fighters: o
});
assert.equal(game.decision?.kind, 'advisor');
assertMainQueue(fixture, game);
- game = applyBgAmbassadorArrivalStep(game, {
- actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true),
- });
+ game = recordControlledLegacyBgAdvisorResponse(game);
assert.equal(game.response?.kind, 'advisor');
assertMainQueue(fixture, game);
game = settleBgAmbassadorArrivalControls(game);
@@ -225,7 +263,7 @@ void test('declining Intrusion and accepting accompaniment preserves fighters: o
assertOriginalMovementResumes(fixture, game, true);
});
-void test('triggering the genuine BG fighter child returns the live Ecaz token before the queued shipper entry resumes', () => {
+void test('controlled recorded legacy fighter child returns the live Ecaz token before the queued shipper entry resumes', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
@@ -233,9 +271,7 @@ void test('triggering the genuine BG fighter child returns the live Ecaz token b
actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false),
});
assert.equal(game.decision?.kind, 'advisor');
- game = settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(game, {
- actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true),
- }));
+ game = settleBgAmbassadorArrivalControls(recordControlledLegacyBgAdvisorResponse(game));
assert.equal(game.pendingAmbassador?.entrant, fixture.advisor);
assert.equal(game.pendingArrivalReaction?.[0]?.entrant, fixture.entrant);
const spentChild = bgAmbassadorOfferAction(game, true);
@@ -278,6 +314,10 @@ void test('canceling the real Intrusion flip retains fighters and the followup b
assertMainQueue(fixture, game);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1);
+ const retained = structuredClone(game);
+ assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
+ /cannot accompany/);
+ assert.deepEqual(game, retained);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(false) });
assertMainOffer(fixture, game);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
diff --git a/tests/fixture-bg-ambassador-arrival.ts b/tests/fixture-bg-ambassador-arrival.ts
index df5d5257..6b478e65 100644
--- a/tests/fixture-bg-ambassador-arrival.ts
+++ b/tests/fixture-bg-ambassador-arrival.ts
@@ -92,12 +92,34 @@ function advance(game: Game, until: (state: Game) => boolean, actions: BgAmbassa
throw Error('Original native BG arrival did not reach the requested boundary');
}
-/** Finish only original response/acknowledgement windows. In particular this
- * never silently chooses Intrusion, accompaniment or the Ambassador offer. */
+/** Allow response/acknowledgement windows, including a committed Spiritual
+ * arrival's automatic conversion. Never choose Intrusion, accompaniment or an
+ * Ambassador offer; tests stop at those original owned decisions. */
export function settleBgAmbassadorArrivalControls(game: Game, actions: BgAmbassadorArrivalStep[] = []): Game {
return advance(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, actions);
}
+/** Controlled already-declared legacy control, not fresh accompaniment permission.
+ * Keep the earned shipment, parent, forces, cards and wallets; replace only its
+ * current advisor chooser with the old recorded response shape, without the new
+ * Spiritual source. This isolates the retained callback protocol. */
+export function recordControlledLegacyBgAdvisorResponse(state: Game): Game {
+ const decision = state.decision;
+ assert.ok(decision?.kind === 'advisor' && decision.destination);
+ assert.ok(!state.response && !state.pendingSpiritualAdvisor);
+ const game = structuredClone(state);
+ game.decision = null;
+ game.response = {
+ kind: 'advisor', owner: decision.player, location: decision.destination,
+ amount: 1, passed: [],
+ ...(decision.ambassadorEvent === undefined ? {} : {
+ advisorResume: 'ambassador' as const, advisorAmbassadorEvent: decision.ambassadorEvent,
+ }),
+ };
+ assert.equal(game.response.spiritualAdvisorSource, undefined);
+ return game;
+}
+
function orderLandBlows(game: Game, staging: string[]) {
for (let at = 0; at < 2; at++) {
const index = game.spiceDeck.findIndex((card, index) => index >= at && 'territory' in card &&
diff --git a/tests/fixture-spiritual-advisor-flow.ts b/tests/fixture-spiritual-advisor-flow.ts
new file mode 100644
index 00000000..b5be8ad5
--- /dev/null
+++ b/tests/fixture-spiritual-advisor-flow.ts
@@ -0,0 +1,284 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
+import {
+ actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
+ advisorPreparationPlayer, nextAdvisorPreparationStep,
+} from './fixture-advisor-preparation';
+
+export type SpiritualAdvisorFlowStep = { actor: string; action: Action };
+export type SpiritualAdvisorFlowTransition = { before: Game; steps: SpiritualAdvisorFlowStep[]; after: Game };
+export type SpiritualAdvisorFlowStance = 'fighters' | 'advisors' | 'polarFighters';
+export const actSpiritualAdvisorFlow = actAdvisorPreparation;
+export const spiritualAdvisorFlowPlayer = advisorPreparationPlayer;
+export const spiritualAdvisorFlowInventory = advisorPreparationInventory;
+export const spiritualAdvisorFlowClean = advisorPreparationClean;
+
+/** The original Spiritual decision, Intrusion and every advisorFlip remain
+ * owned choices. Source construction explicitly handles its original Intrusion. */
+export function nextSpiritualAdvisorFlow(game: Game): SpiritualAdvisorFlowStep | null {
+ if (game.response?.kind === 'advisorFlip') return null;
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
+ const ecaz = game.players.find(player => player.faction === 'ecaz');
+ if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
+ return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
+ const decision = game.decision;
+ if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' ||
+ decision?.kind === 'ecazAmbassador' || decision?.kind === 'ecazPlacement') return null;
+ if (decision?.kind === 'advisorBattle')
+ return { actor: decision.player, action: { type: 'decision', accept: false } };
+ }
+ return nextAdvisorPreparationStep(game);
+}
+export function advanceSpiritualAdvisorFlow(game: Game, until: (state: Game) => boolean,
+ steps: SpiritualAdvisorFlowStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Spiritual chronology ended before its requested boundary');
+ const next = nextSpiritualAdvisorFlow(game);
+ assert.ok(next, `Choose the original Spiritual/Intrusion/Ambassador response at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actSpiritualAdvisorFlow(game, next.actor, next.action, steps);
+ }
+ throw Error('The native Spiritual Advisor source did not reach its finite boundary');
+}
+/** Ordinary shipping responses may finish, but matching/automatic conversion
+ * always stays caller-owned, including after the original free-shipping counter. */
+export function passSpiritualAdvisorFlow(game: Game, steps: SpiritualAdvisorFlowStep[] = []): Game {
+ for (let count = 0; game.response && game.response.kind !== 'advisorFlip' && count < 60; count++) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ game = actSpiritualAdvisorFlow(game, actor.id, { type: 'passResponse' }, steps);
+ }
+ assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
+}
+function originalGuildPacket() {
+ const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
+ const supply: Exclude[] = ['guild', ...original.filter(effect => effect !== 'guild').slice(0, 4)];
+ const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
+ const current = [...original], scalars: number[] = [];
+ for (let index = current.length - 1; index > 0; index--) {
+ const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
+ scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
+ [current[index], current[from]] = [current[from], current[index]];
+ }
+ assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
+}
+function createOriginalPacket(game: Game, step: SpiritualAdvisorFlowStep,
+ steps: SpiritualAdvisorFlowStep[], scalars: number[]): Game {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1)
+ array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try {
+ const next = applyAction(game, step.actor, step.action);
+ assert.equal(cursor, scalars.length, 'Only the original Ecaz packet consumes its nine selected draws');
+ steps.push(structuredClone(step)); return next;
+ } finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+
+/** Actual Advanced public setup/shipments and actual Intrusion choices, stopped
+ * before the separate free Spiritual Advisor choice. Foreign forces and costs
+ * are already committed; BG has not yet declared its accompanying/sink counter. */
+export function createSpiritualAdvisorFlowFixture(options: {
+ parent?: 'ordinary' | 'ambassador'; stance?: SpiritualAdvisorFlowStance;
+} = {}) {
+ const parent = options.parent ?? 'ordinary', stance = options.stance ?? 'fighters';
+ const bg = 'beneGesserit', holder = 'atreides', other = parent === 'ambassador' ? 'ecaz' : 'emperor';
+ const foreignDestination = stance === 'polarFighters' ? 'polar_sink' : 'tueks_sietch';
+ const sector = stance === 'polarFighters' ? 0 : 5, location = `${foreignDestination}:${sector}`;
+ let game = createGame('SPIRITUALADVISORFLOW', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true,
+ parent === 'ambassador' ? ['ecaz'] : []);
+ if (parent === 'ambassador') {
+ joinGame(game, newPlayer(other, 'Ecaz', 'ecaz')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
+ } else {
+ joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
+ }
+ const initial = structuredClone(game), steps: SpiritualAdvisorFlowStep[] = [];
+ const act = (actor: string, action: Action) => { game = actSpiritualAdvisorFlow(game, actor, action, steps); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceSpiritualAdvisorFlow(game, until, steps); };
+ const transition = (run: () => void): SpiritualAdvisorFlowTransition => {
+ const before = structuredClone(game), start = steps.length; run();
+ return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
+ };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
+ assert.deepEqual(game.expansions, parent === 'ambassador' ? ['ecaz'] : []);
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => !player.hand.length));
+ const originalInventory = spiritualAdvisorFlowInventory(game); assert.equal(originalInventory.length, 33);
+ const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
+ const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
+ assert.equal(ordinary.length, 2);
+ const selected = parent === 'ambassador' ? [ordinary[0], ordinary[1], karama] : [ordinary[0], karama, ordinary[1]];
+ 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), packet = parent === 'ambassador' ? originalGuildPacket() : null;
+ let packetCreation: SpiritualAdvisorFlowTransition | null = null;
+ let advisorSetup: SpiritualAdvisorFlowTransition | null = null;
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const next = nextSpiritualAdvisorFlow(game); assert.ok(next);
+ if (next.action.type === 'advisorSetup') next.action = { type: 'advisorSetup', territory: foreignDestination, sector };
+ const before = structuredClone(game), start = steps.length;
+ const createsPacket = packet && game.setupStage === 'traitors' && next.action.type === 'traitor' &&
+ game.players.filter(player => player.traitorChoices.length).length === 1;
+ game = createsPacket ? createOriginalPacket(game, next, steps, packet!.scalars)
+ : actSpiritualAdvisorFlow(game, next.actor, next.action, steps);
+ const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
+ if (createsPacket) packetCreation = record;
+ if (next.action.type === 'advisorSetup') advisorSetup = record;
+ }
+ assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
+ assert.equal(spiritualAdvisorFlowPlayer(game, bg).forces[location], 1);
+ assert.ok(!spiritualAdvisorFlowPlayer(game, bg).advisors?.[foreignDestination]);
+ if (packet) {
+ assert.ok(packetCreation);
+ assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
+ }
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ 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);
+ let placement: SpiritualAdvisorFlowTransition | null = null;
+ let token: string | null = null;
+ if (parent === 'ambassador') {
+ reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
+ const guild = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'guild')!; token = guild.id;
+ assert.equal(guild.zone, 'supply');
+ placement = transition(() => {
+ act(other, { type: 'decision', token: guild.id, territory: 'carthag' });
+ game = passSpiritualAdvisorFlow(game, steps);
+ });
+ assert.equal(spiritualAdvisorFlowPlayer(placement.before, other).spice - spiritualAdvisorFlowPlayer(game, other).spice, 1);
+ act(other, { type: 'decision', decline: true });
+ }
+ reach(state => state.phase === 5 && state.active === bg && spiritualAdvisorFlowClean(state));
+ const bgEnd = transition(() => {
+ act(bg, { type: 'endMovement' });
+ reach(state => state.phase === 5 && state.active === holder && spiritualAdvisorFlowClean(state));
+ });
+ let paidEntry: SpiritualAdvisorFlowTransition | null = null;
+ let offer: Game | null = null, trigger: SpiritualAdvisorFlowTransition | null = null;
+ let event: string | null = null;
+ if (parent === 'ambassador') {
+ paidEntry = transition(() => {
+ act(holder, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
+ for (let count = 0; !(game.pendingAmbassador?.stage === 'offer' && !game.response && !game.phaseOpening) && count < 150; count++) {
+ if (!game.response && !game.phaseOpening && game.decision?.kind === 'advisor')
+ act(bg, { type: 'decision', accept: false });
+ else {
+ const next = nextSpiritualAdvisorFlow(game); assert.ok(next, 'Finish only the actual earlier Carthag entry');
+ act(next.actor, next.action);
+ }
+ }
+ assert.equal(game.pendingAmbassador?.stage, 'offer');
+ });
+ assert.equal(spiritualAdvisorFlowPlayer(paidEntry.before, holder).spice - spiritualAdvisorFlowPlayer(game, holder).spice, 1);
+ offer = structuredClone(game); event = game.pendingAmbassador!.event;
+ trigger = transition(() => {
+ act(other, { type: 'decision', event, trigger: true, beneficiary: other });
+ reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'ship');
+ });
+ }
+ const shipper = parent === 'ordinary' ? holder : other;
+ const foreignShipmentAction: Action = parent === 'ordinary'
+ ? { type: 'ship', territory: foreignDestination, sector, amount: 1, elite: 0, allyPayment: 0 }
+ : { type: 'decision', event, territory: foreignDestination, sector, amount: 1, elite: 0 };
+ const foreignShipment = transition(() => {
+ act(shipper, foreignShipmentAction);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
+ });
+ const beforeIntrusion = structuredClone(game);
+ const intrusion = transition(() => {
+ act(bg, { type: 'decision', accept: stance === 'advisors' });
+ if (stance === 'advisors') {
+ for (let count = 0; game.response && count < 60; count++) {
+ assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.response.advisors, true);
+ assert.ok(!game.response.automaticAdvisorFlip, 'Only the identified original Intrusion response is allowed here');
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ act(actor.id, { type: 'passResponse' });
+ }
+ }
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
+ });
+ assert.ok(game.decision?.kind === 'advisor'); assert.equal(game.decision.player, bg);
+ assert.equal(game.decision.shipment, shipper); assert.equal(game.decision.destination, location);
+ assert.equal(game.decision.ambassadorEvent ?? null, event);
+ assert.equal(spiritualAdvisorFlowPlayer(game, bg).forces[location], 1);
+ assert.equal(!!spiritualAdvisorFlowPlayer(game, bg).advisors?.[foreignDestination], stance === 'advisors');
+ assert.equal(spiritualAdvisorFlowPlayer(game, shipper).forces[location], 1);
+ const foreignCost = spiritualAdvisorFlowPlayer(foreignShipment.before, shipper).spice - spiritualAdvisorFlowPlayer(game, shipper).spice;
+ assert.equal(foreignCost, parent === 'ambassador' ? 0 : foreignDestination === 'polar_sink' ? 2 : 1);
+ if (parent === 'ordinary') assert.equal(spiritualAdvisorFlowPlayer(game, shipper).shipped, true);
+ else assert.equal(spiritualAdvisorFlowPlayer(game, shipper).shipped, spiritualAdvisorFlowPlayer(foreignShipment.before, shipper).shipped);
+ const paidShipmentAction = paidEntry?.steps.find(step => step.action.type === 'ship')?.action ?? foreignShipmentAction;
+ const entryCost = paidEntry ? spiritualAdvisorFlowPlayer(paidEntry.before, holder).spice -
+ spiritualAdvisorFlowPlayer(paidEntry.after, holder).spice : foreignCost;
+ const placementCost = placement ? spiritualAdvisorFlowPlayer(placement.before, other).spice -
+ spiritualAdvisorFlowPlayer(placement.after, other).spice : 0;
+ assert.ok(spiritualAdvisorFlowPlayer(game, holder).hand.some(card => card.id === karama.id));
+ assert.deepEqual(spiritualAdvisorFlowInventory(game), originalInventory);
+ const accompany: Action = { type: 'decision', accept: true, accompany: true, amount: 1 };
+ const sink: Action = { type: 'decision', accept: true, accompany: false, amount: 1 };
+ const decline: Action = { type: 'decision', accept: false };
+ return { game, source: structuredClone(game), beforeDecision: structuredClone(game), initial, lobby, setup,
+ beforeStartingDeal, afterSetup, advisorSetup, packet, packetCreation, placement, token,
+ bgEnd, paidEntry, offer, trigger, foreignShipment, beforeIntrusion, intrusion,
+ parent, stance, bg, owner: bg, holder, atreides: holder, other,
+ ecaz: parent === 'ambassador' ? other : null, emperor: parent === 'ordinary' ? other : null,
+ karama: karama.id, card: karama.id, shipper, actor: bg, foreignDestination, territory: foreignDestination,
+ location, foreignLocation: location, sector, foreignCost, cost: foreignCost, entryCost, placementCost,
+ foreignReservesBefore: spiritualAdvisorFlowPlayer(foreignShipment.before, shipper).reserves,
+ foreignReservesAfter: spiritualAdvisorFlowPlayer(game, shipper).reserves,
+ event, parentEvent: event, order: structuredClone(game.pendingAmbassador?.shipmentReceipt?.order ?? null),
+ parentEntry: structuredClone(game.pendingAmbassador ?? null),
+ paidShipmentAction, foreignShipmentAction, childShipmentAction: parent === 'ambassador' ? foreignShipmentAction : null,
+ foreignShipmentStep: { actor: shipper, action: foreignShipmentAction } satisfies SpiritualAdvisorFlowStep,
+ accompany, accompanyAction: accompany, accept: accompany, action: accompany,
+ sink, sinkAction: sink, decline, declineAction: decline,
+ steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
+ staging: [
+ parent === 'ordinary'
+ ? 'Actual public Advanced BG/Atreides/Emperor ready/startPreview, original base33 and no optional module.'
+ : 'Actual public Advanced BG/Ecaz/Atreides ready/startPreview, bare E3/base33 and no optional module. Exactly nine original Ecaz packet draws on the last real Traitor choice earn Guild in the original cohort.',
+ 'Native prediction, private Traitors and actual advisorSetup place BG’s original counter in empty Tuek’s5 or Polar0, which native setup normally makes a fighter. Only three original unopened Treachery positions and two ordinary Spice positions are selected; Atreides actually receives its original printed Karama.',
+ 'Actually complete safe first Storm, dual Spice, Charity, Bidding and Revival. BG’s real movement turn ends without moving the resident group. No played hand, force, wallet, phase, stance, prevention or receipt is installed.',
+ parent === 'ordinary'
+ ? 'Atreides really pays one for a reserve shipment to Tuek’s5, or two to Polar0. That original foreign shipment has physically committed before the separate free Spiritual Advisors decision.'
+ : 'Ecaz actually pays one to place its original Guild token at Carthag. Atreides actually pays one to enter, and BG declines that earlier ordinary accompaniment. Ecaz really triggers Guild for itself, then sends one actual reserve free to the target through the original child shipment; its physical order/receipt remains native.',
+ stance === 'advisors'
+ ? 'BG actually accepts the resulting Intrusion and every original advisorFlip response allows the fighter→advisor change. Stop before the later independent Spiritual Advisors choice.'
+ : 'BG actually declines the original Intrusion, retaining the original resident fighter, including in the Polar variant. Stop before the later independent Spiritual Advisors choice.',
+ 'Accompany, Polar Sink and decline payloads remain caller-owned. Matching, original free-shipping counter, reserve transfer, automatic alone conversion and parent resumption are not preinstalled or silently selected.',
+ ] };
+}
+export type SpiritualAdvisorFlowFixture = ReturnType;
+
+/** Submit only the owned free-shipping choice; do not answer later counters. */
+export function commitSpiritualAdvisorFlow(source: Game, fixture: SpiritualAdvisorFlowFixture,
+ choice: 'accompany' | 'sink' | 'decline' = 'accompany', steps: SpiritualAdvisorFlowStep[] = []) {
+ const before = structuredClone(source), start = steps.length;
+ const game = actSpiritualAdvisorFlow(source, fixture.bg, fixture[choice], steps);
+ return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
+}
+/** After explicit owned counters, finish only the original shipment parent. */
+export function finishSpiritualAdvisorFlow(source: Game, steps: SpiritualAdvisorFlowStep[] = []): Game {
+ return advanceSpiritualAdvisorFlow(source,
+ game => !game.pendingAmbassador && spiritualAdvisorFlowClean(game), steps);
+}
diff --git a/tests/guild-ambassador-advisor-quote.test.ts b/tests/guild-ambassador-advisor-quote.test.ts
index 19002cd5..61371289 100644
--- a/tests/guild-ambassador-advisor-quote.test.ts
+++ b/tests/guild-ambassador-advisor-quote.test.ts
@@ -67,7 +67,7 @@ void test('advanced accompaniment uses the exact shipment territory with its ori
assert.equal(JSON.stringify({ g, shipment }), before);
});
-void test('Basic accompaniment request and explicit non-accompaniment both grant Polar Sink only', () => {
+void test('Polar fallback stays ordinary in Basic and starts advisor-side in Advanced', () => {
for (const advanced of [false, true]) {
const { g, shipment } = fixture(advanced);
for (const accompany of advanced
@@ -79,7 +79,7 @@ void test('Basic accompaniment request and explicit non-accompaniment both grant
sector: 0,
amount: 1,
elite: 0,
- advisors: false,
+ advisors: advanced,
});
}
});
@@ -124,7 +124,7 @@ void test('historical primary losses, changed alliances and reserves do not inva
g.players[1].ally = 'm';
g.players[3].ally = 'p';
const result = quote(g, shipment, { accompany: true });
- assert.equal(result.advisors, false); // Empty territory converts stance normally.
+ assert.equal(result.advisors, true); // Empty Advanced arrival converts separately after placement.
assert.equal(result.amount, 1);
assert.equal(result.territory, 'hagga_basin');
});
@@ -135,7 +135,7 @@ void test('current advisor/fighter stance and Ecaz shared occupation determine c
g.players[1].forces = { 'carthag:11': 2 };
g.players[3].forces = { 'carthag:11': 1 };
assert.equal(quote(g, shipment, { accompany: true }).advisors, true);
- g.players[0].forces = { 'carthag:11': 1 }; // Existing fighters remain fighters.
+ g.players[0].forces = { 'carthag:11': 1 }; // Retained fighters forbid fresh accompaniment here.
assert.throws(
() => quote(g, shipment, { accompany: true }),
GuildAmbassadorShipmentError,
@@ -147,7 +147,10 @@ void test('current advisor/fighter stance and Ecaz shared occupation determine c
g.players[2].forces = { 'carthag:11': 1 };
g.players[0].ally = 'e';
g.players[2].ally = 'b';
- assert.equal(quote(g, shipment, { accompany: true }).advisors, false);
+ assert.throws(
+ () => quote(g, shipment, { accompany: true }),
+ /cannot accompany/,
+ );
});
void test('invalid BG/source identity and forged shipment shape reject without mutation', () => {
diff --git a/tests/guild-ambassador-bots.test.ts b/tests/guild-ambassador-bots.test.ts
index 3ec73c36..16449067 100644
--- a/tests/guild-ambassador-bots.test.ts
+++ b/tests/guild-ambassador-bots.test.ts
@@ -308,7 +308,7 @@ void test('Guild Ambassador accompaniment candidates exclude the storm sector at
for (const level of DIFFICULTIES) {
const v = view(g, 'al', level);
const before = structuredClone(v);
- const descriptor = v.guildAmbassadorAdvisorChoices!;
+ const descriptor = v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!;
assert.ok(
descriptor.choices.some(
(c) => c.accompany && c.sector === 14 && c.blocked,
@@ -348,7 +348,7 @@ void test('projected blocked accompaniment falls back to Polar Sink, then declin
const g = advisorWindow();
for (const level of DIFFICULTIES) {
const v = view(g, 'al', level);
- const choices = v.guildAmbassadorAdvisorChoices!.choices;
+ const choices = (v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!).choices;
// The policy consumes projected blocking regardless of its public cause.
// Server-side competing-arrival rules have separate engine fixtures.
for (const c of choices)
diff --git a/tests/guild-ambassador-engine.test.ts b/tests/guild-ambassador-engine.test.ts
index 7f8f4b57..d8e91fdb 100644
--- a/tests/guild-ambassador-engine.test.ts
+++ b/tests/guild-ambassador-engine.test.ts
@@ -20,6 +20,7 @@ import {
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { createTechTokens } from '../game/tech-tokens';
+import { recordControlledLegacyBgAdvisorResponse } from './fixture-bg-ambassador-arrival';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
@@ -501,6 +502,13 @@ void test('Intrusion resolves before the independent accompanying advisor choice
!!p(g, 'beneGesserit').advisors?.sietch_tabr,
outcome === 'allow',
);
+ if (outcome !== 'allow') {
+ const before = reload(g);
+ assert.throws(() => applyAction(g, 'beneGesserit', {
+ type: 'decision', accept: true, accompany: true,
+ }), /cannot accompany/);
+ assert.deepEqual(reload(g), before);
+ }
g = applyAction(reload(g), 'beneGesserit', {
type: 'decision',
accept: true,
@@ -733,7 +741,7 @@ void test('free off-planet shipment accrues Heighliner income once, with Guild-o
}
});
-void test('accompanying BG fighters trigger a second Ambassador and preserve the original phase-five or worm continuation until that child completes', () => {
+void test('controlled recorded legacy BG fighter accompaniment preserves its second Ambassador and original phase-five or worm continuation', () => {
for (const phase of [5, 1] as const) {
const initial = fixture(
'atreides',
@@ -794,11 +802,7 @@ void test('accompanying BG fighters trigger a second Ambassador and preserve the
accept: false,
});
assert.equal(g.decision?.kind, 'advisor');
- g = applyAction(reload(g), 'beneGesserit', {
- type: 'decision',
- accept: true,
- accompany: true,
- });
+ g = recordControlledLegacyBgAdvisorResponse(reload(g));
assert.equal(g.response?.kind, 'advisor');
assert.equal(g.pendingAmbassador?.event, parentEvent);
g = allow(reload(g));
diff --git a/tests/homeworld-spiritual-advisors.test.ts b/tests/homeworld-spiritual-advisors.test.ts
index 4d4bf428..fbc1fd38 100644
--- a/tests/homeworld-spiritual-advisors.test.ts
+++ b/tests/homeworld-spiritual-advisors.test.ts
@@ -74,6 +74,17 @@ function setup(reserves = 11, advanced = true, withKarama = true) {
return { g, karama };
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+function allowPolarShipment(g: Game) {
+ const before = structuredClone(g.players[1]);
+ g = applyAction(reload(g), 'atreides', { type: 'passResponse' });
+ if (g.advanced) {
+ assert.equal(g.response?.kind, 'advisorFlip');
+ assert.ok(g.response?.spiritualAdvisorSource);
+ assert.deepEqual(g.players[1], before, 'Incoming advisors must match the existing Polar fighters before withdrawal.');
+ g = applyAction(reload(g), 'atreides', { type: 'passResponse' });
+ }
+ return g;
+}
function ship(g: Game) {
return applyAction(g, 'atreides', {
type: 'ship',
@@ -118,7 +129,7 @@ void test('actual Basic and Advanced shipments offer optional one or two at high
assert.equal(g.response?.kind, 'advisor');
assert.equal(g.response?.amount, amount);
assert.equal(g.players[1].reserves, 11);
- g = applyAction(reload(g), 'atreides', { type: 'passResponse' });
+ g = allowPolarShipment(g);
assert.equal(g.players[1].reserves, 11 - amount);
assert.equal(g.players[1].forces['polar_sink:0'], 9 + amount);
assert.equal(g.response, null);
@@ -149,7 +160,7 @@ void test('a low Wallach suppresses the actual free advisor decision and does no
void test('the next actual shipment rechecks Wallach after a high population group lowers it to nine', () => {
let g = choose(ship(setup().g));
- g = applyAction(g, 'atreides', { type: 'passResponse' });
+ g = allowPolarShipment(g);
assert.equal(g.players[1].reserves, 9);
// Stage a later unused shipment opportunity; conserve all earlier resources.
g.turn++;
@@ -235,6 +246,10 @@ void test('low Wallach still permits paid BG shipments and movement of its exist
sector: 10,
amount: 1,
});
+ assert.equal(g.response?.kind, 'advisorFlip');
+ assert.ok(g.response?.advisorMatchingSource);
+ assert.equal(g.players[1].reserves, 10, 'The ordinary paid packet waits for its independent matching response.');
+ g = applyAction(g, 'atreides', { type: 'passResponse' });
assert.equal(g.players[1].reserves, 9);
assert.equal(g.players[1].forces['arrakeen:10'], 2);
assert.ok(g.players[1].advisors?.arrakeen);
diff --git a/tests/spiritual-advisor-engine.test.ts b/tests/spiritual-advisor-engine.test.ts
new file mode 100644
index 00000000..1ee8fa2a
--- /dev/null
+++ b/tests/spiritual-advisor-engine.test.ts
@@ -0,0 +1,144 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction,normalizeAutomaticGame,viewGame,type Action,type Game } from '../game/engine';
+import { isAdvisor } from '../game/advisors';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { createSpiritualAdvisorFlowFixture as fixture,actSpiritualAdvisorFlow as act,
+ spiritualAdvisorFlowPlayer as player,spiritualAdvisorFlowInventory as inventory,
+ finishSpiritualAdvisorFlow as finish,advanceSpiritualAdvisorFlow as advance,
+ spiritualAdvisorFlowClean as clean } from './fixture-spiritual-advisor-flow';
+
+const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,
+ spice:p.spice,shipped:p.shipped,moved:p.moved}));
+function stage(g:Game){
+ const r=g.response;return r?.spiritualAdvisorSource?JSON.stringify([r.kind,r.spiritualAdvisorSource]):
+ r?.automaticAdvisorFlip?.producer==='spiritualArrival'?JSON.stringify(r.automaticAdvisorFlip):null;
+}
+function allow(g:Game){
+ const original=stage(g);assert.ok(original);
+ for(let n=0;stage(g)===original&&n<30;n++){
+ const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);
+ g=act(g,actor.id,{type:'passResponse'});
+ }
+ assert.notEqual(stage(g),original);return g;
+}
+function reject(g:Game,actor:string,action:Action){
+ const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
+}
+function cancel(g:Game,holder:string,card:string){return act(g,holder,{type:'card',card,mode:'cancel'});}
+
+for(const parent of ['ordinary','ambassador'] as const){
+ void test(`native ${parent} retained non-Polar fighters block accompaniment in source, projected choices and settlement`,()=>{
+ const f=fixture({parent}),choices=viewGame(f.game,f.bg).spiritualAdvisorChoices;
+ assert.ok(choices);assert.ok(choices.choices.filter(c=>c.accompany).every(c=>c.blocked));
+ assert.ok(choices.choices.some(c=>!c.accompany&&!c.blocked));
+ reject(f.game,f.bg,f.accompany);
+ assert.equal(f.game.pendingSpiritualAdvisor,undefined);
+ });
+ void test(`native ${parent} shipping prevention preserves the original committed foreign shipment`,()=>{
+ const f=fixture({parent}),pending=act(f.game,f.bg,f.sink);
+ assert.equal(pending.response?.kind,'advisor');assert.ok(pending.response?.spiritualAdvisorSource);
+ assert.equal(pending.pendingSpiritualAdvisor?.stage,'shipping');assert.deepEqual(physical(pending),physical(f.game));
+ const done=finish(cancel(pending,f.holder,f.karama));
+ assert.equal(done.pendingSpiritualAdvisor,undefined);assert.equal(player(done,f.bg).forces['polar_sink:0'],undefined);
+ assert.deepEqual(physical(done),physical(f.game));assert.deepEqual(inventory(done),f.originalInventory);
+ assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);
+ });
+ for(const prevented of [false,true])void test(`native ${parent} empty Polar arrival ${prevented?'prevents':'allows'} its separate committed alone conversion`,()=>{
+ const f=fixture({parent}),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
+ assert.equal(pending.response?.automaticAdvisorFlip?.producer,'spiritualArrival');
+ assert.equal(pending.pendingSpiritualAdvisor?.stage,'arrival');assert.equal(player(pending,f.bg).forces['polar_sink:0'],1);
+ assert.equal(player(pending,f.bg).reserves,player(f.game,f.bg).reserves-1);
+ assert.equal(isAdvisor(player(pending,f.bg),'polar_sink'),true);
+ assert.equal(pending.log.filter(l=>l.text.startsWith(player(pending,f.bg).name+' sent 1 free')).length,1);
+ const committed=physical(pending);assert.deepEqual(physical(normalizeAutomaticGame(pending)),committed);
+ const done=finish(prevented?cancel(pending,f.holder,f.karama):allow(pending));
+ assert.equal(done.pendingSpiritualAdvisor,undefined);assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
+ assert.deepEqual(physical(done),committed);assert.equal(isAdvisor(player(done,f.bg),'polar_sink'),prevented);
+ assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(prevented));
+ assert.deepEqual(inventory(done),f.originalInventory);
+ });
+ void test(`native ${parent} accepted Intrusion permits same-type advisor accompaniment without another flip`,()=>{
+ const f=fixture({parent,stance:'advisors'}),source=physical(f.game),pending=act(f.game,f.bg,f.accompany);
+ assert.deepEqual(physical(pending),source);
+ const done=finish(allow(pending));assert.equal(done.pendingSpiritualAdvisor,undefined);
+ assert.equal(done.response?.kind==='advisorFlip',false);assert.equal(player(done,f.bg).forces[f.foreignLocation],2);
+ assert.equal(isAdvisor(player(done,f.bg),f.foreignDestination),true);
+ assert.equal(player(done,f.bg).reserves,player(f.game,f.bg).reserves-1);
+ assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
+ });
+ for(const prevented of [false,true])void test(`native ${parent} Polar fighter matching ${prevented?'restores the owned chooser':'commits the incoming fighter'} after distinct shipping allowance`,()=>{
+ const f=fixture({parent,stance:'polarFighters'}),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
+ assert.equal(pending.response?.kind,'advisorFlip');assert.ok(pending.response?.spiritualAdvisorSource);
+ assert.equal(pending.response?.automaticAdvisorFlip,undefined);assert.equal(pending.pendingSpiritualAdvisor?.stage,'matching');
+ assert.deepEqual(physical(pending),physical(f.game));
+ let done=prevented?cancel(pending,f.holder,f.karama):allow(pending);
+ if(prevented){
+ assert.equal(done.decision?.kind,'advisor');assert.equal(done.pendingSpiritualAdvisor?.stage,'offer');
+ assert.deepEqual(physical(done),physical(f.game));reject(done,f.bg,f.sink);reject(done,f.bg,f.accompany);
+ assert.ok(viewGame(done,f.bg).spiritualAdvisorChoices?.choices.every(c=>c.blocked));
+ done=act(done,f.bg,f.decline);
+ }else{
+ assert.equal(player(done,f.bg).forces['polar_sink:0'],2);
+ assert.equal(player(done,f.bg).reserves,player(f.game,f.bg).reserves-1);
+ assert.equal(isAdvisor(player(done,f.bg),'polar_sink'),false);
+ }
+ done=finish(done);assert.equal(done.pendingSpiritualAdvisor,undefined);
+ assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(prevented));assert.deepEqual(inventory(done),f.originalInventory);
+ });
+ void test(`native ${parent} changed original parent rejects before a shipping counter spends Karama`,()=>{
+ const f=fixture({parent}),pending=act(f.game,f.bg,f.sink);
+ pending.pendingSpiritualAdvisor!.signature='different-original-parent';
+ reject(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
+ assert.ok(player(pending,f.holder).hand.some(c=>c.id===f.karama));
+ });
+ void test(`controlled ${parent} late retained-fighter change restores the legal chooser before any matching or placement`,()=>{
+ const f=fixture({parent,stance:'advisors'}),pending=act(f.game,f.bg,f.accompany);
+ // A controlled late stance change tests revalidation; it is not a native
+ // earned action while this declared power is pending.
+ delete player(pending,f.bg).advisors![f.foreignDestination];
+ const before=physical(pending),done=allow(pending);
+ assert.equal(done.decision?.kind,'advisor');assert.equal(done.pendingSpiritualAdvisor?.stage,'offer');
+ assert.equal(done.response,null);assert.deepEqual(physical(done),before);
+ assert.ok(viewGame(done,f.bg).spiritualAdvisorChoices?.choices.filter(c=>c.accompany).every(c=>c.blocked));
+ assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));
+ });
+}
+
+void test('a second genuine foreign shipment joins the continuously-alone held advisor group without rearming conversion',()=>{
+ const f=fixture(),shipping=act(f.game,f.bg,f.sink),pending=allow(shipping);
+ let g=finish(cancel(pending,f.holder,f.karama));
+ const held=player(g,f.bg).advisors?.polar_sink?.aloneFlipHold;assert.ok(held);
+ g=act(g,f.holder,{type:'endMovement'});
+ g=advance(g,state=>state.phase===5&&state.active===f.other&&clean(state));
+ g=act(g,f.other,{type:'ship',territory:'carthag',sector:11,amount:1,elite:0,allyPayment:0});
+ g=advance(g,state=>state.decision?.kind==='advisor');
+ const before=player(g,f.bg).reserves;
+ g=act(g,f.bg,f.sink);
+ assert.equal(g.pendingSpiritualAdvisor,undefined);assert.equal(g.response?.automaticAdvisorFlip,undefined);
+ assert.equal(player(g,f.bg).forces['polar_sink:0'],2);
+ assert.equal(player(g,f.bg).reserves,before-1);
+ assert.equal(player(g,f.bg).advisors?.polar_sink?.aloneFlipHold,held);
+ assert.equal(isAdvisor(player(g,f.bg),'polar_sink'),true);assert.deepEqual(inventory(g),f.originalInventory);
+});
+
+void test('existing profiles choose projected legal targets and answer all applicable Spiritual layers',()=>{
+ for(const parent of ['ordinary','ambassador'] as const)for(const stance of ['fighters','advisors','polarFighters'] as const)
+ for(const profile of DIFFICULTIES){
+ const f=fixture({parent,stance}),own=viewGame(f.game,f.bg);own.players.find(p=>p.id===f.bg)!.bot=profile;
+ const choices=botActions(own);assert.ok(choices.length);const shipping=act(f.game,f.bg,choices[0]);
+ assert.ok(shipping.response?.spiritualAdvisorSource);
+ const counter=viewGame(shipping,f.holder);counter.players.find(p=>p.id===f.holder)!.bot=profile;
+ const answers=botActions(counter);assert.ok(answers.length);assert.doesNotThrow(()=>act(shipping,f.holder,answers[0]));
+ const next=allow(shipping);
+ if(stage(next)){
+ const reply=viewGame(next,f.holder);reply.players.find(p=>p.id===f.holder)!.bot=profile;
+ const actions=botActions(reply);assert.ok(actions.length);assert.doesNotThrow(()=>act(next,f.holder,actions[0]));
+ }
+ if(stance==='polarFighters'){
+ const restored=cancel(next,f.holder,f.karama),view=viewGame(restored,f.bg);view.players.find(p=>p.id===f.bg)!.bot=profile;
+ const alternatives=botActions(view);assert.ok(alternatives.length);assert.doesNotThrow(()=>act(restored,f.bg,alternatives[0]));
+ }
+ }
+});
diff --git a/tests/spiritual-advisor.test.ts b/tests/spiritual-advisor.test.ts
new file mode 100644
index 00000000..a49b6d7b
--- /dev/null
+++ b/tests/spiritual-advisor.test.ts
@@ -0,0 +1,184 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ spiritualAdvisorArrival,
+ spiritualAdvisorMatchingKey,
+ spiritualAdvisorTargetBlock,
+ SpiritualAdvisorError,
+ validateSpiritualAdvisorSource,
+ type SpiritualAdvisorContext,
+ type SpiritualAdvisorSource,
+} from '../game/spiritual-advisor';
+import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+
+function context(): SpiritualAdvisorContext {
+ return {
+ advanced: true, status: 'playing', phase: 5, turn: 4,
+ players: [
+ {
+ id: 'b', faction: 'beneGesserit',
+ forces: { 'carthag:11': 2, 'imperial_basin:9': 1, 'polar_sink:0': 1 },
+ advisors: { imperial_basin: { lockedTurn: 4 } },
+ },
+ { id: 'a', faction: 'atreides', forces: { 'carthag:11': 1, 'imperial_basin:9': 1 } },
+ { id: 'r', faction: 'richese', forces: {} },
+ ],
+ };
+}
+
+function source(fields: Partial = {}): SpiritualAdvisorSource {
+ return {
+ event: 'original-public-spiritual-opportunity', owner: 'b', turn: 4, phase: 5,
+ territory: 'polar_sink', sector: 0, amount: 1, ...fields,
+ };
+}
+
+void test('Advanced Spiritual Advisors cannot accompany retained non-Polar fighters but retain Polar and post-Intrusion choices', () => {
+ const ctx = context();
+ const before = structuredClone(ctx);
+ assert.match(spiritualAdvisorTargetBlock(ctx, 'b', 'carthag')!, /non-Polar territory containing your fighters/);
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', 'polar_sink'), null);
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', { territory: 'imperial_basin', sector: 10 }), null);
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', 'arrakeen'), null);
+ assert.deepEqual(ctx, before);
+ ctx.players[0].advisors = { ...ctx.players[0].advisors, carthag: {} };
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', 'carthag'), null);
+});
+
+void test('Advanced incoming advisors match only the incoming group while alone is determined by foreign effective presence', () => {
+ const ctx = context();
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'carthag'), { advisors: false, matching: true, alone: false });
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'imperial_basin'), { advisors: true, matching: false, alone: false });
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'polar_sink'), { advisors: false, matching: true, alone: true });
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'arrakeen'), { advisors: true, matching: false, alone: true });
+ ctx.players[0].forces = { ...ctx.players[0].forces, 'polar_sink:0': 0 };
+ ctx.players[0].advisors = { ...ctx.players[0].advisors, polar_sink: {} };
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'polar_sink'), { advisors: true, matching: false, alone: true });
+ ctx.players[2].noField = { deployed: { location: { territory: 'polar_sink', sector: 0 } } };
+ Object.defineProperty(ctx.players[2].noField, 'tokens', {
+ get() { throw new Error('Spiritual arrival inspected concealed denominations'); },
+ });
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'polar_sink'), { advisors: true, matching: false, alone: false });
+});
+
+void test('Basic arrivals retain their fighter behavior without applying Advanced matching or alone rules', () => {
+ const ctx = context();
+ ctx.advanced = false;
+ for (const player of ctx.players) {
+ Object.defineProperty(player, 'forces', {
+ get() { throw new Error('Basic arrival read Advanced stance eligibility'); },
+ });
+ }
+ for (const target of ['carthag', 'imperial_basin', 'polar_sink', 'arrakeen']) {
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', target), null);
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', target), { advisors: false, matching: false, alone: false });
+ }
+});
+
+void test('public source metadata has exactly seven fields and binds native BG, current turn, permitted phase and amount', () => {
+ for (const advanced of [false, true]) {
+ for (const phase of [1, 3, 5, 8]) {
+ const ctx = context();
+ ctx.advanced = advanced;
+ ctx.phase = phase;
+ for (const amount of [1, 2]) {
+ const receipt = source({ phase, amount });
+ const before = structuredClone({ ctx, receipt });
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
+ assert.deepEqual({ ctx, receipt }, before);
+ assert.deepEqual(Object.keys(receipt).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
+ }
+ }
+ }
+});
+
+void test('current board source validation admits placed HMS and revealed Discovery interiors without adding arrival permission', () => {
+ const ctx = context();
+ const mobile = source({ territory: MOBILE_STRONGHOLD, sector: 0 });
+ assert.throws(() => validateSpiritualAdvisorSource(ctx, mobile), SpiritualAdvisorError);
+ ctx.mobileStronghold = { location: 'imperial_basin:9' };
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, mobile));
+ ctx.players[0].forces = { [MOBILE_LOCATION]: 1 };
+ assert.match(spiritualAdvisorTargetBlock(ctx, 'b', MOBILE_STRONGHOLD)!, /non-Polar/);
+ const interior = source({ territory: 'cistern', sector: 0 });
+ ctx.discoveries = {
+ tokens: [{ face: 'cistern', status: 'placed', territory: 'hagga_basin', sector: 12, revealedTurn: 1 }],
+ };
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, interior));
+ ctx.discoveries.tokens[0].revealedTurn = null;
+ assert.throws(() => validateSpiritualAdvisorSource(ctx, interior), SpiritualAdvisorError);
+});
+
+void test('stale source fields, wrong owner, malformed quantities and private payloads throw the named source error', () => {
+ const changes: ((ctx: SpiritualAdvisorContext, receipt: SpiritualAdvisorSource) => void)[] = [
+ (ctx) => { ctx.status = 'setup'; },
+ (ctx) => { ctx.turn++; },
+ (ctx) => { ctx.phase = 6; },
+ (ctx) => { ctx.players[0].faction = 'atreides'; },
+ (ctx) => { ctx.players = [...ctx.players, { ...ctx.players[0] }]; },
+ (_ctx, receipt) => { receipt.event = ''; },
+ (_ctx, receipt) => { receipt.owner = 'a'; },
+ (_ctx, receipt) => { receipt.owner = 'missing'; },
+ (_ctx, receipt) => { receipt.turn = 3; },
+ (_ctx, receipt) => { receipt.phase = 0; },
+ (_ctx, receipt) => { receipt.territory = 'homeworld:guild'; },
+ (_ctx, receipt) => { receipt.territory = 'missing'; },
+ (_ctx, receipt) => { receipt.sector = 1; },
+ (_ctx, receipt) => { receipt.amount = 0; },
+ (_ctx, receipt) => { receipt.amount = 3; },
+ (_ctx, receipt) => { receipt.amount = 1.5; },
+ ];
+ for (const change of changes) {
+ const ctx = context();
+ const receipt = source();
+ change(ctx, receipt);
+ const before = structuredClone({ ctx, receipt });
+ assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), (error: unknown) =>
+ error instanceof SpiritualAdvisorError && error.name === 'SpiritualAdvisorError');
+ assert.deepEqual({ ctx, receipt }, before);
+ }
+ for (const malformed of [
+ null, [], { ...source(), order: 'private-parent' }, { ...source(), card: 'private-cost' },
+ { ...source(), amount: '1' }, { ...source(), sector: NaN },
+ ]) {
+ assert.throws(() => validateSpiritualAdvisorSource(context(), malformed as unknown as SpiritualAdvisorSource), SpiritualAdvisorError);
+ }
+});
+
+void test('the matching denial key preserves the original event and packet while ignoring only destination sector', () => {
+ const original = source({ territory: 'imperial_basin', sector: 9 });
+ const key = spiritualAdvisorMatchingKey(original);
+ assert.equal(spiritualAdvisorMatchingKey({ ...original, sector: 10 }), key);
+ for (const fields of [
+ { event: 'another-original-opportunity' }, { owner: 'other' }, { turn: 5 },
+ { territory: 'carthag' }, { amount: 2 },
+ ]) {
+ assert.notEqual(spiritualAdvisorMatchingKey({ ...original, ...fields }), key);
+ }
+});
+
+void test('stance and source helpers never inspect resources, private original frames or automatic-flip hold contents', () => {
+ const ctx = context();
+ for (const player of ctx.players) {
+ for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'moved', 'shipped']) {
+ Object.defineProperty(player, field, {
+ get() { throw new Error(`Spiritual helper read ${player.id} private entitlement ${field}`); },
+ });
+ }
+ }
+ for (const stance of Object.values(ctx.players[0].advisors!)) {
+ for (const field of ['lockedTurn', 'aloneFlipHold']) {
+ Object.defineProperty(stance, field, {
+ get() { throw new Error(`Spiritual helper read an unrelated stance hold ${field}`); },
+ });
+ }
+ }
+ for (const field of ['pendingSpiritualAdvisor', 'pendingShipment', 'stormCard', 'stormForecast', 'homeworlds']) {
+ Object.defineProperty(ctx, field, {
+ get() { throw new Error(`Spiritual helper read private original parent ${field}`); },
+ });
+ }
+ assert.equal(spiritualAdvisorTargetBlock(ctx, 'b', 'imperial_basin'), null);
+ assert.deepEqual(spiritualAdvisorArrival(ctx, 'b', 'imperial_basin'), { advisors: true, matching: false, alone: false });
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source({ amount: 2 })));
+});