diff --git a/components/caladan-reinforcement.tsx b/components/caladan-reinforcement.tsx
index 0326594e..8d9dac1b 100644
--- a/components/caladan-reinforcement.tsx
+++ b/components/caladan-reinforcement.tsx
@@ -40,6 +40,12 @@ function ReinforcementChoice({ game, act, busy }: Props) {
Add one reserve force at the battle location, or leave it in reserves.
+
+ Provisional order: this optional force joins the original victorious
+ army before any later Face Dance. If replacement occurs, it returns with
+ the surviving army and counts toward the current replacement limit.
+ This does not create another victory or grant another Caladan reinforcement.
+
Reinforcement destination
quoteHomeworldVictoryReinforcementDestination(g,
@@ -28779,7 +28784,7 @@ function projectedHomeworldVictoryReturn(g: Game, player: string) {
if (frame?.stage !== 'choice') return null;
const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame)));
return { event: frame.event, player: frame.player, territory: frame.territory, amount: 1 as const,
- blocked: frame.offer?.blocked ?? quote.blocked,
+ blocked: homeworldVictoryStoredBlock(frame) ?? quote.blocked,
destinations: player === frame.player ? quote.destinations : [],
};
}
@@ -28810,7 +28815,7 @@ function finishBattle(g: Game) {
rihani.signature = rihaniObligationSignature(g.lastBattleContext!);
}
if ((next.kind === 'faceDance' || next.kind === 'board') &&
- openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
+ openHomeworldVictoryReturn(g)) return;
const choamInspection = g.lastBattleContext?.nexusChoamInspection;
if (next.kind === 'board' && choamInspection?.stage === 'pending') {
choamInspection.stage = 'offer';
diff --git a/game/reference.ts b/game/reference.ts
index b103f14e..fcc3bad8 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4714,7 +4714,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
- 'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
+ 'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Storm, mobile stronghold and unresolved entry restrictions remain.',
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
@@ -4952,6 +4952,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/homeworld-revival-deployment-controls.test.ts',
'tests/homeworld-revival-deployment-recovery.test.ts',
'tests/homeworld-victory-reinforcement.test.ts',
+ 'tests/caladan-face-dance-engine.test.ts',
+ 'tests/fixture-caladan-face-dance.ts',
+ 'tests/caladan-face-dance-homeworld.test.ts',
+ 'tests/fixture-caladan-face-dance-homeworld.ts',
+ 'tests/caladan-face-dance-controls.test.ts',
'tests/caladan-reinforcement-controls.test.ts',
'tests/homeworld-victory-reinforcement-recovery.test.ts',
'tests/tupile-intelligence.test.ts',
diff --git a/game/spiritual-advisor.ts b/game/spiritual-advisor.ts
index 212dabd4..32d21253 100644
--- a/game/spiritual-advisor.ts
+++ b/game/spiritual-advisor.ts
@@ -30,10 +30,12 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
turn: number;
phase: number;
homeworldRevivalReturn?: Pick, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
+ homeworldVictoryReinforcement?: Pick, 'event' | 'turn' | 'phase' | 'stage'>;
pendingSpiritualAdvisor?: Pick, 'event' | 'turn' | 'phase'> & {
decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
};
- pendingAmbassador?: Pick, 'event' | 'revivalEvent' | 'turn' | 'phase' | 'stage'> | null;
+ pendingAmbassador?: Pick,
+ 'event' | 'revivalEvent' | 'victoryEvent' | 'turn' | 'phase' | 'stage' | 'effect' | 'shipmentReceipt'> | null;
};
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
@@ -120,7 +122,7 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
Object.keys(source).sort().join(',') === (source.phase === 4
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') &&
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
- source.phase === ctx.phase && [1, 3, 4, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
+ source.phase === ctx.phase && [1, 3, 4, 5, 6, 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.');
@@ -128,6 +130,32 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
requireSpiritual(gameTerritories(ctx).some((place) =>
place.id === source.territory && place.sectors.includes(source.sector)),
'Spiritual Advisors need their current board destination and sector.');
+ if (source.phase === 6) {
+ const parent = ctx.homeworldVictoryReinforcement;
+ requireSpiritual(record(parent) && identifier(parent.event) && parent.turn === ctx.turn &&
+ parent.phase === 6 && parent.stage === 'arrival',
+ 'Victory Spiritual Advisors need their original current-turn Caladan arrival parent.');
+ const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
+ const decision = frame?.decision;
+ requireSpiritual(record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 6 &&
+ record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
+ decision.revivalEvent === undefined && record(ambassador) && ambassador.event === decision.ambassadorEvent &&
+ ambassador.victoryEvent === parent.event && ambassador.revivalEvent === undefined &&
+ ambassador.turn === ctx.turn && ambassador.phase === 6 && ambassador.stage === 'arrival' && ambassador.effect === 'guild',
+ 'Victory Spiritual Advisors need their original owned Guild Ambassador child.');
+ const receipt = ambassador.shipmentReceipt;
+ requireSpiritual(record(receipt) && receipt.next === 'advisor' && record(receipt.order) &&
+ identifier(receipt.order.territory) && Number.isSafeInteger(receipt.order.sector) &&
+ identifier(decision.destination),
+ 'Victory Spiritual Advisors need their original committed Guild shipment destination.');
+ const destination = splitLocation(decision.destination);
+ requireSpiritual(decision.destination === `${destination.territory}:${destination.sector}` &&
+ decision.destination === `${receipt.order.territory}:${receipt.order.sector}` &&
+ gameTerritories(ctx).some(place => place.id === destination.territory && place.sectors.includes(destination.sector)),
+ 'Victory Spiritual Advisors need their original board Guild shipment destination.');
+ requireSpiritual(source.territory === destination.territory || source.territory === 'polar_sink',
+ 'Victory Spiritual Advisors need the original Guild shipment territory or Polar Sink.');
+ }
if (source.phase === 4) {
const parent = ctx.homeworldRevivalReturn;
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
diff --git a/tests/caladan-ambassador-engine.test.ts b/tests/caladan-ambassador-engine.test.ts
index 7f157dd8..4e2bc4ec 100644
--- a/tests/caladan-ambassador-engine.test.ts
+++ b/tests/caladan-ambassador-engine.test.ts
@@ -16,13 +16,19 @@ import {
} from './fixture-caladan-victory';
import { caladanAmbassadorFixture as fixture } from './fixture-caladan-ambassador';
+import { fighterCount } from '../game/advisors';
-function open(native = 6) {
+function open(native = 6, polarAlone = false) {
const initial = fixture();
if (native !== 6) {
p(initial, 'a').reserves = native;
p(initial, 'a').forces['polar_sink:0'] -= native - 6;
}
+ if (polarAlone) {
+ // Conserved controlled initial position for the automatic Polar conversion.
+ p(initial, 'a').forces['hagga_basin:12'] = p(initial, 'a').forces['polar_sink:0'];
+ delete p(initial, 'a').forces['polar_sink:0'];
+ }
let g = finishVictoryCalls(
commitVictoryPlans(prepareVictoryBattle(initial, 'arrakeen')),
);
@@ -53,87 +59,59 @@ function allow(g: Game) {
return g;
}
-void test('actual Caladan victory reinforces once and preserves its battle through Guild, BG and Fremen Ambassador children', () => {
+void test('actual Caladan victory keeps its Guild and Advanced Spiritual child without triggering an Ambassador for advisors', () => {
let g = open();
- const event = g.homeworldVictoryReinforcement!.event;
- const parent = g.pendingAmbassador!.event;
- assert.equal(p(g, 'a').reserves, 5);
- assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
- g = applyAction(reload(g), 'ec', {
- type: 'decision',
- event: parent,
- trigger: true,
- beneficiary: 'h',
- });
- g = applyAction(reload(g), 'h', {
- type: 'decision',
- event: parent,
- amount: 2,
- territory: 'sietch_tabr',
- sector: 14,
- });
+ const event = g.homeworldVictoryReinforcement!.event, parent = g.pendingAmbassador!.event;
+ assert.equal(p(g, 'a').reserves, 5); assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
+ g = applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' });
+ g = applyAction(g, 'h', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
assert.equal(g.decision?.kind, 'intrusion');
- g = applyAction(reload(g), 'bg', { type: 'decision', accept: false });
- g = applyAction(reload(g), 'bg', {
- type: 'decision',
- accept: true,
- accompany: true,
- });
+ // Resolve real Intrusion before legal Advanced accompaniment into this territory.
+ g = allow(applyAction(g, 'bg', { type: 'decision', accept: true }));
+ assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 0);
+ g = applyAction(g, 'bg', { type: 'decision', accept: true, accompany: true });
assert.equal(g.response?.kind, 'advisor');
+ assert.equal(g.pendingSpiritualAdvisor!.source!.phase, 6);
+ assert.equal(g.pendingAmbassador!.victoryEvent, event);
g = allow(g);
- assert.equal(g.pendingAmbassador?.entrant, 'bg');
- assert.equal(g.pendingAmbassador?.victoryEvent, event);
- assert.equal(g.pendingAmbassador?.revivalEvent, undefined);
- assert.deepEqual(
- g.homeworldVictoryReinforcement!.ambassadors!.map(
- (entry) => entry.completed,
- ),
- [true, false],
- );
- assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, false);
- assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
- const child = g.pendingAmbassador!.event;
- g = applyAction(reload(g), 'ec', {
- type: 'decision',
- event: child,
- trigger: true,
- beneficiary: 'ec',
- });
- g = applyAction(reload(g), 'ec', {
- type: 'decision',
- event: child,
- forces: { 'red_chasm:7': 1 },
- territory: 'carthag',
- sector: 11,
- });
+ // Advisors are excluded from Ambassador triggers, including the waiting Fremen token.
assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.pendingSpiritualAdvisor ?? null, null);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
- assert.deepEqual(
- g.homeworldVictoryReinforcement!.ambassadors!.map(
- (entry) => entry.completed,
- ),
- [true, true],
- );
+ assert.deepEqual(g.homeworldVictoryReinforcement!.ambassadors!.map(entry => entry.completed), [true]);
+ assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'fremen')!.zone, 'placed');
assert.equal(g.homeworldRevivalReturn, undefined);
- assert.equal(p(g, 'a').reserves, 5);
- assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
+ assert.equal(p(g, 'a').reserves, 5); assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
assert.equal(p(g, 'h').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 2);
- assert.equal(p(g, 'ec').forces['carthag:11'], 1);
+ assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 0);
+ assert.equal(p(g, 'ec').forces['red_chasm:7'], 1);
+ inventory(g); assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
+ assert.throws(() => applyAction(g, 'a', { type: 'decision', event, destination: 'arrakeen:10' }));
+ assert.throws(() => applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' }));
+});
+
+void test('the Battle-phase Guild child can send Spiritual Advisors to Polar and complete their automatic fighter conversion', () => {
+ let g = open(6, true);
+ const event = g.homeworldVictoryReinforcement!.event, parent = g.pendingAmbassador!.event;
+ g = applyAction(g, 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'h' });
+ g = applyAction(g, 'h', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
+ g = applyAction(g, 'bg', { type: 'decision', accept: false });
+ g = applyAction(g, 'bg', { type: 'decision', accept: true, accompany: false });
+ assert.equal(g.pendingSpiritualAdvisor!.source!.phase, 6);
+ assert.equal(g.pendingSpiritualAdvisor!.source!.territory, 'polar_sink');
+ g = allow(g);
+ assert.equal(p(g, 'bg').forces['polar_sink:0'], 1);
+ assert.equal(fighterCount(p(g, 'bg'), 'polar_sink'), 1);
+ assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 1);
+ assert.equal(fighterCount(p(g, 'bg'), 'sietch_tabr'), 1);
+ assert.equal(g.pendingSpiritualAdvisor ?? null, null);
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.homeworldVictoryReinforcement!.event, event);
+ assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.deepEqual(g.homeworldVictoryReinforcement!.ambassadors!.map(entry => entry.completed), [true]);
inventory(g);
- assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
- for (const action of [
- { type: 'decision', event, destination: 'arrakeen:10' },
- {
- type: 'decision',
- event: child,
- forces: { 'red_chasm:7': 1 },
- territory: 'carthag',
- sector: 11,
- },
- ])
- assert.throws(() => applyAction(g, 'a', action));
});
void test('a real victory arrival cannot lose its child, source tag or independent original obligation during recovery', () => {
diff --git a/tests/caladan-face-dance-controls.test.ts b/tests/caladan-face-dance-controls.test.ts
new file mode 100644
index 00000000..3e646982
--- /dev/null
+++ b/tests/caladan-face-dance-controls.test.ts
@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { CaladanReinforcement } from '../components/caladan-reinforcement';
+import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
+import {
+ caladanReinforcementActions, caladanReinforcementCanAct, caladanReinforcementChoice,
+} from '../game/caladan-reinforcement-options';
+
+// Controlled public offer projections supplement genuine Caladan/Face Dance
+// source tests. No victorious army, reserve, dancer identity or pending Game
+// receipt is installed. The stateful component is entered only through React.
+function projected(): GameView {
+ const game = createGame('CALADANFACEDANCECONTROLS', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
+ joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
+ joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
+ const view = viewGame(game, 'a'); view.status = 'playing'; view.phase = 6;
+ view.decision = { kind: 'caladanReinforcement', player: 'a', event: 'controlled-original-caladan-event' };
+ view.caladanReinforcement = {
+ event: 'controlled-original-caladan-event', player: 'a', territory: 'hagga_basin', amount: 1, blocked: null,
+ destinations: [
+ { id: 'hagga_basin:10', name: 'Hagga Basin · sector 10', sector: 10, blocked: 'Storm entry is unavailable.' },
+ { id: 'hagga_basin:12', name: 'Hagga Basin · sector 12', sector: 12, blocked: null },
+ { id: 'hagga_basin:13', name: 'Hagga Basin · sector 13', sector: 13, blocked: null },
+ ],
+ };
+ return view;
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(CaladanReinforcement, { game, busy, act() {} }));
+}
+function button(html: string, label: string) {
+ const found = html.match(new RegExp(`]*>${label}<\\/button>`))?.[0];
+ assert.ok(found, label); return found;
+}
+function order(html: string) {
+ assert.match(html, /aria-label="Provisional Face Dance order"/);
+ assert.match(html, /this optional force joins the original victorious army before any later Face Dance/);
+ assert.match(html, /If replacement occurs, it returns with the surviving army and counts toward the current replacement limit/);
+ assert.match(html, /does not create another victory or grant another Caladan reinforcement/);
+}
+
+void test('Caladan persistently explains the provisional reinforcement-before-Face-Dance order', () => {
+ const game = projected();
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game); order(html);
+ assert.match(html, /Add one reserve force at the battle location, or leave it in reserves/);
+ assert.doesNotMatch(html, /holds the winning identity|will reveal a Face Dancer|Confirm|Acknowledge|countdown/);
+ }
+});
+
+void test('the original pure action helper retains the exact event destination and fixed one-force amount', () => {
+ const game = projected(), before = structuredClone(game);
+ assert.equal(caladanReinforcementCanAct(game), true);
+ for (const destination of ['hagga_basin:12', 'hagga_basin:13']) {
+ assert.deepEqual(caladanReinforcementChoice(game, destination), {
+ blocked: null, action: { type: 'decision', event: 'controlled-original-caladan-event', destination, amount: 1 },
+ });
+ }
+ assert.equal(caladanReinforcementChoice(game, 'invented_destination').action, null);
+ const html = markup(game); order(html);
+ assert.doesNotMatch(button(html, 'Add one reserve force'), /\sdisabled=/);
+ assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
+ assert.doesNotMatch(html, /type="number"|type="range"/);
+ assert.deepEqual(game, before);
+});
+
+void test('busy rendering disables both original choices while preserving the persistent order explanation', () => {
+ const html = markup(projected(), true); order(html);
+ assert.match(button(html, 'Add one reserve force'), /\sdisabled=""/);
+ assert.match(button(html, 'Leave in reserves'), /\sdisabled=""/);
+ assert.match(html, /]*\sdisabled=/);
+});
+
+void test('separate destination blocks remain authoritative without removing the original decline', () => {
+ const game = projected();
+ game.caladanReinforcement!.destinations[1].blocked = 'A separate arrival rule still blocks sector 12.';
+ game.caladanReinforcement!.destinations[2].blocked = 'A separate arrival rule still blocks sector 13.';
+ assert.equal(caladanReinforcementChoice(game, 'hagga_basin:10').blocked, 'Storm entry is unavailable.');
+ assert.equal(caladanReinforcementChoice(game, 'hagga_basin:12').action, null);
+ assert.deepEqual(caladanReinforcementActions(game), [{ type: 'decision', event: 'controlled-original-caladan-event', decline: true }]);
+ const html = markup(game); order(html);
+ assert.match(html, /Storm entry is unavailable/);
+ assert.match(html, /A separate arrival rule still blocks sector 12/);
+ assert.match(html, /A separate arrival rule still blocks sector 13/);
+ assert.match(html, /No legal reinforcement destination/);
+ assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
+ assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
+});
+
+void test('an independently blocked offer is still declinable and never promises that Face Dance makes its destination legal', () => {
+ const game = projected(); game.caladanReinforcement!.blocked = 'This destination requires a separate arrival ruling.';
+ for (let render = 0; render < 2; render++) {
+ const html = markup(game); order(html);
+ assert.match(html, /This destination requires a separate arrival ruling/);
+ assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
+ assert.doesNotMatch(button(html, 'Leave in reserves'), /\sdisabled=/);
+ }
+ assert.equal(caladanReinforcementChoice(game, 'hagga_basin:12').action, null);
+ assert.deepEqual(caladanReinforcementActions(game), [{ type: 'decision', event: 'controlled-original-caladan-event', decline: true }]);
+});
+
+void test('foreign stale and absent offers retain their existing control boundaries', () => {
+ const foreign = projected(); foreign.me = 'e'; assert.equal(markup(foreign), '');
+ const absent = projected(); absent.caladanReinforcement = null; assert.equal(markup(absent), '');
+ const noDecision = projected(); noDecision.decision = null; assert.equal(markup(noDecision), '');
+ const stale = projected(); stale.decision = { kind: 'caladanReinforcement', player: 'a', event: 'old-event' };
+ assert.equal(caladanReinforcementCanAct(stale), false); assert.equal(caladanReinforcementChoice(stale, 'hagga_basin:12').action, null);
+ const html = markup(stale); order(html);
+ assert.match(button(html, 'Add one reserve force'), /\sdisabled=/);
+ assert.match(button(html, 'Leave in reserves'), /\sdisabled=/);
+});
+
+void test('the general owned offer explanation never reads a private Face Dancer identity or opposing hand', () => {
+ const game = projected();
+ for (const player of game.players) for (const field of ['faceDancers', 'traitors', 'hand', 'spice'])
+ Object.defineProperty(player, field, { get() { throw Error(`Caladan guide read private ${field}`); } });
+ const html = markup(game); order(html);
+ assert.doesNotMatch(button(html, 'Add one reserve force'), /\sdisabled=/);
+});
diff --git a/tests/caladan-face-dance-engine.test.ts b/tests/caladan-face-dance-engine.test.ts
new file mode 100644
index 00000000..a52d7af6
--- /dev/null
+++ b/tests/caladan-face-dance-engine.test.ts
@@ -0,0 +1,159 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actCaladanFaceDance, caladanFaceDanceInventory, caladanFaceDancePhysical,
+ caladanFaceDancePlayer as player, caladanFaceDancePopulation, createCaladanFaceDanceFixture,
+ faceDanceCaladanFaceDance, finishCaladanFaceDance, reinforceCaladanFaceDance,
+ type CaladanFaceDanceFixture,
+} from './fixture-caladan-face-dance';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => actCaladanFaceDance(game, actor, action));
+ assert.deepEqual(game, before);
+}
+function source(f: CaladanFaceDanceFixture) {
+ assert.equal(f.game.status, 'playing');
+ assert.equal(f.game.turn, f.beforeBattle.turn);
+ assert.equal(f.game.phase, 6);
+ assert.deepEqual(f.game.expansions, ['ix']);
+ assert.ok(f.game.homeworlds?.custody);
+ assert.ok(!f.game.nexusCards && !f.game.leaderSkills && !f.game.techTokens &&
+ !f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
+ assert.ok(f.game.decision?.kind === 'caladanReinforcement');
+ assert.equal(f.game.decision.player, f.owner);
+ assert.equal(f.game.decision.event, f.event);
+ assert.equal(f.game.response ?? null, null);
+ assert.equal(f.game.phaseOpening ?? null, null);
+ assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
+ assert.equal(f.game.pendingFaceDance!.player, f.dancer);
+ assert.equal(f.game.pendingFaceDance!.winner, f.owner);
+ assert.equal(f.game.pendingFaceDance!.identity, f.leader);
+ assert.equal(f.game.homeworldVictoryReinforcement!.stage, 'choice');
+ assert.equal(f.game.homeworldVictoryReinforcement!.offer!.blocked, null);
+ assert.equal(f.game.homeworldVictoryReinforcement!.offer!.population, 6);
+ assert.equal(f.game.homeworldVictoryReinforcement!.offer!.survivors, 10);
+ assert.equal(caladanFaceDancePopulation(f.afterSetup), 10);
+ assert.equal(caladanFaceDancePopulation(f.game), 6);
+ assert.equal(player(f.game, f.owner).forces[f.location], 10);
+ assert.equal(player(f.game, f.owner).forces['polar_sink:0'], 4);
+ assert.equal(player(f.game, f.dancer).reserves, 20);
+ assert.equal(f.shipments.length, 2);
+ assert.equal(f.shipments[0].step.actor, f.owner);
+ assert.equal(f.shipments[0].step.action.amount, 4);
+ assert.equal(f.shipments[0].step.action.territory, 'polar_sink');
+ assert.ok(f.shipments[0].step.action.homeworldSources);
+ assert.equal(f.shipments[1].step.actor, f.opponent);
+ assert.equal(f.shipments[1].step.action.amount, 1);
+ assert.equal(f.shipments[1].step.action.territory, f.territory);
+ assert.ok(player(f.afterSetup, f.dancer).faceDancers!.some(card => card.leader === f.leader && !card.revealed));
+ assert.ok(!player(f.game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead);
+ assert.equal(f.game.lastBattleContext!.winner, f.owner);
+ assert.equal(f.game.lastBattleContext!.event, f.event);
+ assert.equal(f.sourceActionCount, f.actions.length);
+ assert.deepEqual(caladanFaceDanceInventory(f.game), f.originalInventory);
+}
+function completedGrant(f: CaladanFaceDanceFixture, game: Game, give: boolean) {
+ assert.equal(game.homeworldVictoryReinforcement!.event, f.event);
+ assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.equal(game.homeworldVictoryReinforcement!.destination, give ? f.destination : 'decline');
+ assert.equal(game.lastBattleContext!.caladanReinforcement!.event, f.event);
+ assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
+}
+
+for (const advanced of [false, true]) {
+ for (const give of [true, false]) for (const reveal of [true, false]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} native Caladan ${give ? 'reinforces before' : 'declines before'} Face Dance ${reveal ? 'reveal' : 'decline'}`, () => {
+ const f = createCaladanFaceDanceFixture({ advanced });
+ source(f);
+ let game = reinforceCaladanFaceDance(f, structuredClone(f.game), give);
+ const amount = give ? 11 : 10;
+ assert.equal(game.decision?.kind, 'faceDance');
+ assert.deepEqual(game.pendingFaceDance, f.originalFaceDance);
+ assert.equal(player(game, f.owner).forces[f.location], amount);
+ assert.equal(caladanFaceDancePopulation(game), give ? 5 : 6);
+ assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
+ assert.equal(player(game, f.owner).shipped, player(f.game, f.owner).shipped);
+ assert.equal(player(game, f.owner).moved, player(f.game, f.owner).moved);
+ completedGrant(f, game, give);
+ rejected(game, f.owner, f.action);
+ rejected(game, f.dancer, { type: 'decision', reveal: true, sources: { reserves: amount + 1 }, sector: 10 });
+ const beforeDance = structuredClone(game);
+ game = faceDanceCaladanFaceDance(f, game, reveal);
+ assert.equal(player(game, f.owner).spice, player(beforeDance, f.owner).spice);
+ assert.equal(player(game, f.dancer).spice, player(beforeDance, f.dancer).spice);
+ // Later collection may pay the original or replacement stronghold owner.
+ game = finishCaladanFaceDance(f, game);
+ completedGrant(f, game, give);
+ assert.equal(game.pendingFaceDance ?? null, null);
+ assert.notEqual(game.decision?.kind, 'caladanReinforcement');
+ assert.notEqual(game.decision?.kind, 'faceDance');
+ assert.equal(player(game, f.owner).forces['polar_sink:0'], 4);
+ if (reveal) {
+ assert.equal(player(game, f.owner).forces[f.location] ?? 0, 0);
+ assert.equal(player(game, f.dancer).forces[f.location], amount);
+ assert.equal(player(game, f.dancer).reserves, player(beforeDance, f.dancer).reserves - amount);
+ assert.equal(caladanFaceDancePopulation(game), 16,
+ 'All live winning counters, including the granted one, return to the same native Caladan pool');
+ assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead, true);
+ assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.deaths,
+ player(f.game, f.owner).leaders.find(leader => leader.id === f.leader)!.deaths + 1);
+ assert.equal(player(game, f.dancer).faceDancers!.filter(card => card.leader === f.leader && card.revealed).length, 1);
+ } else {
+ assert.equal(player(game, f.owner).forces[f.location], amount);
+ assert.equal(player(game, f.dancer).forces[f.location] ?? 0, 0);
+ assert.equal(player(game, f.dancer).reserves, 20);
+ assert.equal(caladanFaceDancePopulation(game), give ? 5 : 6);
+ assert.equal(player(game, f.owner).leaders.find(leader => leader.id === f.leader)!.dead, false);
+ assert.ok(player(game, f.dancer).faceDancers!.some(card => card.leader === f.leader && !card.revealed));
+ }
+ rejected(game, f.owner, f.action);
+ assert.deepEqual(caladanFaceDanceInventory(game), f.originalInventory);
+ });
+ }
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} native Caladan pending Face Dance rejects stale, foreign and early reveal choices`, () => {
+ const f = createCaladanFaceDanceFixture({ advanced });
+ source(f);
+ rejected(f.game, f.dancer, f.action);
+ rejected(f.game, f.opponent, f.action);
+ rejected(f.game, f.owner, { ...f.action, event: 'stale' });
+ rejected(f.game, f.owner, { ...f.action, amount: 2 });
+ rejected(f.game, f.dancer, { type: 'decision', reveal: true, sources: { reserves: 11 }, sector: 10 });
+ assert.deepEqual(caladanFaceDancePhysical(f.game), f.originalPhysical);
+ assert.deepEqual(f.game.pendingFaceDance, f.originalFaceDance);
+ assert.equal(f.game.homeworldVictoryReinforcement!.stage, 'choice');
+ assert.deepEqual(caladanFaceDanceInventory(f.game), f.originalInventory);
+ });
+}
+
+void test('four minimal profiles choose the real Caladan reinforcement and native Face Dance without a second grant', () => {
+ for (const advanced of [false, true]) {
+ const f = createCaladanFaceDanceFixture({ advanced });
+ for (const difficulty of DIFFICULTIES) {
+ const ownerView = viewGame(f.game, f.owner);
+ ownerView.players.find(seat => seat.id === f.owner)!.bot = difficulty;
+ const reinforcement = botActions(ownerView)[0];
+ assert.deepEqual(reinforcement, f.action, `${advanced}/${difficulty} reinforces the actual surviving army`);
+ let game = actCaladanFaceDance(structuredClone(f.game), f.owner, reinforcement);
+ assert.equal(player(game, f.owner).forces[f.location], 11);
+ assert.equal(game.decision?.kind, 'faceDance');
+ const dancerView = viewGame(game, f.dancer);
+ dancerView.players.find(seat => seat.id === f.dancer)!.bot = difficulty;
+ const dance = botActions(dancerView)[0];
+ assert.ok(dance);
+ assert.equal(dance.type, 'decision');
+ assert.equal(dance.reveal, true);
+ assert.deepEqual(dance.sources, { reserves: 11 });
+ game = actCaladanFaceDance(game, f.dancer, dance);
+ game = finishCaladanFaceDance(f, game);
+ assert.equal(player(game, f.dancer).forces[f.location], 11);
+ assert.equal(caladanFaceDancePopulation(game), 16);
+ completedGrant(f, game, true);
+ assert.deepEqual(caladanFaceDanceInventory(game), f.originalInventory);
+ }
+ }
+});
diff --git a/tests/caladan-face-dance-homeworld.test.ts b/tests/caladan-face-dance-homeworld.test.ts
new file mode 100644
index 00000000..75d9f92f
--- /dev/null
+++ b/tests/caladan-face-dance-homeworld.test.ts
@@ -0,0 +1,151 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ createCaladanFaceDanceHomeworldFixture as fixture,
+ actCaladanFaceDanceHomeworld as act,
+ finishCaladanFaceDanceHomeworld as finish,
+ caladanFaceDanceHomeworldPlayer as player,
+ caladanFaceDanceHomeworldArmy as army,
+ caladanFaceDanceHomeworldInventory as inventory,
+ caladanFaceDanceHomeworldTraitors as traitors,
+ type CaladanFaceDanceHomeworldFixture as Fixture,
+ type CaladanFaceDanceHomeworldStep as Step,
+} from './fixture-caladan-face-dance-homeworld';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
+}
+function source(f: Fixture) {
+ assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.equal(f.setup.advanced, f.advanced); assert.deepEqual(f.setup.expansions, ['ix']);
+ assert.equal(f.afterSetup.status, 'playing'); assert.ok(f.afterSetup.homeworlds?.custody);
+ assert.equal(f.game.homeworldOccupationPreview, f.afterSetup.homeworldOccupationPreview);
+ assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[2], /CONTROLLED/);
+ assert.equal(f.sourceActionCount, f.actions.length); assert.ok(f.sourceActionCount > 4);
+ assert.equal(player(f.controlled, f.atreides).reserves, 6); assert.equal(army(f.controlled, f.atreides), 3);
+ assert.equal(player(f.controlled, f.atreides).forces['polar_sink:0'], 11);
+ assert.equal(player(f.controlled, f.tleilaxu).reserves, 2);
+ assert.equal(player(f.controlled, f.tleilaxu).forces['polar_sink:0'], 18);
+ assert.equal(player(f.game, f.atreides).reserves, 6); assert.equal(army(f.game, f.atreides), 3);
+ assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 2);
+ assert.equal(player(f.game, f.tleilaxu).reserves, 0); assert.equal(army(f.game, f.tleilaxu), 0);
+ assert.equal(f.game.decision?.kind, 'caladanReinforcement'); assert.equal(f.game.battle ?? null, null);
+ assert.equal(f.game.pendingFaceDance!.player, f.tleilaxu); assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader);
+ assert.equal(player(f.game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
+ assert.equal(f.game.lastBattleContext!.winner, f.atreides); assert.equal(f.game.lastBattleContext!.result, 'normal');
+ assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.length, 47);
+ assert.deepEqual(traitors(f.game), f.originalTraitors);
+ assert.deepEqual(f.game.players.map(p => ({ player: p.id, ids: p.leaders.map(leader => leader.id).sort() })), f.originalLeaders);
+}
+function give(f: Fixture, actions: Step[] = []) {
+ source(f);
+ const offer = viewGame(f.game, f.atreides).caladanReinforcement!;
+ assert.equal(offer.blocked, null); assert.equal(offer.territory, f.territory);
+ assert.deepEqual(offer.destinations.map(destination => ({ id: destination.id, blocked: destination.blocked })),
+ [{ id: f.destination, blocked: null }]);
+ const game = act(f.game, f.atreides, f.reinforcementAction, actions);
+ assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
+ assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
+ assert.deepEqual(game.pendingFaceDance, f.originalPendingFaceDance);
+ assert.equal(game.decision?.kind, 'faceDance'); assert.equal(game.decision?.player, f.tleilaxu);
+ assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
+ assert.deepEqual(inventory(game), f.originalInventory); return game;
+}
+function completed(game: Game, f: Fixture, actions: Step[] = []) {
+ const done = finish(game, actions);
+ assert.equal(done.phase, 7); assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
+ assert.equal(done.pendingFaceDance ?? null, null); assert.equal(viewGame(done, f.atreides).caladanReinforcement, null);
+ assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.equal(done.lastBattleContext!.event, f.event); assert.equal(done.lastBattleContext!.winner, f.atreides);
+ assert.equal(done.lastBattleContext!.caladanReinforcement!.completed, true);
+ assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(traitors(done), f.originalTraitors);
+ const history = f.game.homeworldOccupationHistory;
+ if (history) {
+ const actual = done.homeworldOccupationHistory; assert.ok(actual);
+ assert.deepEqual(actual.sources.slice(0, history.sources.length), history.sources);
+ assert.deepEqual(actual.snapshots.slice(0, history.snapshots.length), history.snapshots);
+ }
+ reject(done, f.atreides, f.reinforcementAction);
+ reject(done, f.tleilaxu, f.faceDanceAction); return done;
+}
+
+for (const advanced of [false, true]) {
+ const band = advanced ? 'Advanced' : 'Basic';
+ void test(`${band} controlled native Homeworld victory reinforces before Face Dance and returns all four winning visitors once`, () => {
+ const f = fixture({ advanced }), actions: Step[] = [];
+ let game = give(f, actions);
+ const wallets = game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved }));
+ const beforeLeader = structuredClone(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!);
+ game = act(game, f.tleilaxu, f.faceDanceAction, actions);
+ assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
+ assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
+ assert.equal(player(game, f.tleilaxu).reserves, 4); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
+ assert.equal(player(game, f.tleilaxu).tanks, 2);
+ const dead = player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!;
+ assert.equal(dead.dead, true); assert.equal(dead.deaths, beforeLeader.deaths + 1);
+ assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
+ assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved })), wallets);
+ game = completed(game, f, actions);
+ assert.equal(actions.filter(step => step.action.reveal === true).length, 1);
+ assert.equal(actions.filter(step => step.action.destination === f.destination).length, 1);
+ assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 4);
+ });
+
+ void test(`${band} controlled native Homeworld victory keeps its fourth visitor when Tleilaxu declines Face Dance`, () => {
+ const f = fixture({ advanced }), actions: Step[] = [];
+ let game = give(f, actions);
+ const before = structuredClone(game.players), custody = structuredClone(game.homeworlds!.custody);
+ game = act(game, f.tleilaxu, f.faceDanceDecline, actions); game = completed(game, f, actions);
+ assert.deepEqual(game.players, before); assert.deepEqual(game.homeworlds!.custody, custody);
+ assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
+ assert.equal(player(game, f.tleilaxu).reserves, 0); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
+ assert.equal(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
+ assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
+ });
+
+ void test(`${band} controlled native Homeworld reinforcement decline leaves the original three-counter Face Dance cap`, () => {
+ const f = fixture({ advanced }); source(f);
+ let game = act(f.game, f.atreides, f.reinforcementDecline);
+ assert.equal(game.decision?.kind, 'faceDance'); assert.equal(player(game, f.atreides).reserves, 6);
+ assert.equal(army(game, f.atreides), 3); assert.deepEqual(game.pendingFaceDance, f.originalPendingFaceDance);
+ reject(game, f.tleilaxu, f.faceDanceAction);
+ game = act(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 3 } });
+ game = completed(game, f);
+ assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
+ assert.equal(player(game, f.tleilaxu).reserves, 3); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
+ assert.equal(player(game, f.tleilaxu).tanks, 2);
+ });
+
+ void test(`${band} controlled Homeworld reinforcement and subsequent Face Dance reject stale, foreign, quantity and live-source over-cap actions`, () => {
+ const f = fixture({ advanced }); source(f);
+ reject(f.game, f.tleilaxu, f.reinforcementAction);
+ reject(f.game, f.atreides, { ...f.reinforcementAction, event: `${f.event}:stale` });
+ reject(f.game, f.atreides, { ...f.reinforcementAction, amount: 2 });
+ reject(f.game, f.atreides, { ...f.reinforcementAction, destination: 'homeworld:guild' });
+ const game = give(f);
+ reject(game, f.atreides, f.reinforcementAction);
+ reject(game, f.atreides, f.faceDanceAction);
+ reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 5 } });
+ reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 19 } });
+ reject(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'hagga_basin:12': 1 } });
+ completed(act(game, f.tleilaxu, f.faceDanceAction), f);
+ });
+}
+
+void test('all four minimal profiles use legal Caladan and native Homeworld Face Dance controls at the controlled Advanced source', () => {
+ const f = fixture({ advanced: true }); source(f);
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = viewGame(f.game, f.atreides); view.players.find(p => p.id === f.atreides)!.bot = profile;
+ const choices = botActions(view); assert.ok(choices.length);
+ for (const action of choices) {
+ const game = act(f.game, f.atreides, action);
+ assert.equal(game.decision?.kind, 'faceDance');
+ const tleilaxu = viewGame(game, f.tleilaxu); tleilaxu.players.find(p => p.id === f.tleilaxu)!.bot = profile;
+ const dance = botActions(tleilaxu); assert.ok(dance.length);
+ for (const choice of dance) completed(act(game, f.tleilaxu, choice), f);
+ }
+ }
+});
diff --git a/tests/fixture-caladan-face-dance-homeworld.ts b/tests/fixture-caladan-face-dance-homeworld.ts
new file mode 100644
index 00000000..16ec7cf7
--- /dev/null
+++ b/tests/fixture-caladan-face-dance-homeworld.ts
@@ -0,0 +1,190 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
+ viewGame, type Action, type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { positionVictoryArmy, positionVictoryInvader, victoryInventory } from './fixture-caladan-victory';
+
+export type CaladanFaceDanceHomeworldStep = { actor: string; action: Action };
+export const caladanFaceDanceHomeworldPlayer = (game: Game, actor = 'a') => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export function caladanFaceDanceHomeworldInventory(game: Game) {
+ victoryInventory(game);
+ return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
+ .map(card => card.id).sort();
+}
+export function caladanFaceDanceHomeworldTraitors(game: Game) {
+ const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [
+ ...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader),
+ ])];
+ assert.equal(new Set(ids).size, ids.length, 'Each original Traitor identity occupies one physical slot');
+ return ids.sort();
+}
+export function caladanFaceDanceHomeworldPhysical(game: Game) {
+ return {
+ players: game.players.map(player => ({ id: player.id, reserves: player.reserves, tanks: player.tanks,
+ forces: structuredClone(player.forces), spice: player.spice, shipped: player.shipped, moved: player.moved,
+ battleLosses: player.battleLosses })),
+ custody: structuredClone(game.homeworlds!.custody),
+ };
+}
+export function caladanFaceDanceHomeworldArmy(game: Game, actor: string, homeworld = 'homeworld:tleilaxu') {
+ const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
+ .find(home => home.id === homeworld); assert.ok(home);
+ return home.forces[actor]?.normal ?? 0;
+}
+export function actCaladanFaceDanceHomeworld(game: Game, actor: string, action: Action,
+ actions: CaladanFaceDanceHomeworldStep[] = []): Game {
+ const next = applyAction(game, actor, action); actions.push({ actor, action: structuredClone(action) }); return next;
+}
+/** Original preparation and aftermath only. Both optional choices remain owned. */
+export function nextCaladanFaceDanceHomeworld(game: Game): CaladanFaceDanceHomeworldStep | null {
+ if (game.response) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'passResponse' } };
+ }
+ if (game.phaseOpening) {
+ const actor = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); assert.ok(actor);
+ return { actor: actor.id, action: { type: 'ready' } };
+ }
+ if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
+ if (game.decision?.kind === 'fullPlanOffer')
+ return { actor: game.decision.player, action: { type: 'decision', decline: true } };
+ if (game.decision?.kind === 'battleCards')
+ return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
+ if (game.decision?.kind === 'battleLosses')
+ return { actor: game.decision.player, action: { type: 'decision', choice: 0 } };
+ const battle = game.battle;
+ if (battle?.preLeader && !battle.preLeader.closed) {
+ const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'battlePreparationReady', event: battle.event } };
+ }
+ if (battle?.preparation) return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
+ if (battle?.outcomeWindow && !battle.outcomeWindow.closed) {
+ const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'battleOutcomeReady', event: battle.event } };
+ }
+ if (battle?.revealed) {
+ const actor = viewGame(game, battle.attacker).battle!.traitorVoters
+ .find(id => battle.traitorCalls[id] === undefined);
+ if (actor) return { actor, action: { type: 'traitorCall', call: false } };
+ }
+ return null;
+}
+export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game) => boolean,
+ actions: CaladanFaceDanceHomeworldStep[] = []): Game {
+ for (let count = 0; count < 160; count++) {
+ if (until(game)) return game;
+ const next = nextCaladanFaceDanceHomeworld(game);
+ assert.ok(next, `Choose the original Caladan, Face Dance or plan control at ${game.phase}/${game.decision?.kind}`);
+ game = actCaladanFaceDanceHomeworld(game, next.actor, next.action, actions);
+ }
+ throw Error('The original Homeworld battle did not reach its bounded continuation');
+}
+/** Finish only this battle; retain the original Collection opening. */
+export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
+ return advanceCaladanFaceDanceHomeworld(game,
+ state => state.phase === 7 && !state.battle && !state.response && !state.decision, actions);
+}
+
+/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
+ * conserved invasion and matching Traitor exchange. Battle and both subsequent
+ * printed choices are actual actions. This is not a native invasion programme. */
+export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boolean } = {}) {
+ const advanced = options.advanced ?? false, atreides = 'a', tleilaxu = 't', observer = 'g';
+ const territory = 'homeworld:tleilaxu', destination = territory;
+ let game = createGame('CALADANFACEDANCEHOMEWORLD', newPlayer(atreides, 'Atreides', 'atreides'), advanced, ['ix']);
+ joinGame(game, newPlayer(observer, 'Guild', 'guild')); joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
+ const actions: CaladanFaceDanceHomeworldStep[] = [];
+ const act = (actor: string, action: Action) => { game = actCaladanFaceDanceHomeworld(game, actor, action, actions); };
+ act(atreides, { type: 'homeworlds', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); game = initializeHomeworldGameForAudit(game);
+ const setup = structuredClone(game);
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ let next: CaladanFaceDanceHomeworldStep | null = null;
+ for (const player of game.players) {
+ const view = viewGame(game, player.id); view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0]; if (action) { next = { actor: player.id, action }; break; }
+ }
+ assert.ok(next, 'Original Ix/Homeworld setup requires a legal native action'); act(next.actor, next.action);
+ }
+ assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
+ const originalInventory = caladanFaceDanceHomeworldInventory(game);
+ const originalTraitors = caladanFaceDanceHomeworldTraitors(game);
+ const originalLeaders = game.players.map(player => ({ player: player.id, ids: player.leaders.map(leader => leader.id).sort() }));
+ // The same labelled seam as fixture-caladan-victory: retain original cards,
+ // native discs/custody profile, but select a conserved later battle position.
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ Object.assign(game, { phase: 6, turn: 2, storm: 18, phaseOpening: null, response: null,
+ decision: null, active: atreides, ready: [], order: [atreides, observer, tleilaxu] });
+ positionVictoryArmy(game, atreides, 9, 0);
+ positionVictoryArmy(game, observer, 20, 0);
+ positionVictoryArmy(game, tleilaxu, 2, 0);
+ positionVictoryInvader(game, atreides, territory, 3);
+ const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
+ const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => b.strength - a.strength)[0];
+ const loserDisc = [...native.leaders].filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerDisc && loserDisc && winnerDisc.strength > loserDisc.strength);
+ const winnerLeader = winnerDisc.id, loserLeader = loserDisc.id;
+ let exchange: { from: string; returned: string; selected: string } | null = null;
+ if (!native.faceDancers!.some(card => card.leader === winnerLeader)) {
+ const dancer = native.faceDancers!.find(card => !card.revealed); assert.ok(dancer);
+ const returned = dancer.leader, index = game.traitorReserve!.indexOf(winnerLeader);
+ if (index >= 0) {
+ game.traitorReserve![index] = returned; exchange = { from: 'traitorReserve', returned, selected: winnerLeader };
+ } else {
+ const holder = game.players.find(player => player.traitors.includes(winnerLeader)); assert.ok(holder);
+ holder.traitors[holder.traitors.indexOf(winnerLeader)] = returned;
+ exchange = { from: holder.id, returned, selected: winnerLeader };
+ }
+ dancer.leader = winnerLeader;
+ }
+ const controlled = structuredClone(game);
+ assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
+ assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
+ const battleAction: Action = { type: 'chooseBattle', territory, target: tleilaxu };
+ act(atreides, battleAction); const beforePreparation = structuredClone(game);
+ game = advanceCaladanFaceDanceHomeworld(game, state => !!state.battle && !state.response && !state.decision &&
+ !state.phaseOpening && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
+ const beforePlans = structuredClone(game);
+ const planSteps: CaladanFaceDanceHomeworldStep[] = [
+ { actor: atreides, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
+ { actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
+ ];
+ for (const step of planSteps) act(step.actor, step.action);
+ const revealed = structuredClone(game);
+ game = advanceCaladanFaceDanceHomeworld(game, state => state.decision?.kind === 'caladanReinforcement' &&
+ !state.response && !state.phaseOpening, actions);
+ assert.equal(game.lastBattleContext!.winner, atreides); assert.equal(game.lastBattleContext!.result, 'normal');
+ assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).reserves, 6);
+ assert.equal(caladanFaceDanceHomeworldArmy(game, atreides), 3);
+ assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).tanks, 0);
+ assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 2);
+ assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
+ const event = game.homeworldVictoryReinforcement!.event;
+ const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
+ const reinforcementDecline: Action = { type: 'decision', event, decline: true };
+ const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 } };
+ const faceDanceDecline: Action = { type: 'decision', reveal: false };
+ return { game, source: structuredClone(game), advanced, atreides, winner: atreides, owner: atreides, tleilaxu,
+ observer, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
+ action: reinforcementAction, reinforcementAction, reinforcementDecline, faceDanceAction, faceDanceDecline,
+ battleAction, planSteps, lobby, setup, afterSetup, controlled, exchange, beforePreparation, beforePlans, revealed,
+ originalInventory, originalTraitors, originalLeaders, originalPendingFaceDance: structuredClone(game.pendingFaceDance),
+ originalPhysical: caladanFaceDanceHomeworldPhysical(game), originalContext: structuredClone(game.lastBattleContext),
+ actions, steps: actions, sourceActionCount: actions.length,
+ staging: [
+ 'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. This unchanged fixture-caladan-victory profile does not enable occupation-preview benefits or a new public start gate.',
+ 'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native20. The exact original custody quote moves A3 from Caladan9 to the visitor pool. No extra counter, card or disc is created.',
+ 'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors/discs stay unique; no replacement or reveal status is installed.',
+ 'Actual chooseBattle, preparation/response passes, zero-dial unarmed native-leader plans, outcome readiness and declined Traitor votes create a normal A victory, no A ordinary casualties and Tleilaxu2 Tanks. Stop at the original Caladan choice before native Face Dance. Optional transfer and reveal remain caller-owned.',
+ ],
+ };
+}
+export type CaladanFaceDanceHomeworldFixture = ReturnType;
diff --git a/tests/fixture-caladan-face-dance.ts b/tests/fixture-caladan-face-dance.ts
new file mode 100644
index 00000000..9706e849
--- /dev/null
+++ b/tests/fixture-caladan-face-dance.ts
@@ -0,0 +1,209 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { traitorDeck } from '../game/traitors';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
+import { actAdvisorFaceDanceFlip, advisorFaceDanceFlipClean, advisorFaceDanceFlipInventory,
+ advisorFaceDanceFlipTraitors, nextAdvisorFaceDanceFlip } from './fixture-advisor-face-dance-flip';
+
+export type CaladanFaceDanceStep = { actor: string; action: Action };
+export const actCaladanFaceDance = actAdvisorFaceDanceFlip;
+export const caladanFaceDanceClean = advisorFaceDanceFlipClean;
+export function caladanFaceDancePlayer(game: Game, id: string) {
+ const player = game.players.find(player => player.id === id);
+ assert.ok(player, `The original ${id} seat must remain present`);
+ return player;
+}
+export function caladanFaceDanceInventory(game: Game) {
+ return { treachery: advisorFaceDanceFlipInventory(game), traitors: advisorFaceDanceFlipTraitors(game) };
+}
+export function caladanFaceDancePopulation(game: Game) {
+ assert.ok(game.homeworlds?.custody);
+ const caladan = homeworldForceGroups(homeworldContext(game), game.homeworlds.custody)
+ .find(world => world.id === 'homeworld:atreides');
+ assert.ok(caladan);
+ const pool = caladan.forces.a;
+ assert.ok(pool);
+ return pool.normal + pool.elite;
+}
+export function caladanFaceDancePhysical(game: Game) {
+ return { population: caladanFaceDancePopulation(game), custody: structuredClone(game.homeworlds!.custody),
+ players: game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
+ tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
+ shipped: player.shipped, moved: player.moved })) };
+}
+export function nextCaladanFaceDanceStep(game: Game): CaladanFaceDanceStep | null {
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
+ if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
+ if (game.status === 'setup' && game.setupStage === 'traitors') {
+ const player = game.players.find(player => player.traitorChoices.length);
+ assert.ok(player);
+ const leader = player.traitorChoices.find(identity => identity !== 'atreides-0');
+ assert.ok(leader, 'A real alternative initial Traitor choice must preserve the original winning Face Dancer');
+ return { actor: player.id, action: { type: 'traitor', leader } };
+ }
+ }
+ return nextAdvisorFaceDanceFlip(game);
+}
+export function advanceCaladanFaceDance(game: Game, until: (state: Game) => boolean,
+ actions: CaladanFaceDanceStep[] = []): Game {
+ for (let count = 0; count < 1400; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The native Caladan/Face Dance source ended before its owned boundary');
+ const step = nextCaladanFaceDanceStep(game);
+ assert.ok(step, `Choose the real ${game.turn}/${game.phase}/${game.decision?.kind}/${game.battle?.preparation?.kind}/${game.battle?.preLeader?.closed} before continuing`);
+ game = actCaladanFaceDance(game, step.actor, step.action, actions);
+ }
+ throw Error('The native Caladan/Face Dance source did not reach its finite boundary');
+}
+
+/** Select only the first native Face Dancer shuffle inside the actual final
+ * Traitor choice. The winning identity is never installed in a played hand. */
+function originalFaceDancerDeal(game: Game, step: CaladanFaceDanceStep, actions: CaladanFaceDanceStep[]) {
+ assert.equal(game.setupStage, 'traitors');
+ assert.equal(step.action.type, 'traitor');
+ assert.equal(game.players.filter(player => player.traitorChoices.length > 0).length, 1);
+ assert.ok(game.players.every(player => !player.faceDancers));
+ const held = new Set([...game.players.flatMap(player => player.traitors), String(step.action.leader)]);
+ const original = traitorDeck(game.players, true).filter(identity => !held.has(identity));
+ assert.ok(original.includes('atreides-0'));
+ const first = ['atreides-0', ...original.filter(identity => identity !== 'atreides-0').slice(0, 2)];
+ const order = [...first, ...original.filter(identity => !first.includes(identity))];
+ 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);
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
+ let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) {
+ assert.ok(cursor < scalars.length, 'Only the actual initial Face Dancer permutation uses these scalar choices');
+ array[0] = scalars[cursor++];
+ } else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try {
+ const next = applyAction(game, step.actor, step.action);
+ assert.equal(cursor, scalars.length);
+ actions.push(structuredClone(step));
+ return { game: next, original, order, scalars, step: structuredClone(step) };
+ } finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+
+/** Public native A/T/G with original Ix deck and Homeworlds. Real Caladan4
+ * shipment and Guild1 invasion lead to the original ten-survivor victory.
+ * Only unopened deal/Spice positions and original scalar lotteries are chosen. */
+export function createCaladanFaceDanceFixture(options: { advanced?: boolean } = {}) {
+ const advanced = options.advanced ?? false;
+ let game = createGame('CALADANFACEDANCE', newPlayer('a', 'Atreides', 'atreides'), advanced, ['ix']);
+ joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
+ joinGame(game, newPlayer('g', 'Guild', 'guild'));
+ const actions: CaladanFaceDanceStep[] = [];
+ const act = (actor: string, action: Action) => { game = actCaladanFaceDance(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceCaladanFaceDance(game, until, actions); };
+ act(game.host, { type: 'homeworlds', enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const initial = structuredClone(game);
+ act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game), originalInventory = caladanFaceDanceInventory(game);
+ let faceDancerDeal: ReturnType | null = null;
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const step = nextCaladanFaceDanceStep(game);
+ assert.ok(step);
+ if (game.setupStage === 'traitors' && step.action.type === 'traitor' &&
+ game.players.filter(player => player.traitorChoices.length > 0).length === 1) {
+ faceDancerDeal = originalFaceDancerDeal(game, step, actions);
+ game = faceDancerDeal.game;
+ } else act(step.actor, step.action);
+ }
+ assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
+ const afterSetup = structuredClone(game);
+ assert.equal(caladanFaceDancePopulation(game), 10);
+ assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
+ assert.ok(caladanFaceDancePlayer(game, 't').faceDancers!.some(card => card.leader === 'atreides-0' && !card.revealed));
+ assert.deepEqual(caladanFaceDanceInventory(game), originalInventory);
+ const front: Game['spiceDeck'] = [];
+ for (let count = 0; count < (advanced ? 2 : 1); count++) {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
+ assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ game.spiceDeck.unshift(...front);
+ const shipments: { before: Game; step: CaladanFaceDanceStep; after: Game }[] = [];
+ const ship = (actor: string, territory: string, sector: number, amount: number) => {
+ reach(state => state.phase === 5 && state.active === actor && caladanFaceDanceClean(state));
+ const homeworldSources = nativeShipmentSources(viewGame(game, actor), amount, 0);
+ assert.ok(homeworldSources);
+ const before = structuredClone(game), step: CaladanFaceDanceStep = { actor,
+ action: { type: 'ship', territory, sector, amount, elite: 0, allyPayment: 0, homeworldSources } };
+ act(actor, step.action); reach(caladanFaceDanceClean);
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ ship('a', 'polar_sink', 0, 4);
+ assert.equal(caladanFaceDancePopulation(game), 6);
+ ship('g', 'arrakeen', 10, 1);
+ reach(state => state.phase === 6 && caladanFaceDanceClean(state));
+ assert.ok(game.active === 'a' || game.active === 'g');
+ const beforeBattle = structuredClone(game), chooser = game.active;
+ const choice: CaladanFaceDanceStep = { actor: chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
+ target: chooser === 'a' ? 'g' : 'a' } };
+ act(choice.actor, choice.action);
+ reach(state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
+ !state.battle.preparation && caladanFaceDanceClean(state));
+ const beforePlans = structuredClone(game), winningLeader = caladanFaceDancePlayer(game, 'a').leaders.find(leader => leader.id === 'atreides-0');
+ const losingLeader = [...caladanFaceDancePlayer(game, 'g').leaders]
+ .filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(winningLeader && losingLeader && winningLeader.strength > losingLeader.strength);
+ const plans: CaladanFaceDanceStep[] = [
+ { actor: 'a', action: { type: 'battlePlan', leader: winningLeader.id, dial: 0, support: 0 } },
+ { actor: 'g', action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
+ ];
+ for (const step of plans) act(step.actor, step.action);
+ const sealed = structuredClone(game);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'caladanReinforcement');
+ assert.equal(game.decision!.player, 'a');
+ assert.ok(game.pendingFaceDance);
+ assert.equal(game.pendingFaceDance.identity, winningLeader.id);
+ assert.equal(caladanFaceDancePopulation(game), 6);
+ assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
+ const offer = viewGame(game, 'a').caladanReinforcement;
+ assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The original victory must allow Caladan before its pending Face Dance');
+ const target = offer.destinations.find(destination => destination.sector === 10 && !destination.blocked);
+ assert.ok(target, 'The real surviving Arrakeen army must be a reinforcement destination');
+ const quoted = caladanReinforcementChoice(viewGame(game, 'a'), target.id);
+ assert.ok(quoted.action, quoted.blocked ?? undefined);
+ const frame = structuredClone(game.homeworldVictoryReinforcement!);
+ return { game, advanced, owner: 'a', holder: 'a', dancer: 't', opponent: 'g', leader: winningLeader.id,
+ territory: 'arrakeen', location: 'arrakeen:10', event: frame.event, destination: target.id, action: quoted.action,
+ initial, setup, afterSetup, faceDancerDeal, shipments, beforeBattle, choice, beforePlans, plans, sealed,
+ originalInventory, originalPhysical: caladanFaceDancePhysical(game), originalFrame: frame,
+ originalFaceDance: structuredClone(game.pendingFaceDance), actions, sourceActionCount: actions.length,
+ staging: ['Only original unopened scalar/deck positions are selected. Public original Homeworld setup, private Traitor choices and native Face Dancer deal, Caladan4→Polar, Guild1→Arrakeen, real unarmed plans, votes and outcome acknowledgements produce this source. No played hand, force, wallet, leader, clock or receipt is installed.'] };
+}
+export type CaladanFaceDanceFixture = ReturnType;
+export function reinforceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game = structuredClone(fixture.game), give = true,
+ actions: CaladanFaceDanceStep[] = []) {
+ const action: Action = give ? fixture.action : { type: 'decision', event: fixture.event, decline: true };
+ return actCaladanFaceDance(game, fixture.owner, action, actions);
+}
+export function faceDanceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game, reveal = true,
+ amount = caladanFaceDancePlayer(game, fixture.owner).forces[fixture.location] ?? 0,
+ actions: CaladanFaceDanceStep[] = []) {
+ assert.equal(game.decision?.kind, 'faceDance');
+ return actCaladanFaceDance(game, fixture.dancer, reveal
+ ? { type: 'decision', reveal: true, sources: { reserves: amount }, sector: 10 }
+ : { type: 'decision', reveal: false }, actions);
+}
+export function finishCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game,
+ actions: CaladanFaceDanceStep[] = []) {
+ return advanceCaladanFaceDance(game, state => !state.battle && !state.pendingFaceDance &&
+ state.homeworldVictoryReinforcement?.event === fixture.event &&
+ state.homeworldVictoryReinforcement.stage === 'complete' && caladanFaceDanceClean(state), actions);
+}
diff --git a/tests/homeworld-replacement-engine.test.ts b/tests/homeworld-replacement-engine.test.ts
index 4fa6a775..eaeb6108 100644
--- a/tests/homeworld-replacement-engine.test.ts
+++ b/tests/homeworld-replacement-engine.test.ts
@@ -387,9 +387,9 @@ function actualFaceDance() {
g = commit(reload(g), 'guild');
assert.equal(g.lastBattleContext!.winner, 'atreides');
// This fixture tests Face Dance after the winner explicitly declines its
- // new Caladan option; it does not resolve the pending placement-order ruling.
+ // Caladan option, keeping the original replacement-only baseline.
assert.equal(g.decision?.kind, 'caladanReinforcement');
- assert.match(viewGame(g, 'atreides').caladanReinforcement!.blocked!, /ordering ruling/);
+ assert.equal(viewGame(g, 'atreides').caladanReinforcement!.blocked, null);
const beforeReinforcement = structuredClone(g.players);
g = applyAction(reload(g), 'atreides', {
type: 'decision',
diff --git a/tests/homeworld-victory-reinforcement-engine.test.ts b/tests/homeworld-victory-reinforcement-engine.test.ts
index fb2ad8bf..fbb29127 100644
--- a/tests/homeworld-victory-reinforcement-engine.test.ts
+++ b/tests/homeworld-victory-reinforcement-engine.test.ts
@@ -14,6 +14,7 @@ import {
} from '../game/engine';
import { botActions } from '../game/bots';
import { caladanReinforcementActions } from '../game/caladan-reinforcement-options';
+import { homeworldVictoryOfferSignature } from '../game/homeworld-victory-return';
import {
caladanVictoryFixture,
prepareVictoryBattle,
@@ -411,7 +412,7 @@ void test('a waiting Caladan victory cannot lose its real optional Shield cleanu
stable(valid);
});
-void test('all profiles consume the real owned event and observers receive no destinations; Face Dance ordering stays blocked but declinable', () => {
+void test('all profiles consume the real owned event and the original reinforcement precedes Face Dance', () => {
const g = victory();
const observer = viewGame(g, 'g');
assert.deepEqual(observer.caladanReinforcement!.destinations, []);
@@ -428,25 +429,11 @@ void test('all profiles consume the real owned event and observers receive no de
}
let contested = victory(caladanVictoryFixture({ tleilaxu: true }));
const offer = viewGame(contested, 'a').caladanReinforcement!;
- assert.match(offer.blocked!, /Face Dancer.*ordering ruling/);
- reject(
- contested,
- 'a',
- {
- type: 'decision',
- event: offer.event,
- amount: 1,
- destination: 'hagga_basin:12',
- },
- /ordering ruling/,
- );
+ assert.equal(offer.blocked, null);
const before = structuredClone(p(contested, 'a'));
- contested = applyAction(reload(contested), 'a', {
- type: 'decision',
- event: offer.event,
- decline: true,
- });
- assert.deepEqual(p(contested, 'a'), before);
+ contested = applyAction(contested, 'a', reinforce(contested));
+ assert.equal(p(contested, 'a').reserves, before.reserves - 1);
+ assert.equal(p(contested, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
assert.equal(contested.homeworldVictoryReinforcement!.stage, 'complete');
if (contested.decision?.kind === 'faceDance')
contested = applyAction(contested, 't', {
@@ -455,3 +442,20 @@ void test('all profiles consume the real owned event and observers receive no de
});
stable(contested);
});
+
+void test('an existing signed ordering-only refusal can use the current legal Caladan destination', () => {
+ // Controlled historical metadata, not a restored-room or recovery proof.
+ let game = victory(caladanVictoryFixture({ tleilaxu: true }));
+ const frame = game.homeworldVictoryReinforcement!, offer = frame.offer!;
+ offer.blocked = 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.';
+ offer.signature = homeworldVictoryOfferSignature(frame, offer);
+ assert.equal(viewGame(game, 'a').caladanReinforcement!.blocked, null);
+ const before = structuredClone(p(game, 'a'));
+ game = applyAction(game, 'a', reinforce(game));
+ assert.equal(p(game, 'a').reserves, before.reserves - 1);
+ assert.equal(p(game, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
+ assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.equal(game.decision?.kind, 'faceDance');
+ game = applyAction(game, 't', { type: 'decision', reveal: false });
+ stable(game);
+});
diff --git a/tests/spiritual-advisor-victory.test.ts b/tests/spiritual-advisor-victory.test.ts
new file mode 100644
index 00000000..2e989672
--- /dev/null
+++ b/tests/spiritual-advisor-victory.test.ts
@@ -0,0 +1,161 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ SpiritualAdvisorError, validateSpiritualAdvisorSource,
+ type SpiritualAdvisorContext, type SpiritualAdvisorSource,
+} from '../game/spiritual-advisor';
+
+/** Controlled public parent/source context only. Native Caladan, consumed
+ * Ambassador token, exact physical shipment and BG resources remain the engine
+ * producer's duty; no played Game or arrival is manufactured by these quotes. */
+function context(): SpiritualAdvisorContext {
+ return {
+ advanced: true, status: 'playing', turn: 3, phase: 6,
+ players: [
+ { id: 'bg', faction: 'beneGesserit', forces: {} },
+ { id: 'h', faction: 'harkonnen', forces: { 'sietch_tabr:14': 2 } },
+ { id: 'a', faction: 'atreides', forces: { 'arrakeen:10': 3 } },
+ { id: 'ec', faction: 'ecaz', forces: {} },
+ ],
+ homeworldVictoryReinforcement: { event: 'original-caladan-victory', turn: 3, phase: 6, stage: 'arrival' },
+ pendingSpiritualAdvisor: {
+ event: 'original-spiritual-choice', turn: 3, phase: 6,
+ decision: { player: 'bg', destination: 'sietch_tabr:14', ambassadorEvent: 'original-guild-child' },
+ },
+ pendingAmbassador: {
+ event: 'original-guild-child', victoryEvent: 'original-caladan-victory', effect: 'guild',
+ turn: 3, phase: 6, stage: 'arrival',
+ shipmentReceipt: {
+ next: 'advisor',
+ order: { player: 'h', territory: 'sietch_tabr', sector: 14, amount: 2, elite: 0,
+ advisors: false, cost: 0, allyPayment: 0 },
+ },
+ },
+ };
+}
+function source(polar = false): SpiritualAdvisorSource {
+ return { event: 'original-spiritual-choice', owner: 'bg', turn: 3, phase: 6,
+ territory: polar ? 'polar_sink' : 'sietch_tabr', sector: polar ? 0 : 14, amount: 1 };
+}
+function rejected(ctx: SpiritualAdvisorContext, receipt: SpiritualAdvisorSource) {
+ const before = structuredClone({ ctx, receipt });
+ assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), error =>
+ error instanceof SpiritualAdvisorError && error.name === 'SpiritualAdvisorError');
+ assert.deepEqual({ ctx, receipt }, before);
+}
+
+void test('phase-six Spiritual accompaniment and Polar sources retain the original Caladan Guild chain with exactly seven fields', () => {
+ for (const polar of [false, true]) for (const amount of [1, 2]) {
+ const ctx = context(), receipt = { ...source(polar), amount }, 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('missing stale wrong-clock or completed Caladan parents reject accompanying and Polar packets equally', () => {
+ const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
+ ctx => { delete ctx.homeworldVictoryReinforcement; },
+ ctx => { ctx.homeworldVictoryReinforcement!.event = 'another-victory'; },
+ ctx => { ctx.homeworldVictoryReinforcement!.turn = 2; },
+ ctx => { Object.assign(ctx.homeworldVictoryReinforcement!, { phase: 5 }); },
+ ...(['waiting', 'choice', 'complete'] as const).map(stage =>
+ (ctx: SpiritualAdvisorContext) => { ctx.homeworldVictoryReinforcement!.stage = stage; }),
+ ];
+ for (const change of changes) for (const polar of [false, true]) {
+ const ctx = context(); change(ctx); rejected(ctx, source(polar));
+ }
+});
+
+void test('phase-six packets require the same owned Spiritual frame and original Guild child before choosing either target', () => {
+ const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
+ ctx => { delete ctx.pendingSpiritualAdvisor; },
+ ctx => { ctx.pendingSpiritualAdvisor!.event = 'another-choice'; },
+ ctx => { ctx.pendingSpiritualAdvisor!.turn = 2; },
+ ctx => { ctx.pendingSpiritualAdvisor!.phase = 5; },
+ ctx => { ctx.pendingSpiritualAdvisor!.decision.player = 'h'; },
+ ctx => { delete ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent; },
+ ctx => { ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent = 'another-child'; },
+ ctx => { ctx.pendingSpiritualAdvisor!.decision.revivalEvent = 'unrelated-revival'; },
+ ctx => { delete ctx.pendingAmbassador; },
+ ctx => { ctx.pendingAmbassador!.event = 'another-child'; },
+ ctx => { delete ctx.pendingAmbassador!.victoryEvent; },
+ ctx => { ctx.pendingAmbassador!.victoryEvent = 'another-victory'; },
+ ctx => { ctx.pendingAmbassador!.revivalEvent = 'unrelated-revival'; },
+ ctx => { ctx.pendingAmbassador!.turn = 2; },
+ ctx => { ctx.pendingAmbassador!.phase = 5; },
+ ctx => { ctx.pendingAmbassador!.stage = 'offer'; },
+ ctx => { ctx.pendingAmbassador!.effect = 'fremen'; },
+ ctx => { delete ctx.pendingAmbassador!.effect; },
+ ];
+ for (const change of changes) for (const polar of [false, true]) {
+ const ctx = context(); change(ctx); rejected(ctx, source(polar));
+ }
+});
+
+void test('the captured chooser destination must be canonical current board and equal the committed Guild receipt', () => {
+ const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
+ ctx => { delete ctx.pendingAmbassador!.shipmentReceipt; },
+ ctx => { ctx.pendingAmbassador!.shipmentReceipt!.next = 'finish'; },
+ ctx => { ctx.pendingAmbassador!.shipmentReceipt!.order.territory = 'carthag'; },
+ ctx => { ctx.pendingAmbassador!.shipmentReceipt!.order.sector = 0; },
+ ctx => { delete ctx.pendingSpiritualAdvisor!.decision.destination; },
+ ...['sietch_tabr:014', 'sietch_tabr:0', 'homeworld:atreides', 'missing:14', 'carthag:11'].map(destination =>
+ (ctx: SpiritualAdvisorContext) => { ctx.pendingSpiritualAdvisor!.decision.destination = destination; }),
+ ];
+ for (const change of changes) for (const polar of [false, true]) {
+ const ctx = context(); change(ctx); rejected(ctx, source(polar));
+ }
+ const ctx = context();
+ for (const receipt of [
+ { ...source(), territory: 'arrakeen', sector: 10 },
+ { ...source(), territory: 'carthag', sector: 11 },
+ { ...source(), sector: 0 },
+ { ...source(true), sector: 1 },
+ ]) rejected(ctx, receipt);
+});
+
+void test('phase-six source metadata keeps current native BG identity clock and strict legacy keys', () => {
+ const ctx = context();
+ for (const patch of [
+ { event: 'another-choice' }, { owner: 'h' }, { turn: 2 }, { phase: 5 },
+ { amount: 0 }, { amount: 3 }, { amount: 1.5 },
+ { revivalEvent: 'not-a-revival-source' }, { victoryEvent: 'not-a-source-field' }, { card: 'private-cost' },
+ ]) rejected(ctx, { ...source(), ...patch } as SpiritualAdvisorSource);
+ for (const change of [
+ (g: SpiritualAdvisorContext) => { g.status = 'setup'; },
+ (g: SpiritualAdvisorContext) => { g.phase = 5; },
+ (g: SpiritualAdvisorContext) => { g.turn = 4; },
+ (g: SpiritualAdvisorContext) => { g.players[0].faction = 'atreides'; },
+ ]) { const changed = context(); change(changed); rejected(changed, source()); }
+});
+
+void test('ordinary seven-field and revival eight-field sources retain their existing shapes and phase boundaries', () => {
+ for (const phase of [1, 3, 5, 8]) {
+ const ctx = context(); ctx.phase = phase;
+ for (const field of ['homeworldVictoryReinforcement', 'pendingAmbassador', 'pendingSpiritualAdvisor'])
+ Object.defineProperty(ctx, field, { get() { throw Error(`Ordinary source read ${field}`); } });
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, { ...source(true), phase }));
+ }
+ const ctx = context(); ctx.phase = 4;
+ delete ctx.pendingAmbassador; delete ctx.pendingSpiritualAdvisor;
+ ctx.homeworldRevivalReturn = { event: 'original-revival', turn: 3, phase: 4, stage: 'arrival', destination: 'arrakeen:10' };
+ Object.defineProperty(ctx, 'homeworldVictoryReinforcement', { get() { throw Error('Revival read a victory parent'); } });
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, {
+ ...source(true), phase: 4, revivalEvent: 'original-revival',
+ }));
+ for (const phase of [0, 2, 7]) {
+ const changed = context(); changed.phase = phase;
+ rejected(changed, { ...source(true), phase });
+ }
+});
+
+void test('victory source binding reads no wallets hands private source selection or future controls', () => {
+ const ctx = context();
+ for (const player of ctx.players) for (const field of ['hand', 'spice', 'leaders', 'reserves', 'traitors'])
+ Object.defineProperty(player, field, { get() { throw Error(`Victory Spiritual source read ${field}`); } });
+ for (const field of ['pendingShipment', 'stormCard', 'stormForecast', 'homeworlds', 'battle'])
+ Object.defineProperty(ctx, field, { get() { throw Error(`Victory Spiritual source read ${field}`); } });
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
+ assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source(true)));
+});