Compose classic World cards with phase modules

This commit is contained in:
Quintine committed 2026-10-16 16:19:35 +11:00
1 parent ccdcfe2c4d
commit f5b7ebc8da
18 files changed
+845 -35

No files matched your search

+4 -4
View File
@@ -117,8 +117,8 @@ function initialize(game: Game): Game {
}
}
/** Homeworlds selected before the independent 33+3 physical setup. No expansion
* roster, other optional module, preselected variant flag or synthetic phase. */
/** Homeworlds selected before the independent 33+3 physical setup. Original
* Tech/Advanced Strongholds may be configured in a fresh classic initial lobby. */
export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {}): HarassHomeworldSetup {
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides'];
let game = options.initial ? structuredClone(options.initial)
@@ -130,8 +130,8 @@ export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {})
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, []);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'guild']);
assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.discoveries && !game.greatMaker);
assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills &&
!game.discoveryEnabled && !game.discoveries && !game.greatMaker);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
assert.deepEqual(game.homeworlds, { custody: null });
const nativeSteps: HarassHomeworldStep[] = [];
@@ -17,6 +17,7 @@ export type IndependentCardModulesOptions = {
strongholds?: boolean;
/** False keeps the original native deck for revealed-card comparisons. */
independent?: boolean;
homeworlds?: boolean;
};
/** Select original modules and independent cards before the undealt native setup.
@@ -28,6 +29,7 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
let game = createGame('INDEPENDENTCARDMODULES', newPlayer(factions[0], factions[0], factions[0]),
advanced, options.expansions ?? []);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
if (options.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (options.tech ?? true) game.techTokens = createTechTokens();
if (options.strongholds) game.strongholdCards = createStrongholdCards();
const steps: IndependentCardModulesStep[] = [];
+232
View File
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import {
actHarassHomeworld, assertHarassHomeworldCustody, createHarassHomeworldSetup,
harassHomeworldClean, harassHomeworldPlayer, nextHarassHomeworldStep,
type HarassHomeworldStep,
} from './fixture-harass-homeworld';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
createReinforcementsHomeworldFixture, type ReinforcementsHomeworldFixture,
} from './fixture-reinforcements-homeworld';
export type WorldPhaseBattleStep = HarassHomeworldStep;
export type WorldPhaseBattleOptions = {
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
support?: number;
};
export type WorldPhaseBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: WorldPhaseBattleStep;
afterFirstMentat: Game;
shipments: { before: Game; step: WorldPhaseBattleStep; after: Game }[];
beforeBattle: Game;
battleStep: WorldPhaseBattleStep;
game: Game;
owner: string;
opponent: string;
observer: string;
location: string;
plans: [WorldPhaseBattleStep, WorldPhaseBattleStep];
sources: NativeReserveSelections;
returns: Record<string, { normal: number; elite: number }>;
actions: WorldPhaseBattleStep[];
staging: string[];
};
export const worldPhaseBattlePlayer = harassHomeworldPlayer;
export const worldPhaseBattleClean = harassHomeworldClean;
export const assertWorldPhaseBattleInventory = assertHarassHomeworldCustody;
/** Only the actual first Storm's native lottery/dials use fixed entropy. Later
* Storms keep their original native draws and movement semantics. */
function firstStormAction(game: Game, step: WorldPhaseBattleStep): Game {
if (game.turn !== 1 || game.phase !== 0) return applyAction(game, step.actor, step.action);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
};
try { return applyAction(game, step.actor, step.action); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actWorldPhaseBattle(game: Game, step: WorldPhaseBattleStep,
actions?: WorldPhaseBattleStep[]): Game {
actions?.push(structuredClone(step)); return firstStormAction(game, step);
}
export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => boolean,
actions?: WorldPhaseBattleStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextHarassHomeworldStep(game);
assert.ok(step, 'Supply the genuine human plan or physical return allocation');
game = actWorldPhaseBattle(game, step, actions);
}
throw Error('Original World/phase-module programme did not reach its requested boundary');
}
/** Atreides retains its original ten Arrakeen counters through the actual first
* END Mentat. Turn two: Guild pays for four native invaders, Atreides pays for
* four Caladan reinforcements, and Emperor pays to invade Junction with one.
* The latter real conflict keeps Battle open, separating support/Tech economics
* from later Collection. No force, wallet, phase, module owner or receipt staging.
* Existing provisional utility/Stronghold timing is inherited, not a new ruling. */
export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions = {}): WorldPhaseBattleFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const base = options.initial ? createHarassHomeworldSetup({ advanced, initial: options.initial }) : null;
const original = base ? { initial: base.initial, game: base.afterSetup, steps: base.nativeSteps }
: createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
factions: ['emperor', 'guild', 'atreides'] });
let game = structuredClone(original.game);
const actions = [...original.steps], initial = structuredClone(original.initial);
assert.ok(game.homeworlds?.custody);
if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech);
if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds);
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'atreides')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'emperor')!.id;
const location = 'arrakeen:10';
const staging = [
'Only original setup and first-Storm entropy are fixed; all later Storm draws and movement remain native.',
'Position eight original unopened non-worm Spice Cards for the two actual native Spice Blows; no draw, threshold, force or phase assignment.',
];
for (let position = 0; position < 8; position++) {
const at = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
const act = (step: WorldPhaseBattleStep): Game => {
game = actWorldPhaseBattle(game, step, actions); return game;
};
const reach = (until: (state: Game) => boolean) => { game = advanceWorldPhaseBattle(game, until, actions); };
reach(g => g.phase === 8 && harassHomeworldClean(g));
let beforeFirstMentat: Game | undefined, firstMentatStep: WorldPhaseBattleStep | undefined;
while (game.turn === 1) {
const step = nextHarassHomeworldStep(game); assert.ok(step);
const before = structuredClone(game), after = act(step);
if (after.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
reach(g => g.turn === 2 && g.phase === 5 && harassHomeworldClean(g));
const shipments: WorldPhaseBattleFixture['shipments'] = [];
while (game.phase === 5) {
const actor = game.active!;
if (harassHomeworldClean(game) && !harassHomeworldPlayer(game, actor).shipped) {
const amount = actor === observer ? 1 : 4;
const homeworldSources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(homeworldSources, 'Withdraw original typed native reserves, never guessed source pools');
const before = structuredClone(game);
const invasion = actor === observer ? homeworldShipmentChoice(viewGame(game, actor), 'homeworld:guild',
Object.fromEntries(Object.entries(homeworldSources).filter(([, forces]) => forces.normal + forces.elite > 0))) : null;
if (invasion) assert.ok(invasion.action, invasion.blocked ?? 'Original typed invasion must be available');
const step: WorldPhaseBattleStep = { actor, action: invasion?.action ?? { type: 'ship',
territory: 'arrakeen', sector: 10, amount, elite: 0, homeworldSources, allyPayment: 0 } };
act(step); reach(harassHomeworldClean);
shipments.push({ before, step, after: structuredClone(game) });
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step);
}
}
reach(g => g.phase === 6 && harassHomeworldClean(g));
for (const p of game.players) game.deck.push(...p.hand.splice(0));
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
staging.push('At the real Battle window, conserve all original hands into undealt stock and position the original Reinforcements and Harass & Withdraw in Atreides’ hand. This is explicit physical card staging, not a natural purchase.');
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice);
const battleStep: WorldPhaseBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = living(owner).at(-1)!, opponentLeader = living(opponent)[0];
const support = advanced ? options.support ?? 3 : 0;
const sources = { 'homeworld:atreides': { normal: 3, elite: 0 } };
const plans: WorldPhaseBattleFixture['plans'] = [
{ actor: owner, action: { type: 'battlePlan', dial: 3, support, leader: leader.id,
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, reinforcementsSources: sources } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id } },
];
const army = combatArmy(game, owner, game.battle!.territory);
assert.deepEqual(army, { normal: 14, elite: 0 });
const committed = advanced ? 6 - support : 3;
const returns = { [location]: { normal: army.normal - committed, elite: 0 } };
assertHarassHomeworldCustody(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, shipments,
beforeBattle, battleStep, game, owner, opponent, observer, location, plans, sources, returns, actions, staging };
}
/** Exposes exactly the original source-key controls for a genuine own-World
* battle: ten normal Kaitain reserves move to Salusa; Guild pays to invade
* Kaitain. The three fee counters may come from either real normal pool. */
export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech?: boolean } = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: options.tech ?? true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'native', outcome: 'win',
nativeTransfer: 10, sources: { 'homeworld:emperor:salusa': { normal: 3, elite: 0 } }, ownerDial: 2, ownerSupport: 2 });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s hand solely to exercise the native own-World ban; never seal it in the legal plan.');
assertHarassHomeworldCustody(fixture.game);
return fixture;
}
/** Seven original Emperor visitors invade Junction (3N4E), then two normal
* reserves move Kaitain → Salusa. R pays 1N Kaitain + 2N Salusa before H returns
* 2N to Kaitain and 3E to Salusa. Both modules start original and unused. */
export function createWorldPhaseVisitorBattleFixture(options: { initial?: Game } = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'visitor', outcome: 'win',
slot: 'weapon', nativeTransfer: 2, ownerDial: 2, ownerSupport: 1,
sources: { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 } } });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
fixture.planActions[0].action.defense = HARASS_WITHDRAW_CARD;
fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s other physical utility slot; exact post-fee returns remain a human typed allocation.');
assertHarassHomeworldCustody(fixture.game);
return fixture;
}
export function revealWorldPhaseBattle(fixture: WorldPhaseBattleFixture,
plans: readonly WorldPhaseBattleStep[] = fixture.plans): Game {
let game = structuredClone(fixture.game);
for (const plan of plans) {
game = actHarassHomeworld(game, plan);
game = advanceWorldPhaseBattle(game, g => harassHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false);
}
assert.ok(game.battle?.revealed); return game;
}
/** No decision is cleared or fabricated. Callers can stop before an actual
* Caladan/Tech choice using advanceWorldPhaseBattle and consume its event. */
export function finishWorldPhaseBattle(state: Game, fixture: WorldPhaseBattleFixture,
actions?: WorldPhaseBattleStep[]): Game {
let game = state;
for (let n = 0; n < 240; n++) {
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
let step: WorldPhaseBattleStep | null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: fixture.returns } };
} else step = nextHarassHomeworldStep(game);
assert.ok(step, 'Seal both original plans before finishing their physical aftermath');
game = actHarassHomeworld(game, step, actions);
}
throw Error('Original World utility battle did not finish');
}
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, type GameView,
} from '../game/engine';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
assertRecruitsHomeworldInventory, createRecruitsHomeworldFixture,
recruitsHomeworldClean, recruitsHomeworldPlayer, type RecruitsHomeworldFixture, type RecruitsHomeworldStep,
} from './fixture-recruits-homeworld';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
export type WorldPhaseRecruitsOptions = {
advanced?: boolean;
strongholds?: boolean;
seatIds?: readonly [string, string, string, string];
};
export type WorldPhaseRecruitsFixture = {
initial: Game; afterSetup: Game; game: Game;
afterPaidReturn: Game; beforePlay: Game; afterPlay: Game; beforeFremen: Game;
afterTypedReturn: Game; afterReturns: Game; beforePhaseEnd: Game; afterPhaseEnd: Game;
phaseEndStep: RecruitsHomeworldStep;
actors: { emperor: string; fremen: string; atreides: string; guild: string; owner: string };
actions: Record<'paidReturn' | 'readyAtreides' | 'play' | 'reopenedReturn' |
'typedReturn' | 'followupReturn', Action>;
ordinary: { fremen: GameView['revival']; atreides: GameView['revival'] };
expected: {
paidReturnPrice: number; reopenedReturnPrice: number; fremenReturnPrice: number;
income: number; wallets: Record<string, number>;
doubled: { fremenFree: number; atreidesFree: number; limit: number };
afterThreshold: { freeRemaining: number; forcesRemaining: number; homeworldBonus: number };
};
nativeSteps: RecruitsHomeworldStep[]; steps: RecruitsHomeworldStep[]; staging: string[];
};
export const worldPhaseRecruitsPlayer = recruitsHomeworldPlayer;
export function worldPhaseRecruitsNativePool(game: Game, actor: string) {
const world = viewGame(game, actor).homeworlds?.worlds?.find(w => w.native === actor && !w.secondary);
assert.ok(world, 'The original native World must retain its real custody');
return world.forces[actor];
}
/** Original E/F/A/G setup, paid typed shipments and two actual battles are
* supplied by the canonical World fixture. Only an undealt lobby is composed
* here: no live phase, force, wallet, owner or revival receipt is retrofitted.
* Its existing initializer scopes its own entropy; the first subsequent draw
* is the original first-Storm shuffle of the two unassigned Tech Tokens.
* All later entropy (including Advanced's native Storm deck) delegates intact. */
export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptions = {}): WorldPhaseRecruitsFixture {
const advanced = options.advanced ?? false;
const strongholds = options.strongholds ?? false;
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild'];
const [emperor, fremen, atreides, guild] = ids;
let lobby = createGame('WORLDPHASERECRUITS', newPlayer(emperor, 'Emperor', 'emperor'), advanced);
for (const [id, faction] of [[fremen, 'fremen'], [atreides, 'atreides'], [guild, 'guild']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
lobby = applyAction(lobby, emperor, { type: 'homeworlds', enabled: true });
lobby.techTokens = createTechTokens();
if (strongholds) lobby.strongholdCards = createStrongholdCards();
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
// Binding loses Crypto's generic guarantee that it returns the same view.
const originalRandom = crypto.getRandomValues.bind(crypto) as <T extends ArrayBufferView | null>(array: T) => T;
let firstStormDraw = true;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (!firstStormDraw) return originalRandom(array);
assert.ok(array);
firstStormDraw = false;
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
let source: RecruitsHomeworldFixture;
try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids }); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
assert.equal(firstStormDraw, false, 'The original first Storm must assign unused Tech Tokens');
let game = structuredClone(source.game);
const steps: RecruitsHomeworldStep[] = [];
const act = (actor: string, action: Action) => {
steps.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
const settle = () => {
for (let count = 0; count < 100; count++) {
if (recruitsHomeworldClean(game)) return;
const next = nextHarassHomeworldStep(game); assert.ok(next);
act(next.actor, next.action);
}
throw Error('Original World revival decisions did not settle');
};
const actions = {
paidReturn: { type: 'revive', amount: 3, elite: 0 },
readyAtreides: { type: 'ready' },
play: { type: 'card', card: RECRUITS_CARD_ID },
reopenedReturn: { type: 'revive', amount: 2, elite: 0 },
typedReturn: { type: 'revive', amount: 1, elite: 1 },
followupReturn: { type: 'revive', amount: 5, elite: 0 },
} satisfies Record<string, Action>;
const owner = game.techTokens?.axlotl.owner; assert.ok(owner);
const income = ownedTech(game.techTokens, owner).length;
assert.ok(income > 0);
const ordinary = {
fremen: structuredClone(viewGame(game, fremen).revival),
atreides: structuredClone(viewGame(game, atreides).revival),
};
const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice]));
act(atreides, actions.paidReturn); settle();
const afterPaidReturn = structuredClone(game);
act(atreides, actions.readyAtreides);
const beforePlay = structuredClone(game);
act(emperor, actions.play);
const afterPlay = structuredClone(game);
act(atreides, actions.reopenedReturn); settle();
const beforeFremen = structuredClone(game);
act(fremen, actions.typedReturn); settle();
const afterTypedReturn = structuredClone(game);
act(fremen, actions.followupReturn); settle();
const afterReturns = structuredClone(game);
let beforePhaseEnd: Game | undefined;
let phaseEndStep: RecruitsHomeworldStep | undefined;
for (let count = 0; count < 100 && game.phase === 4; count++) {
const next = nextHarassHomeworldStep(game); assert.ok(next);
const before = structuredClone(game);
act(next.actor, next.action);
if (game.phase !== 4) { beforePhaseEnd = before; phaseEndStep = structuredClone(next); }
}
assert.ok(beforePhaseEnd && phaseEndStep, 'The original last ready must settle Revival');
assert.equal(game.phase, 5);
const afterPhaseEnd = structuredClone(game);
for (const boundary of [source.afterSetup, source.game, afterPaidReturn, beforePlay, afterPlay,
beforeFremen, afterTypedReturn, afterReturns, beforePhaseEnd, afterPhaseEnd])
assertRecruitsHomeworldInventory(boundary);
return {
initial: source.initial, afterSetup: source.afterSetup, game: source.game,
afterPaidReturn, beforePlay, afterPlay, beforeFremen, afterTypedReturn, afterReturns,
beforePhaseEnd, phaseEndStep, afterPhaseEnd,
actors: { emperor, fremen, atreides, guild, owner }, actions, ordinary,
expected: {
paidReturnPrice: 2, reopenedReturnPrice: 0, fremenReturnPrice: 0,
income, wallets,
doubled: { fremenFree: 7, atreidesFree: 4, limit: 7 },
afterThreshold: { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 },
},
nativeSteps: source.nativeSteps, steps,
staging: [...source.staging,
'Select unused original Tech Tokens and optional Advanced Strongholds in the undealt lobby, before readiness and original setup.',
'Fix only the original first-Storm unused-token shuffle; restore native later Storm entropy. Custody and held-token count are read, never assigned.',
'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after reopening. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.',
],
};
}
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { harassHomeworldPool as pool, finishHarassHomeworldBattle } from './fixture-harass-homeworld';
import {
finishReinforcementsHomeworldBattle, revealReinforcementsHomeworldBattle,
} from './fixture-reinforcements-homeworld';
import {
actWorldPhaseBattle, advanceWorldPhaseBattle, assertWorldPhaseBattleInventory,
createWorldPhaseBattleFixture, createWorldPhaseOwnBattleFixture, createWorldPhaseVisitorBattleFixture,
finishWorldPhaseBattle, revealWorldPhaseBattle, worldPhaseBattleClean,
worldPhaseBattlePlayer as player, type WorldPhaseBattleFixture,
} from './fixture-world-phase-battle';
function assertCompletedUtilityBattle(game: Game, f: WorldPhaseBattleFixture): void {
assert.equal(game.turn, 2);
assert.equal(game.phase, 6, 'A second paid native conflict prevents Collection from hiding battle economics');
assert.equal(game.battle, null);
assert.ok(worldPhaseBattleClean(game));
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.result, 'normal');
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
assert.equal(game.discard.filter(card => card.id === id).length, 1);
assert.ok(!player(game, f.owner).hand.some(card => card.id === id));
}
assert.deepEqual(combatArmy(game, f.observer, 'homeworld:guild'), { normal: 1, elite: 0 },
'The original paid Emperor visitor remains outside Player.forces and native reserve totals');
assert.equal(player(game, f.observer).reserves, 19);
assert.deepEqual(pool(game, f.opponent, 'homeworld:guild'), { normal: 11, elite: 0 });
assertWorldPhaseBattleInventory(game);
}
for (const support of [0, 1, 3]) {
void test(`earned Arrakeen + original Tech: support ${support} pays only actual support, then exact native R/H custody`, () => {
const f = createWorldPhaseBattleFixture({ support });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(player(f.beforeFirstMentat, f.owner).forces[f.location], 10);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
const ownShipment = f.shipments.find(s => s.step.actor === f.owner)!;
const attack = f.shipments.find(s => s.step.actor === f.opponent)!;
assert.equal(player(ownShipment.before, f.owner).spice - player(ownShipment.after, f.owner).spice, 4,
'Four original Caladan counters pay their native four-spice invoice');
assert.equal(player(attack.before, f.opponent).spice - player(attack.after, f.opponent).spice, 2,
'Four original Guild invaders pay their real native half-price invoice');
assert.deepEqual(pool(f.game, f.owner, 'homeworld:atreides'), { normal: 6, elite: 0 });
assert.deepEqual(combatArmy(f.game, f.owner, f.game.battle!.territory), { normal: 14, elite: 0 });
assert.equal(f.game.techTokens!.heighliners.owner, f.opponent);
assert.equal(f.game.techTokens!.heighliners.spice, 0);
const revealed = revealWorldPhaseBattle(f);
assert.equal(player(revealed, f.owner).reserves, 6, 'Sealing and reveal do not prematurely spend counters');
const done = finishWorldPhaseBattle(revealed, f);
const committed = 6 - support;
const returned = 14 - committed;
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - Math.max(0, support - 2),
'Arrakeen covers at most two actually selected support; it never mints personal spice');
assert.equal(player(done, f.owner).tanks, 3 + committed,
'Three off-World native fee counters plus only the physical committed Arrakeen complement die');
assert.equal(player(done, f.owner).battleLosses, 3 + committed);
assert.equal(player(done, f.owner).reserves, 3 + returned,
'Original Caladan6 pays R3 before all undialed board counters return to that actual native pool');
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 3 + returned, elite: 0 });
assert.deepEqual(combatArmy(done, f.owner, f.game.battle!.territory), { normal: 0, elite: 0 });
assert.equal(player(done, f.opponent).tanks, 4);
assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice,
'Winning does not retroactively redirect the former owner’s Heighliners income');
assert.equal(done.techTokens!.heighliners.owner, f.owner, 'Original winner Tech transfer follows the actual Guild defeat');
assert.equal(done.techTokens!.heighliners.spice, 0);
assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'high');
assertCompletedUtilityBattle(done, f);
});
}
void test('Advanced earned Strongholds alone: utility fee cannot turn six native reserves into extra Arrakeen dial', () => {
const f = createWorldPhaseBattleFixture({ tech: false, strongholds: true });
const tooMany = structuredClone(f.plans[0].action);
tooMany.dial = 8.5; tooMany.support = 2;
assert.throws(() => applyAction(f.game, f.owner, tooMany),
'Fifteen physical board commitments cannot borrow any of the six native reserves, even with R');
const wrongSource = structuredClone(f.plans[0].action);
wrongSource.reinforcementsSources = { 'homeworld:guild': { normal: 3, elite: 0 } };
assert.throws(() => applyAction(f.game, f.owner, wrongSource), 'Only original native reserves can pay this fee');
const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 1);
assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice,
'No post-battle income may be fabricated when the Tech module is absent');
assert.equal(player(done, f.owner).tanks, 6);
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 });
assertCompletedUtilityBattle(done, f);
});
void test('Basic Worlds + original Tech: native paid attack and R/H retain Basic forces and no support charge', () => {
const f = createWorldPhaseBattleFixture({ advanced: false, tech: true, strongholds: false });
const tooMany = structuredClone(f.plans[0].action); tooMany.dial = 15;
assert.throws(() => applyAction(f.game, f.owner, tooMany), 'The native fee is never an additional Basic board army');
const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice, 'Basic pays no force support');
assert.equal(player(done, f.owner).tanks, 6);
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 });
assert.equal(viewGame(done, f.owner).strongholdCards, null);
assert.equal(done.techTokens!.heighliners.owner, f.owner);
assertCompletedUtilityBattle(done, f);
});
void test('native current Caladan face: a surviving original army consumes the actual reinforcement decision', () => {
const f = createWorldPhaseBattleFixture();
const plans = structuredClone(f.plans);
plans[0].action.weapon = null; plans[0].action.defense = null;
delete plans[0].action.reinforcementsSources;
let game = revealWorldPhaseBattle(f, plans);
game = advanceWorldPhaseBattle(game, g => !g.response && !g.phaseOpening && g.decision?.kind === 'caladanReinforcement');
assert.equal(game.decision!.player, f.owner);
assert.equal(player(game, f.owner).reserves, 6);
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).forces[f.location], 11);
const beforeSpice = player(game, f.owner).spice;
const offer = viewGame(game, f.owner).caladanReinforcement!;
const destination = offer.destinations.find(d => d.id === f.location && !d.blocked); assert.ok(destination);
game = actWorldPhaseBattle(game, { actor: f.owner,
action: { type: 'decision', event: offer.event, destination: destination.id } });
game = advanceWorldPhaseBattle(game, g => !g.battle && worldPhaseBattleClean(g));
assert.equal(player(game, f.owner).reserves, 5, 'Exactly one real Caladan normal counter leaves its native pool');
assert.equal(player(game, f.owner).forces[f.location], 12);
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).spice, beforeSpice, 'The native reinforcement is not a second paid shipment');
assert.equal(viewGame(game, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'low',
'The actual native6→5 transfer immediately crosses the printed Caladan threshold');
assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(game.techTokens!.heighliners.owner, f.owner);
assertWorldPhaseBattleInventory(game);
});
void test('own-World R: local fee counters are disjoint from dial, off-World normals pay physically, and H remains forbidden', () => {
const f = createWorldPhaseOwnBattleFixture();
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 5, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 10, elite: 5 });
assert.deepEqual(pool(f.game, f.opponent, f.location), { normal: 5, elite: 0 }, 'The original paid Guild invaders retain separate visitor custody');
const local = structuredClone(f.planActions[0].action);
local.dial = 3; local.support = 3;
local.reinforcementsSources = { 'homeworld:emperor': { normal: 3, elite: 0 } };
assert.throws(() => applyAction(f.game, f.owner, local), 'Kaitain5 cannot both pay its own three and dial three');
const elite = structuredClone(f.planActions[0].action);
elite.reinforcementsSources = { 'homeworld:emperor:salusa': { normal: 0, elite: 3 } };
assert.throws(() => applyAction(f.game, f.owner, elite), 'Existing native normals must pay before any real Sardaukar');
const forbidden = structuredClone(f.planActions[0].action); forbidden.weapon = HARASS_WITHDRAW_CARD;
assert.throws(() => applyAction(f.game, f.owner, forbidden), 'An original H card in hand never permits own-World withdrawal');
f.planActions[0].action.dial = 3; f.planActions[0].action.support = 3;
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f));
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 2, elite: 0 });
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 7, elite: 5 },
'Exactly three original Salusa normals pay R; none of the five original Sardaukar substitutes');
assert.equal(player(done, f.owner).reserves, 14);
assert.equal(player(done, f.owner).tanks, 6);
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.equal(player(done, f.opponent).tanks, 5);
assert.deepEqual(pool(done, f.opponent, f.location), { normal: 0, elite: 0 });
assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(done, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD), 'Rejected H was never used or discarded');
assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.id === f.location)?.side, 'low');
assertWorldPhaseBattleInventory(done);
});
void test('foreign World R+H: original split normal fee precedes exact normal/elite deposits, never tanks or visitor reserves', () => {
const f = createWorldPhaseVisitorBattleFixture();
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 10, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 2, elite: 1 });
assert.deepEqual(pool(f.game, f.owner, f.location), { normal: 3, elite: 4 });
const done = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f),
{ returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 }, 'Kaitain10 − fee1 + return2');
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 }, 'Salusa2N1E − fee2N + return3E');
assert.deepEqual(pool(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.equal(player(done, f.owner).reserves, 15);
assert.equal(player(done, f.owner).elites!.reserves, 4);
assert.equal(player(done, f.owner).tanks, 5, 'The fee3N and committed 1N1E die; the undialed 2N3E do not');
assert.equal(player(done, f.owner).elites!.tanks, 1);
assert.equal(player(done, f.opponent).reserves, 0);
assert.equal(player(done, f.opponent).tanks, 15, 'Only the genuine native Junction army dies, not its original five board counters');
assert.equal(player(done, f.opponent).forces['tueks_sietch:5'], 5);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) assert.equal(done.discard.filter(c => c.id === id).length, 1);
assert.equal(done.battle, null); assert.ok(worldPhaseBattleClean(done));
assertWorldPhaseBattleInventory(done);
});
+124
View File
@@ -0,0 +1,124 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { ownedTech } from '../game/tech-tokens';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
import {
createWorldPhaseRecruitsFixture, worldPhaseRecruitsNativePool as pool,
worldPhaseRecruitsPlayer as player,
} from './fixture-world-phase-recruits';
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before, 'Rejected spending/returns must preserve the actual programme');
}
const cases = [
{ name: 'Basic World + Tech', advanced: false, strongholds: false },
{ name: 'Advanced World + Tech', advanced: true, strongholds: false },
{ name: 'Advanced World + Tech + earned Strongholds', advanced: true, strongholds: true },
];
for (const options of cases) {
void test(`${options.name}: original casualties, useful doubled typed returns and one delayed Axlotl payment`, () => {
const f = createWorldPhaseRecruitsFixture(options);
const { emperor, fremen, atreides, guild, owner } = f.actors;
const start = f.game;
assert.equal(player(start, fremen).reserves, 2);
assert.equal(player(start, fremen).tanks, 18);
assert.equal(player(start, fremen).elites!.tanks, 3);
assert.equal(player(start, atreides).tanks, 10);
assert.equal(start.techTokens!.production.owner, emperor, 'The real Fremen loss transfers its original token');
assert.equal(ownedTech(start.techTokens, owner).length, 2);
assert.equal(start.techTokens!.axlotl.spice, 0);
assert.deepEqual({ free: f.ordinary.fremen.freeRemaining, limit: f.ordinary.fremen.limit,
bonus: f.ordinary.fremen.homeworldBonus }, { free: 4, limit: 4, bonus: 1 });
assert.deepEqual({ free: f.ordinary.atreides.freeRemaining, limit: f.ordinary.atreides.limit },
{ free: 2, limit: 3 });
const paid = player(f.afterPaidReturn, atreides);
assert.deepEqual({ revived: paid.revived, free: paid.freeForcesRevived, reserves: paid.reserves,
tanks: paid.tanks, spice: paid.spice },
{ revived: 3, free: 2, reserves: 13, tanks: 7, spice: player(start, atreides).spice - 2 });
assert.ok(f.beforePlay.ready.includes(atreides));
assert.equal(viewGame(f.afterPlay, fremen).revival.freeRemaining, 7);
assert.equal(viewGame(f.afterPlay, fremen).revival.limit, 7);
assert.equal(viewGame(f.afterPlay, atreides).revival.freeRemaining, 2,
'Doubled free allowance subtracts the recorded two free returns, not all three returns');
assert.equal(viewGame(f.afterPlay, atreides).revival.forcesRemaining, 4);
const reopened = player(f.beforeFremen, atreides);
assert.deepEqual({ revived: reopened.revived, free: reopened.freeForcesRevived,
reserves: reopened.reserves, tanks: reopened.tanks, spice: reopened.spice },
{ revived: 5, free: 4, reserves: 15, tanks: 5, spice: paid.spice });
assert.deepEqual(pool(f.beforeFremen, atreides), { normal: 15, elite: 0 });
assert.deepEqual(pool(f.afterTypedReturn, fremen), { normal: 2, elite: 1 });
const current = viewGame(f.afterTypedReturn, fremen).revival;
assert.deepEqual({ freeRemaining: current.freeRemaining, forcesRemaining: current.forcesRemaining,
homeworldBonus: current.homeworldBonus }, { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 });
assert.equal(current.eliteRemaining, options.advanced ? 0 : 2);
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 6, elite: 0 });
if (options.advanced)
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 1, elite: 1 });
const returned = player(f.afterReturns, fremen);
assert.deepEqual({ revived: returned.revived, free: returned.freeForcesRevived,
reserves: returned.reserves, tanks: returned.tanks, spice: returned.spice },
{ revived: 6, free: 6, reserves: 8, tanks: 12, spice: player(start, fremen).spice });
assert.equal(returned.elites!.reserves, 1); assert.equal(returned.elites!.tanks, 2);
assert.deepEqual(pool(f.afterReturns, fremen), { normal: 7, elite: 1 });
assert.equal(viewGame(f.afterReturns, fremen).revival.forcesRemaining, 0);
assert.equal(f.afterReturns.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
for (const boundary of [f.afterPaidReturn, f.afterPlay, f.beforeFremen,
f.afterTypedReturn, f.afterReturns, f.beforePhaseEnd]) {
assert.equal(boundary.techTokens!.axlotl.spice, 2);
assert.equal(boundary.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(player(boundary, owner).spice, player(start, owner).spice,
'Accrued Axlotl spice is not a spendable wallet credit');
}
const available = player(f.afterReturns, owner).spice;
const bribes = viewGame(f.afterReturns, owner).bribeOptions;
assert.equal(bribes.available, available);
assert.equal(bribes.blocked, null, 'The spending rejection must be a wallet boundary, not a timing gate');
assert.equal(bribes.targets.find(target => target.id === fremen)?.maximum, available);
rejectsUnchanged(f.afterReturns, owner,
{ type: 'bribe', target: fremen, amount: available + f.expected.income });
const settled = applyAction(f.beforePhaseEnd, f.phaseEndStep.actor, f.phaseEndStep.action);
assert.equal(settled.phase, 5);
assert.equal(player(settled, owner).spice, available + 2);
assert.equal(settled.techTokens!.axlotl.spice, 0);
assert.equal(settled.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(player(settled, atreides).spice, paid.spice,
'Recruits and settlement never refund the earlier paid return');
assert.equal(player(settled, atreides).revived, 5);
assert.equal(player(settled, atreides).freeForcesRevived, 4);
assert.equal(player(settled, fremen).revived, 6);
assert.equal(player(settled, fremen).freeForcesRevived, 6);
if (options.strongholds) {
assert.equal(start.strongholdCards!.claimedTurn, 1);
assert.equal(start.strongholdCards!.owners.sietch_tabr, emperor);
assert.equal(start.strongholdCards!.owners.arrakeen, guild);
assert.equal(start.strongholdCards!.owners.tueks_sietch, guild);
}
for (const boundary of [start, f.afterPaidReturn, f.afterPlay, f.afterTypedReturn,
f.afterReturns, settled]) assertRecruitsHomeworldInventory(boundary);
});
}
void test('A bulk native low-population request locks its quoted seven, unlike the six separate typed returns', () => {
const f = createWorldPhaseRecruitsFixture();
const { fremen } = f.actors;
const bulk = applyAction(f.beforeFremen, fremen, { type: 'revive', amount: 7, elite: 0 });
assert.equal(player(bulk, fremen).revived, 7);
assert.equal(player(bulk, fremen).freeForcesRevived, 7);
assert.equal(player(bulk, fremen).spice, player(f.beforeFremen, fremen).spice);
assert.equal(player(bulk, fremen).tanks, 11);
assert.equal(player(bulk, fremen).elites!.tanks, 3);
assert.deepEqual(pool(bulk, fremen), { normal: 9, elite: 0 });
assert.equal(bulk.techTokens!.axlotl.spice, f.expected.income,
'Another eligible bulk return cannot accrue a second payment in the same original phase');
assertRecruitsHomeworldInventory(bulk);
});