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

+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 })));
});