Separate Spiritual Advisor shipment matching and alone conversion

Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
This commit is contained in:
Quintine committed 2026-10-24 22:28:42 +11:00
1 parent 438a5692a6
commit 767bace774
24 files changed
+1536 -69

No files matched your search

+14 -3
View File
@@ -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.
</p>
)}
{g.response.kind==='advisorFlip'&&g.response.spiritualAdvisorSource&&(
<p className="notice">
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.
</p>
)}
{g.response.kind === 'advisor' && (
<p className="notice">
{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.</>}
</p>
)}
{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.
</p>
{g.guildAmbassadorAdvisorChoices ? (
g.guildAmbassadorAdvisorChoices.choices.map((choice) => {
{g.advanced&&<p className="notice">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.</p>}
{(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices) ? (
(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.map((choice) => {
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
return (
<div key={reasonId}>
+4 -3
View File
@@ -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.
+32 -9
View File
@@ -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
+43
View File
@@ -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
+21
View File
@@ -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
+113
View File
@@ -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.
+25 -2
View File
@@ -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<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }>,
) {
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) &&
+5 -2
View File
@@ -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 [
+279 -10
View File
@@ -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<Decision,{kind:'advisor'}>;
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<typeof quoteGuildAmbassadorAdvisor>){
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<T>(quote: () => T): T {
try { return quote(); }
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
}
function spiritualRule<T>(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<Decision,{kind:'advisor'}>){
return JSON.stringify([g.turn,g.phase,g.active,decision,decision.ambassadorEvent
? g.pendingAmbassador : g.pendingArrivalReaction??null]);
}
function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,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<Decision,{kind:'advisor'}>,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<typeof frame.order>;
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<T>(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
+6 -1
View File
@@ -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) &&
+7 -1
View File
@@ -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'
+1
View File
@@ -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',
+23 -6
View File
@@ -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",
+123
View File
@@ -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<Record<string, unknown>>;
};
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<string, unknown> =>
!!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.');
}
+112
View File
@@ -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<AutomaticAdvisorFlipSource, { producer: 'groundMovement' }>;
type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>;
@@ -26,6 +27,7 @@ type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworl
type FaceDanceSource = Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>;
type StormSource = Extract<AutomaticAdvisorFlipSource, { producer: 'storm' }>;
type RelocationSource = Extract<AutomaticAdvisorFlipSource, { producer: 'specialRelocation' }>;
type SpiritualArrivalSource = Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }>;
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));
});
+55 -15
View File
@@ -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);
+24 -2
View File
@@ -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 &&
+284
View File
@@ -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<AmbassadorEffect, 'ecaz'>[] = ['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 = <V extends ArrayBufferView | null>(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<typeof createSpiritualAdvisorFlowFixture>;
/** 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);
}
+8 -5
View File
@@ -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', () => {
+2 -2
View File
@@ -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)
+10 -6
View File
@@ -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));
+17 -2
View File
@@ -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);
+144
View File
@@ -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]));
}
}
});
+184
View File
@@ -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> = {}): 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 })));
});