Integrate Caladan victory placement and entry reactions

This commit is contained in:
Quintine committed 2026-10-27 14:39:27 +11:00
1 parent 97edc3eac8
commit 2e4aba9630
18 files changed
+789 -76

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game } from '../game/engine';
import { CaladanReinforcement } from '../components/caladan-reinforcement';
import { caladanReinforcementCanAct, caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import {
caladanVictoryFixture, prepareVictoryBattle, commitVictoryPlans, finishVictoryCalls,
victoryPlayer,
} from './fixture-caladan-victory';
// Existing controlled victory source: genuine Homeworld setup/deal, staged
// later phase/armies/wallets and storm position, then real battle packets yield
// the choice. This is not original-game progression or an HMS entry programme.
// SSR and shared-adapter checks do not operate the stateful React chooser.
const sources = new Map<boolean, Game>();
function choiceGame(advanced = true): Game {
let source = sources.get(advanced);
if (!source) {
const initial = caladanVictoryFixture({ advanced });
initial.storm = 13;
source = finishVictoryCalls(commitVictoryPlans(prepareVictoryBattle(initial)));
assert.equal(source.decision?.kind, 'caladanReinforcement');
assert.equal(source.lastBattleContext?.winner, 'a');
sources.set(advanced, source);
}
return structuredClone(source);
}
function html(game: Game, owner = 'a', busy = false) {
return renderToStaticMarkup(createElement(CaladanReinforcement, { game: viewGame(game, owner), busy,
act() { assert.fail('Rendering the chooser must not submit a decision.'); } }));
}
void test('the engine-owned Caladan choice keeps its default target and original exact one-force adapter packet', () => {
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
assert.ok(offer);
const target = offer.destinations.find(destination => !destination.blocked); assert.ok(target);
assert.ok(target.id.startsWith('hagga_basin:'));
const markup = html(game);
assert.ok(markup.includes(`<option value="${target.id}" selected="">`));
const choice = caladanReinforcementChoice(view, target.id);
assert.deepEqual(choice, { blocked: null, action: { type: 'decision', event: offer.event,
destination: target.id, amount: 1 } });
assert.ok(choice.action);
const before = victoryPlayer(game, 'a');
const after = applyAction(game, 'a', choice.action), placed = victoryPlayer(after, 'a');
assert.equal(placed.reserves, before.reserves - 1);
assert.equal(placed.forces[target.id], (before.forces[target.id] ?? 0) + 1);
});
void test('arrival guidance distinguishes placement from shipment and preserves the original Face Dance explanation', () => {
const game = choiceGame(), before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const markup = html(game);
assert.match(markup, /original surviving winner at the original battle location/);
assert.match(markup, /including inside the Hidden Mobile Stronghold/);
assert.match(markup, /direct-shipment ban does not block this placement/);
assert.match(markup, /No spice, shipment, movement or revival allowance is spent/);
assert.match(markup, /no Spiritual Advisor opportunity is created/);
assert.match(markup, /Storm-sector entry remains unavailable/);
assert.match(markup, /Advanced Bene Gesserit Intrusion resolves first/);
assert.match(markup, /Provisional arrival classification: eligible Ambassador and Terror reactions then follow their original arrival order/);
assert.match(markup, /aria-label="Provisional Face Dance order"/);
assert.match(markup, /joins the original victorious army before any later Face Dance/);
assert.match(markup, /does not create another victory or grant another Caladan reinforcement/);
}
assert.deepEqual(game, before);
});
void test('actual storm and foreign targets stay unavailable and another viewer receives no owned chooser', () => {
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
assert.ok(offer);
const storm = offer.destinations.find(destination => destination.id === 'hagga_basin:13'); assert.ok(storm);
assert.match(storm.blocked ?? '', /storm/i);
assert.equal(caladanReinforcementChoice(view, storm.id).action, null);
assert.equal(caladanReinforcementChoice(view, 'carthag:11').action, null);
assert.match(html(game), /<option[^>]*value="hagga_basin:13"[^>]*disabled=""/);
const rival = viewGame(game, 'g');
assert.equal(caladanReinforcementCanAct(rival), false);
assert.equal(caladanReinforcementChoice(rival, 'hagga_basin:12').action, null);
assert.equal(html(game, 'g'), '');
});
void test('busy disables the existing chooser while Basic keeps the placement guidance without an Advanced Intrusion claim', () => {
const game = choiceGame(), busy = html(game, 'a', true);
assert.match(busy, /<select[^>]*disabled=""/);
assert.equal((busy.match(/<button[^>]*disabled=""/g) ?? []).length, 2);
assert.match(busy, /Provisional arrival classification/);
const basic = choiceGame(false), markup = html(basic);
assert.match(markup, /no Spiritual Advisor opportunity is created/);
assert.match(markup, /Provisional arrival classification/);
assert.doesNotMatch(markup, /Advanced Bene Gesserit Intrusion resolves first/);
const view = viewGame(basic, 'a');
assert.ok(caladanReinforcementChoice(view, 'hagga_basin:12').action);
});
+215
View File
@@ -0,0 +1,215 @@
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 { fighterCount } from '../game/advisors';
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
import { TERROR_KINDS } from '../game/moritani-terror';
import {
actCaladanArrival as act, advanceCaladanArrival as advance,
allowCaladanArrivalResponses as allow, caladanArrivalInventory as inventory,
caladanArrivalPlayer as p, caladanArrivalReload as reload,
createCaladanArrivalFixture as fixture,
} from './fixture-caladan-arrival';
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function completed(g: Game, event: string) {
assert.equal(g.homeworldVictoryReinforcement!.event, event);
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(p(g,'a').reserves, 5);
inventory(g);
}
void test('controlled Caladan battle sources record actual plans, calls and the original free addition', () => {
const f = fixture({bg:true});
assert.match(f.disclosure, /Controlled.*not public acquisition/);
assert.equal(f.sourceCount, f.actions.length);
assert.equal(f.actions.filter(s => s.action.type === 'chooseBattle').length,1);
assert.equal(f.actions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(f.actions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(f.source.homeworldVictoryReinforcement,undefined);
const before = structuredClone(p(f.game,'a'));
const g = act(reload(f.game),'a',f.action);
assert.equal(p(g,'a').reserves,before.reserves-1);
assert.equal(p(g,'a').forces[f.destination],before.forces[f.destination]+1);
for (const field of ['spice','shipped','moved','battleLosses'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.equal(g.homeworldRevivalReturn,undefined);
inventory(g);
});
for (const flip of [false,true])
void test(`Caladan Advanced Intrusion ${flip ? 'flip' : 'keep'} waits for the original arrival`, () => {
const f = fixture({bg:true});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.decision?.kind,'intrusion');
assert.equal(g.decision?.player,'bg');
assert.equal(g.decision?.kind === 'intrusion' && g.decision.victoryEvent,f.event);
assert.equal(fighterCount(p(g,'bg'),f.territory),1);
reject(g,'a',f.action);
g = act(g,'bg',{type:'decision',accept:flip});
if (flip) {
assert.equal(g.response?.kind,'advisorFlip');
assert.equal(g.response?.advisorVictoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = allow(g);
}
g = advance(g);
assert.equal(fighterCount(p(g,'bg'),f.territory),flip ? 0 : 1);
assert.equal(p(g,'bg').forces[f.destination],1);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan Terror decline leaves the original hidden token and completes once', () => {
const f = fixture({terror:'robbery'});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingTerrorEntry?.cause,'caladanReinforcement');
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.pendingTerrorEntry?.entrant,'a');
assert.equal(g.pendingTerrorEntry?.amount,1);
assert.equal(g.pendingTerrorEntry?.elite,0);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
reject(g,'a',{type:'decision',decline:true});
g = act(g,'m',{type:'decision',decline:true});
g = advance(g);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'placed');
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});
for (const kind of TERROR_KINDS)
void test(`Caladan original Terror ${kind} resolves before its reinforcement completes`, () => {
const f = fixture({terror:kind});
let g = act(reload(f.game),'a',f.action);
const before = reload(g), entry = g.pendingTerrorEntry!;
assert.equal(entry.stage,'offer');
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',reveal:true});
if (kind === 'robbery') {
assert.equal(g.pendingTerrorEntry?.stage,'robbery');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',choice:'spice'});
const stolen = Math.ceil(p(before,'a').spice/2);
assert.equal(p(g,'a').spice,p(before,'a').spice-stolen);
assert.equal(p(g,'m').spice,p(before,'m').spice+stolen);
} else if (kind === 'sneakAttack') {
assert.equal(g.pendingTerrorEntry?.stage,'sneakAttack');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',amount:1});
assert.equal(p(g,'m').reserves,p(before,'m').reserves-1);
assert.equal(p(g,'m').forces[f.destination],1);
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else if (kind === 'sabotage') {
g = advance(g);
assert.equal(g.pendingTerrorEntry?.stage,'gift');
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
assert.ok(g.discard.some(c => c.id === f.heldCard));
assert.equal(p(g,'a').hand.length,0);
g = act(g,'m',{type:'decision',decline:true});
} else if (kind === 'assassination') {
const deadBefore = p(before,'a').leaders.filter(l => l.dead).length;
assert.equal(p(g,'a').leaders.filter(l => l.dead).length,deadBefore+1);
const victim = p(g,'a').leaders.find(l => l.dead && !p(before,'a').leaders.find(old => old.id === l.id)!.dead)!;
assert.equal(p(g,'m').spice,p(before,'m').spice+victim.strength);
} else if (kind === 'extortion') {
assert.ok(g.moritaniExtortion);
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'extortion');
assert.equal(p(g,'m').spice,p(before,'m').spice);
} else {
assert.equal(p(g,'a').forces[f.destination] ?? 0,0);
assert.equal(p(g,'a').tanks,p(before,'a').tanks+3);
}
g = advance(g);
assert.equal(g.pendingTerrorEntry,null);
if (kind !== 'extortion')
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
completed(g,f.event);
reject(g,'a',f.action);
});
void test('Caladan adds directly to a surviving army aboard the actual Ix HMS without shipping', () => {
const f = fixture({hms:true});
assert.ok(f.source.mobileStronghold?.location);
assert.equal(f.territory,'hidden_mobile_stronghold');
const choice = caladanReinforcementChoice(viewGame(f.game,'a'),f.destination);
assert.equal(choice.blocked,null);
assert.deepEqual(choice.action,f.action);
const before = structuredClone(p(f.game,'a'));
const g = advance(act(reload(f.game),'a',choice.action!));
assert.equal(p(g,'a').forces[f.destination],3);
for (const field of ['spice','shipped','moved'] as const)
assert.deepEqual(p(g,'a')[field],before[field]);
assert.deepEqual(g.mobileStronghold,f.game.mobileStronghold);
completed(g,f.event);
});
void test('Caladan original event and owner reject stale, foreign and oversized entry before cost', () => {
const f = fixture({bg:true});
reject(f.game,'g',f.action);
reject(f.game,'a',{...f.action,event:`${f.event}:stale`});
reject(f.game,'a',{...f.action,amount:2});
reject(f.game,'a',{...f.action,destination:'arrakeen:10'});
assert.equal(p(f.game,'a').reserves,6);
assert.equal(p(f.game,'a').forces[f.destination],2);
inventory(f.game);
});
void test('all four minimal profiles use the actual Caladan and Intrusion controls legally', () => {
const f = fixture({bg:true});
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
let g = reload(f.game);
const own = viewGame(g,'a');
own.players.find(seat => seat.id === 'a')!.bot = profile;
const action = botActions(own).find(a => a.type === 'decision' && a.event === f.event);
assert.ok(action,`${profile} needs a current original reinforcement action.`);
g = act(g,'a',action);
if (g.decision?.kind === 'intrusion') {
const view = viewGame(g,'bg');
view.players.find(seat => seat.id === 'bg')!.bot = profile;
const reaction = botActions(view)[0];
assert.ok(reaction,`${profile} needs a native Intrusion choice.`);
g = allow(act(g,'bg',reaction));
}
g = advance(g);
assert.equal(g.homeworldVictoryReinforcement!.stage,'complete');
assert.equal(p(g,'a').reserves,action.decline ? 6 : 5);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed,true);
inventory(g);
}
});
for (const first of ['first','second'] as const)
void test(`Caladan Ambassador ${first} in storm order retains its original victory through Terror`, () => {
const f = fixture({terror:'robbery',ambassador:first});
let g = act(reload(f.game),'a',f.action);
assert.equal(g.pendingArrivalOverlap?.first,first === 'first' ? 'ambassador' : 'terror');
assert.equal(g.pendingArrivalOverlap?.victoryEvent,f.event);
const declineAmbassador = () => {
const entry = g.pendingAmbassador; assert.ok(entry);
assert.equal(entry.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'ec',{type:'decision',event:entry.event,decline:true});
};
const declineTerror = () => {
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
g = act(g,'m',{type:'decision',decline:true});
};
if (first === 'first') { declineAmbassador(); declineTerror(); }
else { declineTerror(); declineAmbassador(); }
g = advance(g);
assert.equal(g.pendingArrivalOverlap,null);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors!.length,1);
assert.equal(g.homeworldVictoryReinforcement!.ambassadors![0].completed,true);
assert.equal(p(g,'a').forces[f.destination],3);
completed(g,f.event);
});
+11 -9
View File
@@ -29,8 +29,9 @@ function source(f: Fixture) {
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(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 0);
assert.equal(player(f.game, f.defender).tanks, 2);
assert.equal(player(f.game, f.tleilaxu).reserves, 2); assert.equal(army(f.game, f.tleilaxu), 2);
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);
@@ -56,7 +57,8 @@ function give(f: Fixture, actions: Step[] = []) {
}
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.phase, army(done, f.atreides) > 0 ? 6 : 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);
@@ -82,8 +84,8 @@ for (const advanced of [false, true]) {
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);
assert.equal(player(game, f.tleilaxu).reserves, 6); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).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);
@@ -91,7 +93,7 @@ for (const advanced of [false, true]) {
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);
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 6);
});
void test(`${band} controlled native Homeworld victory keeps its fourth visitor when Tleilaxu declines Face Dance`, () => {
@@ -101,7 +103,7 @@ for (const advanced of [false, true]) {
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.tleilaxu).reserves, 2); 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);
});
@@ -115,8 +117,8 @@ for (const advanced of [false, true]) {
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);
assert.equal(player(game, f.tleilaxu).reserves, 5); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).tanks, 2);
});
void test(`${band} controlled Homeworld reinforcement and subsequent Face Dance reject stale, foreign, quantity and live-source over-cap actions`, () => {
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeHomeworldGameForAudit, joinGame,
newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { location, territory as boardTerritory } from '../game/board';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
import {
caladanVictoryFixture, holdVictoryCard, positionVictoryArmy,
victoryPlayer, victoryReload,
} from './fixture-caladan-victory';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export const caladanArrivalPlayer = victoryPlayer;
export const caladanArrivalReload = victoryReload;
export type CaladanArrivalStep = { actor: string; action: Action };
/** Controlled, conserved battle source. The inherited Homeworld preview
* initializer is not a public start journey. Wallets, later turn/phase and
* positions, selected held original cards, and the placed original Terror token are
* scenario inputs. Only the recorded battle and arrival actions are native
* chronology; no victory, entry, reaction or reinforcement receipt is installed. */
export const caladanArrivalDisclosure =
'Controlled Homeworld preview setup and later battle positions/purses/cards/token placement; recorded native battle and arrival actions, not public acquisition history.';
export function actCaladanArrival(
game: Game, actor: string, action: Action, actions: CaladanArrivalStep[] = [],
): Game {
const result = normalizeAutomaticGame(applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) });
return result;
}
/** Resolve only actual neutral native controls, preserving the decisions under
* test. It never seals a plan, chooses an entry reaction or advances a new phase. */
export function advanceCaladanArrival(
game: Game, actions: CaladanArrivalStep[] = [],
): Game {
let g = game;
for (let n = 0; n < 100; n++) {
if (g.decision && ['caladanReinforcement', 'intrusion', 'moritaniTerror',
'ambassador', 'arrivalOverlap'].includes(g.decision.kind)) return g;
if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
const next = nextStrongholdFactionsNativeStep(g);
if (!next) return g;
g = actCaladanArrival(g, next.actor, next.action, actions);
}
assert.fail('The bounded native Caladan continuation did not stop.');
}
export function allowCaladanArrivalResponses(
game: Game, actions: CaladanArrivalStep[] = [],
): Game {
let g = game;
for (let n = 0; g.response && n < 40; n++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(actor);
g = actCaladanArrival(g, actor.id, { type: 'passResponse' }, actions);
}
assert.equal(g.response, null);
return advanceCaladanArrival(g, actions);
}
export function caladanArrivalInventory(g: Game) {
homeworldGameIntegrity(g);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
assert.equal(cards.length, g.expansions.includes('ix') ? 47 : 33);
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
for (const p of g.players) {
const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
(sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0);
const stars = p.elites ? p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0) : 0;
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) + visitors, 20);
assert.ok(stars >= 0 && p.spice >= 0);
}
return { cards: cards.length, leaders: g.players.flatMap(p => p.leaders).length };
}
function hmsBattleSource(): Game {
let g = createGame('CALADANHMSARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; (g.status === 'setup' || !g.mobileStronghold?.location) && n < 140; n++) {
const next = nextStrongholdFactionsNativeStep(g);
if (next) g = applyAction(g, next.actor, next.action);
else {
const choice = g.players.map(p => ({actor:p.id, action:botActions(viewGame(g,p.id))[0]})).find(c => c.action);
assert.ok(choice);
g = applyAction(g, choice.actor, choice.action);
}
}
assert.equal(g.status, 'playing');
assert.ok(g.mobileStronghold?.location, 'The original Ix setup must place its actual HMS.');
// Conserved later scenario positions, not a claimed native movement journey.
const ix = victoryPlayer(g, 'i');
ix.reserves += Object.values(ix.forces).reduce((sum, count) => sum + count, 0);
ix.forces = {};
ix.elites!.reserves += Object.values(ix.elites!.forces).reduce((sum, count) => sum + count, 0);
ix.elites!.forces = {};
for (const p of g.players) { g.deck.push(...p.hand.splice(0)); p.spice = 20; }
Object.assign(g, { phase:6, turn:2, storm:18, phaseOpening:null, response:null,
decision:null, active:'a', ready:[], order:g.players.map(p => p.id) });
positionVictoryArmy(g, 'a', 6, 3);
positionVictoryArmy(g, 'g', 17, 3);
for (const id of ['a', 'g']) {
const p = victoryPlayer(g, id);
delete p.forces['hagga_basin:12'];
p.forces['hidden_mobile_stronghold:0'] = 3;
}
caladanArrivalInventory(g);
return g;
}
export function createCaladanArrivalFixture(options: {
bg?: boolean; terror?: TerrorKind; hms?: boolean; ambassador?: 'first' | 'second';
} = {}) {
assert.ok(!options.hms || (!options.bg && !options.terror));
let g = options.hms ? hmsBattleSource() : caladanVictoryFixture({
advanced:true,
extraSeats: options.bg ? [{id:'bg',faction:'beneGesserit'}] :
options.terror ? [{id:'m',faction:'moritani'}, ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : [])] : [],
});
const territory = options.hms ? 'hidden_mobile_stronghold' : options.terror ? 'arrakeen' : 'hagga_basin';
const destination = `${territory}:${options.hms ? 0 : options.terror ? 10 : 12}`;
let token: string | undefined;
let heldCard: string | undefined;
if (options.bg) {
const bg = victoryPlayer(g, 'bg');
Object.assign(bg, {reserves:19,forces:{'hagga_basin:12':1},advisors:{}});
holdVictoryCard(g,'g','karama'); // Keep an actual preventable flip response open.
}
if (options.terror) {
// Ecaz/Moritani native roster preview; no extra Treachery module is added.
g.expansions.push('ecaz');
for (const id of ['a','g']) {
const p = victoryPlayer(g,id);
delete p.forces['hagga_basin:12'];
p.forces['arrakeen:10'] = 3;
}
const original = g.moritaniTerror!.tokens.find(t => t.kind === options.terror)!;
token = original.id;
g.moritaniTerror = placeTerror(g.moritaniTerror!, token, territory, 1);
if (options.terror === 'sabotage') {
heldCard = holdVictoryCard(g,'a','shield');
holdVictoryCard(g,'m','karama'); // An actual held card supplies the optional gift.
}
}
if (options.ambassador) {
assert.ok(options.terror);
// Controlled original-token supply and storm order; neither reaction has
// been accepted and no later arrival/continuation receipt is installed.
g.ecazAmbassadors = createAmbassadors(() => 0);
const ambassador = g.ecazAmbassadors.tokens.find(token => token.zone === 'supply')!;
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, ambassador.id, {turn:1,availableSpice:20,
destination:{id:territory,stronghold:true,inStorm:false,allowed:true}}).state;
g.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
}
// A separately staged one-counter contest retains Battle phase after this
// victory, so unrelated automatic Collection income is not part of entry proof.
const other = location('shield_wall', boardTerritory('shield_wall').sectors[0]);
const atreides = victoryPlayer(g,'a'), guildArmy = victoryPlayer(g,'g');
assert.ok(atreides.forces['polar_sink:0'] > 0 && guildArmy.reserves > 0);
atreides.forces['polar_sink:0']--; atreides.forces[other] = 1;
guildArmy.reserves--; guildArmy.forces[other] = 1;
caladanArrivalInventory(g);
const source = victoryReload(g), actions: CaladanArrivalStep[] = [];
g = actCaladanArrival(g,'a',{type:'chooseBattle',territory,target:'g'},actions);
g = advanceCaladanArrival(g,actions);
assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
const a = victoryPlayer(g,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
const guild = victoryPlayer(g,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
g = actCaladanArrival(g,'a',{type:'battlePlan',leader:a.id,dial:1,support:1},actions);
g = actCaladanArrival(g,'g',{type:'battlePlan',leader:guild.id,dial:0,support:0},actions);
g = advanceCaladanArrival(g,actions);
assert.equal(g.decision?.kind,'caladanReinforcement');
assert.equal(g.lastBattleContext?.winner,'a');
assert.equal(victoryPlayer(g,'a').forces[destination],2);
const event = g.homeworldVictoryReinforcement!.event;
const offer = viewGame(g,'a').caladanReinforcement!;
caladanArrivalInventory(g);
return {game:g,source,actions,sourceActions:actions,sourceCount:actions.length,
disclosure:caladanArrivalDisclosure,territory,destination,event,token,heldCard,offer,
action:{type:'decision',event,destination,amount:1} as Action,
decline:{type:'decision',event,decline:true} as Action};
}
+19 -10
View File
@@ -85,10 +85,14 @@ export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game
}
throw Error('The original Homeworld battle did not reach its bounded continuation');
}
/** Finish only this battle; retain the original Collection opening. */
/** Finish this original aftermath only. A declined Face Dance can leave a
* further mandatory battle against the noncombatant native Tleilaxu army. */
export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
const event = game.lastBattleContext?.event; assert.ok(event);
return advanceCaladanFaceDanceHomeworld(game,
state => state.phase === 7 && !state.battle && !state.response && !state.decision, actions);
state => state.lastBattleContext?.event === event && !state.pendingFaceDance &&
state.lastBattleContext.caladanReinforcement?.completed === true &&
!state.battle && !state.response && !state.decision && !state.phaseOpening, actions);
}
/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
@@ -126,10 +130,13 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
positionVictoryArmy(game, observer, 20, 0);
positionVictoryArmy(game, tleilaxu, 2, 0);
positionVictoryInvader(game, atreides, territory, 3);
positionVictoryInvader(game, observer, territory, 2);
const defender = observer;
const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
const opposing = caladanFaceDanceHomeworldPlayer(game, defender);
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)
const loserDisc = [...opposing.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;
@@ -148,14 +155,14 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
const controlled = structuredClone(game);
assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
const battleAction: Action = { type: 'chooseBattle', territory, target: tleilaxu };
const battleAction: Action = { type: 'chooseBattle', territory, target: defender };
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 } },
{ actor: defender, 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);
@@ -165,7 +172,9 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
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.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 0);
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).reserves, 2);
assert.equal(caladanFaceDanceHomeworldPlayer(game, defender).tanks, 2);
assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
const event = game.homeworldVictoryReinforcement!.event;
const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
@@ -173,17 +182,17 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
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,
observer, defender, 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.',
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. Original occupation state/preview flags remain unchanged; no occupied benefit or native-only Face Dance restriction is disabled.',
'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 native18 and two visitors on Tleilax. Exact original custody quotes move A3 from Caladan9 and Guild2 from Junction20 into their visitor pools. Tleilaxu stays a noncombatant native, so A never solely occupies Tleilax. 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.',
'Actual A-versus-Guild chooseBattle, preparation/response passes, zero-dial unarmed faction-leader plans and outcome readiness create a normal A victory, no A ordinary casualties and Guild2 Tanks; two visiting combatants have no Homeworld Traitor voters. Native Tleilaxu2 remains, permitting its native Face Dance without occupied-Tleilax immunity. Stop at the original Caladan choice. Declining Face Dance leaves the later A–T battle unresolved rather than claiming Collection.',
],
};
}
@@ -116,7 +116,7 @@ void test('victory on native Caladan is a physical no-op, never a newly created
assert.deepEqual(g, before);
});
void test('permission is territorial; sector choices stay at the original battle and storm/HMS remain explicitly blocked', () => {
void test('permission is territorial; storm stays unavailable while the separate addition can reach a won HMS', () => {
const g = fixture();
const sectors = territory('imperial_basin').sectors;
g.players[0].forces = { [location('imperial_basin', sectors[0])]: 1 };
@@ -130,19 +130,17 @@ void test('permission is territorial; sector choices stay at the original battle
HomeworldVictoryReinforcementContext['mobileStronghold']
>;
g.players[0].forces = { [location(MOBILE_STRONGHOLD, 0)]: 1 };
assert.match(
quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked!,
/Hidden Mobile Stronghold/,
);
assert.equal(quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked, null);
assert.equal(transfer(g, victory(MOBILE_STRONGHOLD), `${MOBILE_STRONGHOLD}:0`).transfer.players[0].reserves, 5);
});
void test('actual entry boundaries retain occupancy, BG Intrusion and public low-Grumman count distinctions', () => {
void test('arrival reactions do not prohibit the original addition; ordinary allied occupancy still does', () => {
const g = fixture();
const bg = newPlayer('b', 'BG', 'beneGesserit');
bg.forces = { 'arrakeen:10': 1 };
bg.reserves = 19;
g.players.push(bg);
assert.match(quote(g, victory()).destinations[0].blocked!, /Intrusion/);
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players.pop();
const moritani = newPlayer('m', 'Moritani', 'moritani');
moritani.reserves = 8;
@@ -151,7 +149,7 @@ void test('actual entry boundaries retain occupancy, BG Intrusion and public low
const token = state.tokens[0];
state = placeTerror(state, token.id, 'arrakeen', 1);
g.moritaniTerror = state;
assert.match(quote(g, victory()).destinations[0].blocked!, /Terror/);
assert.equal(quote(g, victory()).destinations[0].blocked, null);
moritani.reserves = 7;
assert.equal(quote(g, victory()).destinations[0].blocked, null);
g.players[0].ally = 'h';