Allow Caladan reinforcement before Face Dance
This commit is contained in:
1 parent
18bf5d4423
commit
cab6b41c67
20 files changed
+1272
-118
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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=/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
Reference in new issue
Block a user