Allow Caladan reinforcement before Face Dance

This commit is contained in:
Quintine committed 2026-10-26 07:58:51 +11:00
1 parent 18bf5d4423
commit cab6b41c67
20 files changed
+1272 -118

No files matched your search

+49 -71
View File
@@ -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', () => {
+121
View File
@@ -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(`<button\\b[^>]*>${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, /<select\b[^>]*\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=/);
});
+159
View File
@@ -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);
}
}
});
+151
View File
@@ -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);
}
}
});
@@ -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<typeof createCaladanFaceDanceHomeworldFixture>;
+209
View File
@@ -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 = <V extends ArrayBufferView | null>(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<typeof originalFaceDancerDeal> | 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<typeof createCaladanFaceDanceFixture>;
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);
}
+2 -2
View File
@@ -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',
@@ -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);
});
+161
View File
@@ -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)));
});