Compose selected native World independent cards

This commit is contained in:
Quintine committed 2026-10-17 10:31:05 +11:00
1 parent f5b7ebc8da
commit cda9338f13
18 files changed
+1292 -40

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
import { isAuditorLeader } from '../game/cards';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import {
advanceHarassHomeworld, harassHomeworldClean, nextHarassHomeworldStep,
type HarassHomeworldStep,
} from './fixture-harass-homeworld';
export { REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD };
export type NativeWorldBattleStep = HarassHomeworldStep;
export type NativeWorldBattleOptions = {
advanced?: boolean;
route?: 'ix-visitor' | 'ix-native' | 'choam-native';
/** Actual first-turn export, not a reserve assignment. */
exportNormal?: number;
exportElite?: number;
ownerDial?: number;
harass?: boolean;
outcome?: 'win' | 'loss';
/** Exchange an original unrevealed Face Dancer for the chosen leader. */
faceDancer?: boolean;
};
export type NativeWorldBattleShipment = { before: Game; step: NativeWorldBattleStep; cost: number; after: Game };
export type NativeWorldBattleFixture = {
initial: Game; afterSetup: Game; game: Game; options: NativeWorldBattleOptions;
route: 'ix-visitor' | 'ix-native' | 'choam-native';
owner: string; opponent: string; native: string; location: string;
shipments: NativeWorldBattleShipment[];
snapshots: { afterSetup: Game; beforeBattle: Game; plans: Game };
beforeBattle: Game; battleStep: NativeWorldBattleStep;
planActions: [NativeWorldBattleStep, NativeWorldBattleStep];
sources: NativeReserveSelections; returns: Record<string, HomeworldForces>;
nativeSteps: NativeWorldBattleStep[]; staging: string[];
};
export const nativeWorldBattlePlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
};
export const nativeWorldBattlePool = (game: Game, actor: string, location: string): HomeworldForces => {
const world = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(w => w.id === location);
assert.ok(world); return { ...(world.forces[actor] ?? { normal: 0, elite: 0 }) };
};
const physicalCards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
const physicalIdentities = (game: Game) => [...(game.traitorReserve ?? []), ...game.players.flatMap(p =>
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(d => d.leader)])].sort();
/** Native reserves already represent the primary World: never count it twice.
* Ix owns seven Cyborgs in Basic as well as Advanced, not Emperor's five. */
export function assertNativeWorldBattleInventory(game: Game, original: Game): void {
homeworldGameIntegrity(game);
const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const p of game.players) {
const foreign = worlds.filter(w => w.native !== p.id).map(w => w.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, v) => n + v, 0) +
foreign.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, v) => n + v, 0) + foreign.reduce((n, f) => n + f.elite, 0),
p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
}
assert.deepEqual(physicalCards(game), physicalCards(original));
assert.deepEqual(physicalIdentities(game), physicalIdentities(original));
}
function conserveCard(game: Game, actor: string, id: string, staging: string[]): void {
const owner = nativeWorldBattlePlayer(game, actor);
if (owner.hand.some(c => c.id === id)) return;
const at = game.deck.findIndex(c => c.id === id); assert.ok(at >= 0);
owner.hand.push(game.deck.splice(at, 1)[0]);
staging.push(`Conserved original ${id}: undealt deck → ${actor}; not a purchase.`);
}
function conserveDancer(game: Game, identity: string, staging: string[]): void {
const tleilaxu = nativeWorldBattlePlayer(game, 'tleilaxu');
if (tleilaxu.faceDancers!.some(d => d.leader === identity)) return;
const dancer = tleilaxu.faceDancers!.find(d => !d.revealed); assert.ok(dancer);
const returned = dancer.leader;
const at = game.traitorReserve!.indexOf(identity);
const donor = game.players.find(p => p.traitors.includes(identity));
assert.ok(at >= 0 || donor, 'Use an original physical identity, never mint a Face Dancer');
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
dancer.leader = identity;
staging.push(`Conserved Face Dancer ${identity}: ${donor?.id ?? 'original reserve'} exchanged with Tleilaxu's ${returned}.`);
}
/** Real native setup, paid exports/invasions and original battle preparation.
* Only unopened Spice order, physical hands and an optional identity exchange
* are staged. All armies, banks, World faces, phase and receipts stay native. */
export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions = {}): NativeWorldBattleFixture {
const route = options.route ?? 'ix-visitor';
const advanced = options.advanced ?? false;
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true, tech: false, strongholds: false,
factions: route === 'choam-native' ? ['choam', 'guild', 'tleilaxu', 'ixians'] : ['ixians', 'tleilaxu', 'guild'],
expansions: route === 'choam-native' ? ['ix', 'choam'] : ['ix'] });
let game = setup.game;
const afterSetup = structuredClone(game), nativeSteps: NativeWorldBattleStep[] = [...setup.steps];
const staging: string[] = [];
for (let i = 0; i < (advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Position original unopened non-worm Spice Cards for turn one; original Storm and later draws remain untouched.');
const act = (step: NativeWorldBattleStep) => {
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
};
const reach = (until: (g: Game) => boolean) => { game = advanceHarassHomeworld(game, until, nativeSteps); };
const shipments: NativeWorldBattleShipment[] = [];
const ship = (actor: string, destination: string, normal: number, elite: number) => {
reach(g => g.phase === 5 && g.active === actor && harassHomeworldClean(g));
const before = structuredClone(game), view = viewGame(game, actor);
const sources = nativeShipmentSources(view, normal + elite, elite); assert.ok(sources);
const choice = homeworldShipmentChoice(view, destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'Original paid native shipment must be available');
const step = { actor, action: choice.action }; act(step);
reach(g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g));
shipments.push({ before, step, cost: choice.cost, after: structuredClone(game) });
};
const owner = route === 'choam-native' ? 'choam' : 'ixians';
const opponent = route === 'ix-visitor' ? 'tleilaxu' : 'guild';
const native = route === 'ix-visitor' ? opponent : owner;
const location = `homeworld:${native}`;
// Advanced Guild chooses its real early-shipment window before Ixians.
if (route === 'ix-native' && advanced) ship('guild', location, 3, 0);
if (route !== 'choam-native') ship('ixians', route === 'ix-visitor' ? location : 'homeworld:tleilaxu',
options.exportNormal ?? 9, options.exportElite ?? 0);
if (route === 'choam-native' || route === 'ix-native' && !advanced) ship('guild', location, 3, 0);
reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g));
// Preserve every original card, including Ix setup draws and CHOAM stock.
for (const p of game.players) game.deck.push(...p.hand.splice(0));
staging.push('Return all original hands to unopened stock before conserved card selection; no native stock is omitted.');
conserveCard(game, owner, REINFORCEMENTS_CARD, staging);
const harass = options.harass ?? route === 'ix-visitor';
if (harass || route !== 'ix-visitor') conserveCard(game, owner, HARASS_WITHDRAW_CARD, staging);
if (route === 'choam-native') {
const unused = game.deck.find(c => c.id !== REINFORCEMENTS_CARD && c.id !== HARASS_WITHDRAW_CARD);
assert.ok(unused); conserveCard(game, opponent, unused.id, staging);
}
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt && !isAuditorLeader(l))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const win = options.outcome === 'win';
const ownLeader = route === 'choam-native' && advanced ? nativeWorldBattlePlayer(game, owner).leaders.find(isAuditorLeader)!
: win ? living(owner).at(-1)! : living(owner)[0];
const otherLeader = win ? living(opponent)[0] : living(opponent).at(-1)!;
assert.ok(ownLeader && otherLeader);
if (options.faceDancer) {
conserveDancer(game, ownLeader.id, staging);
}
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location); assert.ok(choice);
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
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 preview = viewGame(game, owner).battle!.reinforcements; assert.ok(preview && !preview.blocked);
const positiveSources = preview.homeworldSources?.[0]; assert.ok(positiveSources);
const dial = options.ownerDial ?? (route === 'choam-native' ? 0 : route === 'ix-native' ? 2 : 1);
const support = 0;
const planActions: [NativeWorldBattleStep, NativeWorldBattleStep] = [
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
weapon: REINFORCEMENTS_CARD, defense: harass ? HARASS_WITHDRAW_CARD : null, reinforcementsSources: positiveSources } },
{ actor: opponent, action: { type: 'battlePlan', dial: win ? 0 : route === 'ix-visitor' ? 3 : 0,
support: 0, leader: otherLeader.id } },
];
const returns: Record<string, HomeworldForces> = harass
? { [location]: { normal: (options.exportNormal ?? 9) - 2, elite: options.exportElite ?? 0 } } : {};
assertNativeWorldBattleInventory(game, afterSetup);
return { initial: setup.initial, afterSetup, game, options, route, owner, opponent, native, location,
shipments, snapshots: { afterSetup, beforeBattle, plans: structuredClone(game) }, beforeBattle, battleStep,
planActions, sources: positiveSources, returns, nativeSteps, staging };
}
/** Stop at a requested original aftercare decision rather than letting a policy
* silently decline the native Face Dance, Auditor or Cyborg exchange. */
export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean,
steps: NativeWorldBattleStep[] = [], options: { returns?: Record<string, HomeworldForces>; callers?: string[];
casualties?: HomeworldForces } = {}): Game {
for (let n = 0; n < 300; n++) {
if (until(game)) return game;
assert.ok(game.battle || game.homeworldBattleLoss || game.decision || game.response || game.phaseOpening,
'The original battle finished without the requested aftercare decision');
let step: NativeWorldBattleStep | null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
assert.ok(options.returns); step = { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, returns: options.returns } };
} else if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses' && options.casualties) {
const d = game.decision;
const choice = d.options.findIndex(f => f.normal === options.casualties!.normal && f.elite === options.casualties!.elite);
assert.ok(choice >= 0, 'Choose a genuine native typed casualty option');
step = { actor: d.player, action: { type: 'decision', choice } };
} else step = nextHarassHomeworldStep(game);
assert.ok(step, 'Seal actual human plans before continuing');
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('Native battle programme did not reach the requested aftercare boundary');
}
export function revealNativeWorldBattle(fixture: NativeWorldBattleFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) {
game = applyAction(game, step.actor, step.action);
game = advanceNativeWorldBattle(game, g => harassHomeworldClean(g) && !!g.battle &&
(!g.battle.preparation && g.battle.preLeader?.closed !== false), [], { returns: fixture.returns });
}
assert.ok(game.battle?.revealed); return game;
}
export const nativeWorldBattleFinished = (g: Game) => !g.battle && !g.homeworldBattleLoss && harassHomeworldClean(g);
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { forceInventory } from '../game/force-inventory';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import {
createIndependentCardModulesFixture, type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
import {
createRicheseReinforcementsFixture, RICHESE_HARASS, RICHESE_REINFORCEMENTS,
type RicheseReinforcementsFixture, type RicheseReinforcementsOptions,
type RicheseReinforcementsStep,
} from './fixture-richese-reinforcements';
export type NativeWorldMarkerOptions = Omit<RicheseReinforcementsOptions, 'initial' | 'seatIds'>;
export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & {
/** Ready but undealt: the original initializer seam for controls/policies. */
readyLobby: Game;
readySteps: IndependentCardModulesStep[];
actors: { owner: string; opponent: string; observer: string };
source: {
homeworld: string;
reservesBeforeReveal: number;
materializes: number;
reservesAfterReveal: number;
feeChoices: NativeReserveSelections[] | undefined;
};
};
/** Genuine selected-family World programme. Only the original initializer's
* randomness is scoped. Basic low budgets come from two reveals, real Spice
* Blow collection and an ordinary paid shipment over four turns. Advanced uses
* its funded turn-one route, never a staged low budget or frozen later Storm.
* The inherited staging log explicitly labels conserved physical card moves;
* none of those moves is represented as a natural purchase. */
export function createNativeWorldMarkerFixture(
options: NativeWorldMarkerOptions = {},
): NativeWorldMarkerFixture {
const family = options.family ?? 'choam';
const selected = createIndependentCardModulesFixture({
advanced: options.advanced ?? false,
homeworlds: true,
tech: false,
strongholds: false,
expansions: family === 'choam' ? ['choam'] : ['ix', 'choam'],
factions: family === 'choam' ? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'],
});
const readyLobby = selected.initial;
const fixture = createRicheseReinforcementsFixture({ ...options, family, initial: readyLobby });
const view = viewGame(fixture.game, fixture.richese);
const homeworld = view.homeworlds?.worlds?.find(home => home.native === fixture.richese && !home.secondary);
const preview = view.battle!.reinforcements!;
assert.ok(homeworld && preview.noField);
return {
...fixture,
readyLobby,
readySteps: selected.steps.filter(step => step.action.type === 'ready'),
actors: { owner: fixture.richese, opponent: fixture.opponent, observer: fixture.observer },
source: {
homeworld: homeworld.id,
reservesBeforeReveal: homeworld.forces[fixture.richese].normal,
materializes: preview.noField.materializes,
reservesAfterReveal: preview.noField.reservesAfterReveal,
feeChoices: structuredClone(preview.homeworldSources),
},
};
}
/** Original public inventory counts native Worlds within reserves exactly once,
* keeps foreign visitors separate and preserves Ixian elite identity. */
export function nativeWorldMarkerInventory(game: Game, actor: string) {
const view = viewGame(game, actor);
const player = view.players.find(seat => seat.id === actor);
const worlds = view.homeworlds?.worlds;
assert.ok(player && worlds);
const inventory = forceInventory(player, worlds);
assert.ok(inventory, `Original native force inventory is inconsistent for ${actor}`);
return inventory;
}
/** Both independent machines may occupy either actual battle-plan slot. For a
* combined plan slot selects R's slot; H occupies the other. The fee allocation
* is the owner's real preview quote, never an inferred native source split. */
export function nativeWorldMarkerPlan(fixture: NativeWorldMarkerFixture,
mode: 'reinforcements' | 'both' | 'harass' | 'plain' = 'reinforcements',
slot: 'weapon' | 'defense' = 'weapon'): RicheseReinforcementsStep {
const step = structuredClone(fixture.plans.plain);
step.action.weapon = null;
step.action.defense = null;
if (mode === 'reinforcements' || mode === 'both') {
step.action[slot] = RICHESE_REINFORCEMENTS;
const sources = fixture.source.feeChoices?.[0];
if (sources) step.action.reinforcementsSources = structuredClone(sources);
}
if (mode === 'harass') step.action[slot] = RICHESE_HARASS;
if (mode === 'both') step.action[slot === 'weapon' ? 'defense' : 'weapon'] = RICHESE_HARASS;
return step;
}
+193
View File
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { forceRevivalQuote } from '../game/revival';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
export type NativeWorldRecruitsFaction = 'ixians' | 'tleilaxu' | 'choam' | 'richese';
export type NativeWorldRecruitsStep = { actor: string; action: Action };
export type NativeWorldRecruitsFixture = {
initial: Game;
afterSetup: Game;
beforeShipment: Game;
shipmentStep: NativeWorldRecruitsStep;
shipmentPrice: number;
afterShipment: Game;
beforeBattle: Game;
battleStep: NativeWorldRecruitsStep;
planSteps: NativeWorldRecruitsStep[];
afterBattle: Game;
/** Quiet original second-turn Revival, with the conserved card in owner's hand. */
game: Game;
owner: string;
native: string;
visitor: string;
location: string;
steps: NativeWorldRecruitsStep[];
staging: string[];
};
export function nativeWorldRecruitsPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing native seat ${actor}`);
return player;
}
export const nativeWorldRecruitsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Original native choices, never a population, wallet, phase or saved receipt edit.
* An unrequested Face Dancer is declined; every other window keeps its original
* continuation and the whole remaining programme is driven by it. */
export function nextNativeWorldRecruitsStep(game: Game): NativeWorldRecruitsStep | null {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextHarassHomeworldStep(game);
}
export function advanceNativeWorldRecruits(game: Game, until: (state: Game) => boolean,
steps: NativeWorldRecruitsStep[] = []): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Native revival programme must reach its requested boundary');
const step = nextNativeWorldRecruitsStep(game);
assert.ok(step, 'Supply actual human battle plans before continuing');
steps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('Native World Recruits programme did not reach its boundary');
}
/** Native World stock is already included in aggregate reserves; foreign World
* visitors are separate. Ix has seven physical cyborgs, not Emperor's five. */
export function assertNativeWorldRecruitsInventory(game: Game): void {
homeworldGameIntegrity(game);
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const player of game.players) {
const visitors = homes.filter(home => home.native !== player.id)
.map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0) +
visitors.reduce((sum, group) => sum + group.normal + group.elite, 0), 20, player.faction);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((a, b) => a + b, 0) +
visitors.reduce((sum, group) => sum + group.elite, 0), player.faction === 'ixians' ? 7 : 5);
}
}
/** An Emperor pays to invade the actual native World. Unarmed original leaders
* let the defender lose its whole home army without shipping it or spending its
* wallet. The optional all-Sardaukar dial creates two real Imperial casualties
* for the ordinary elite cap. No stock is fabricated/reset. */
export function createNativeWorldRecruitsFixture(options: {
advanced?: boolean;
faction?: NativeWorldRecruitsFaction;
imperialCasualties?: boolean;
} = {}): NativeWorldRecruitsFixture {
const native = options.faction ?? 'ixians';
const setup = createIndependentCardModulesFixture({ advanced: options.advanced ?? false,
homeworlds: true, tech: false, strongholds: false, factions: ['emperor', native, 'guild'],
expansions: native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam'] });
let game = setup.game;
const afterSetup = structuredClone(game), steps = [...setup.steps];
assert.equal(setup.initial.status, 'lobby');
assert.ok(setup.initial.homeworlds, 'The undealt ready lobby must have Homeworlds selected');
assert.deepEqual(game.expansions, native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam']);
assert.equal(game.status, 'playing');
for (const [name, value] of [['Tech Tokens', game.techTokens], ['Stronghold Cards', game.strongholdCards],
['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills],
['Discovery', game.discoveryEnabled]] as const)
assert.equal(value, undefined, `${name} must stay outside a native World Recruits programme`);
const visitor = 'emperor', owner = visitor;
const worlds = viewGame(game, native).homeworlds?.worlds;
assert.ok(worlds, 'A native World programme needs its projected custody view');
const home = worlds.find(w => w.native === native && !w.secondary);
assert.ok(home);
const location = home.id;
const staging = ['Return original setup hands to the unopened Treachery deck; no weapon or defense is used.'];
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
assert.ok(at >= 0);
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Position original unopened first-Blow non-worm cards; later Storm and all counters remain native.');
const act = (actor: string, action: Action) => {
steps.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
game = advanceNativeWorldRecruits(game,
g => g.phase === 5 && g.active === visitor && nativeWorldRecruitsClean(g), steps);
const beforeShipment = structuredClone(game);
const amount = options.imperialCasualties ? 2 : 1;
const sources = nativeShipmentSources(viewGame(game, visitor), amount, options.imperialCasualties ? 2 : 0);
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'Original typed invasion must be legal');
assert.equal(choice.cost, amount);
const shipmentStep = { actor: visitor, action: choice.action };
act(shipmentStep.actor, shipmentStep.action);
game = advanceNativeWorldRecruits(game,
g => !g.pendingHomeworldShipment && !g.pendingShipment && nativeWorldRecruitsClean(g), steps);
const afterShipment = structuredClone(game);
game = advanceNativeWorldRecruits(game, g => g.phase === 6 && nativeWorldRecruitsClean(g) && !!g.active, steps);
const beforeBattle = structuredClone(game);
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location);
assert.ok(battleChoice);
const battleStep: NativeWorldRecruitsStep = { actor: battleChoice.chooser,
action: { type: 'chooseBattle', territory: location,
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
act(battleStep.actor, battleStep.action);
const planSteps: NativeWorldRecruitsStep[] = [];
for (const actor of [visitor, native]) {
game = advanceNativeWorldRecruits(game, g => !!g.battle && nativeWorldRecruitsClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, steps);
const leaders = nativeWorldRecruitsPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = actor === visitor ? leaders.at(-1) : leaders[0];
assert.ok(leader);
const step: NativeWorldRecruitsStep = { actor,
action: { type: 'battlePlan', leader: leader.id,
dial: actor === visitor && options.imperialCasualties ? (game.advanced ? 4 : 2) : 0, support: 0 } };
planSteps.push(step); act(step.actor, step.action);
}
game = advanceNativeWorldRecruits(game,
g => !g.battle && !g.homeworldBattleLoss && nativeWorldRecruitsClean(g), steps);
const afterBattle = structuredClone(game);
assert.equal(nativeWorldRecruitsPlayer(game, native).tanks, native === 'ixians' ? 14 : 20);
game = advanceNativeWorldRecruits(game,
g => g.turn === 2 && g.phase === 4 && nativeWorldRecruitsClean(g), steps);
takeHarassCard(game, owner, RECRUITS_CARD_ID);
staging.push(`Conserve the existing physical Recruits to ${owner} at the real second Revival; this is not a purchase.`);
assertNativeWorldRecruitsInventory(game);
return { initial: setup.initial, afterSetup, beforeShipment, shipmentStep, shipmentPrice: choice.cost,
afterShipment, beforeBattle, battleStep, planSteps, afterBattle, game, owner, native, visitor,
location, steps, staging };
}
/** Capture the quote, accepted request, response/deployment choices and physical
* deposit. Keep invoice price separate from Tleilaxu's own free-return income. */
export function requestNativeWorldRecruits(game: Game, actor: string, amount: number, elite = 0,
freeElite?: number) {
const before = structuredClone(game);
const player = nativeWorldRecruitsPlayer(before, actor);
const quote = forceRevivalQuote(before, player, amount, elite, freeElite);
const step: NativeWorldRecruitsStep = { actor,
action: { type: 'revive', amount, elite, ...(freeElite === undefined ? {} : { freeElite }) } };
const nativeBefore = combatArmy(before, actor, `homeworld:${player.faction}`);
const requested = applyAction(game, actor, step.action);
const continuations: NativeWorldRecruitsStep[] = [];
game = advanceNativeWorldRecruits(requested, nativeWorldRecruitsClean, continuations);
const after = nativeWorldRecruitsPlayer(game, actor);
return { before, requested, game, step, continuations, quote,
rate: viewGame(before, actor).revival,
price: quote.cost, walletDelta: player.spice - after.spice,
free: (after.freeForcesRevived ?? 0) - (player.freeForcesRevived ?? 0),
nativeBefore, nativeAfter: combatArmy(game, actor, `homeworld:${player.faction}`) };
}
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, RuleError, viewGame } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import {
advanceNativeWorldBattle, assertNativeWorldBattleInventory, createNativeWorldBattleFixture,
HARASS_WITHDRAW_CARD, nativeWorldBattleFinished, nativeWorldBattlePlayer, nativeWorldBattlePool,
REINFORCEMENTS_CARD, revealNativeWorldBattle, type NativeWorldBattleStep,
} from './fixture-native-world-battle';
for (const advanced of [false, true]) {
const rules = advanced ? 'Advanced' : 'Basic';
void test(`${rules}: nine paid Suboids retain original 1N4E reserves; Reinforcements pays 1N2E before Harass returns`, () => {
const f = createNativeWorldBattleFixture({ advanced });
const shipment = f.shipments[0], original = nativeWorldBattlePlayer(shipment.before, f.owner);
const shipped = nativeWorldBattlePlayer(shipment.after, f.owner);
assert.equal(shipment.cost, 9);
assert.equal(shipped.spice, original.spice - 9);
assert.deepEqual(combatArmy(shipment.after, f.owner, f.location), { normal: 9, elite: 0 });
assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, 'homeworld:ixians'), { normal: 1, elite: 4 });
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
reinforcementsSources: { 'homeworld:ixians': { normal: 3, elite: 0 } } }), RuleError);
assert.deepEqual(f.game, before, 'Seven future returned Suboids cannot replace the two original Cyborg fees');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action,
reinforcementsSources: { [f.location]: { normal: 1, elite: 2 } } }));
assert.deepEqual(f.game, before, 'Foreign visitor stock is not a native fee source');
const revealed = revealNativeWorldBattle(f);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, ['tleilaxu']);
assert.throws(() => applyAction(revealed, f.owner, { type: 'traitorCall', call: false }));
const steps: NativeWorldBattleStep[] = [];
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished, steps, { returns: f.returns });
const p = nativeWorldBattlePlayer(done, f.owner);
assert.equal(done.lastBattleContext?.winner, 'tleilaxu');
assert.equal(p.reserves, 9, 'Original five minus fee three plus seven returned normals');
assert.equal(p.elites!.reserves, 2, 'Returned normals never undo the original two-Cyborg payment');
assert.equal(p.tanks, 5, 'Fee three plus only the two committed Suboids');
assert.equal(p.elites!.tanks, 2);
assert.deepEqual(nativeWorldBattlePool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 2 });
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.ok(!done.pendingIxSubstitution);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(done.discard.filter(c => c.id === id).length, 1);
assert.deepEqual(p.forces, nativeWorldBattlePlayer(f.afterSetup, f.owner).forces, 'Original HMS garrison stays physical');
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test(`${rules}: own Ix removes local fees from the physical dial, retains Cyborg/Suboid traits and forbids Harass`, () => {
const f = createNativeWorldBattleFixture({ advanced, route: 'ix-native', faceDancer: true });
assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, f.location), { normal: 1, elite: 4 });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }),
RuleError);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }),
RuleError);
assert.deepEqual(f.game, before);
const revealed = revealNativeWorldBattle(f), view = viewGame(revealed, f.owner).battle!;
assert.ok(view.ownForces);
assert.equal(view.ownForces.normal, 0);
assert.equal(view.ownForces.elite, 2, 'Two local Cyborg fee counters cannot also be dialed');
assert.equal(view.ownForces.normalFixedHalf, true);
assert.equal(view.ownForces.eliteStrength, 2);
assert.equal(view.ownForces.freeSupport, !advanced);
assert.deepEqual(view.traitorVoters, ['ixians']);
assert.throws(() => applyAction(revealed, 'guild', { type: 'traitorCall', call: false }));
const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished);
const p = nativeWorldBattlePlayer(done, f.owner);
assert.equal(done.lastBattleContext?.winner, f.owner);
assert.equal(p.tanks, advanced ? 5 : 4);
assert.equal(p.elites!.tanks, advanced ? 4 : 3, 'Fee two plus only this dial’s actual Cyborg casualties');
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: advanced ? 0 : 1 });
assert.deepEqual(combatArmy(done, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 },
'The separate real exported garrison is not a casualty or fee origin for this battle');
assert.ok(nativeWorldBattlePlayer(done, 'tleilaxu').faceDancers!.some(d =>
d.leader === f.planActions[0].action.leader && !d.revealed),
'A matching spectator Face Dancer cannot act on another faction’s World');
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test(`${rules}: paid typed invasion separates two fee Cyborgs from one battle Cyborg and resumes native Face Dance`, () => {
const f = createNativeWorldBattleFixture({ advanced, exportNormal: 9, exportElite: 1,
ownerDial: advanced ? 1 : 2, harass: false, outcome: 'win', faceDancer: true });
assert.equal(f.shipments[0].cost, 10);
assert.equal(nativeWorldBattlePlayer(f.shipments[0].after, f.owner).spice, 0);
assert.deepEqual(combatArmy(f.game, f.owner, f.location), { normal: 9, elite: 1 });
const steps: NativeWorldBattleStep[] = [];
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'ixSubstitution', steps,
{ casualties: { normal: 0, elite: 1 } });
const ix = nativeWorldBattlePlayer(game, f.owner);
assert.equal(ix.tanks, 4);
assert.equal(ix.elites!.tanks, 3, 'Two reserve-fee Cyborgs and exactly one actual board casualty');
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 0 });
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 3 },
recover: { [f.location]: 3 } }), RuleError);
assert.deepEqual(game, before, 'The fee cannot become two extra board-loss origins');
game = applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', steps);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 8, elite: 1 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2, 'Only the battle Cyborg was recovered');
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 4, 'One surviving Suboid replaces that Cyborg in Tanks');
assert.equal(game.decision?.player, 'tleilaxu');
assert.equal(game.decision?.kind === 'faceDance' && game.decision.territory, 'homeworld:tleilaxu');
const offer = viewGame(game, 'tleilaxu').decision;
assert.ok(offer?.kind === 'faceDance');
assert.equal(offer.blocked, undefined);
const dancer = nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === f.planActions[0].action.leader)!;
assert.ok(dancer && !dancer.revealed);
assertNativeWorldBattleInventory(game, f.afterSetup);
game = applyAction(game, 'tleilaxu', { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(nativeWorldBattlePool(game, f.owner, 'homeworld:ixians'), { normal: 8, elite: 2 },
'Eight Suboids and the one recovered Cyborg return to native Ix, joining its one remaining native Cyborg');
const returned = nativeWorldBattlePlayer(game, f.owner);
assert.equal(returned.reserves, 10);
assert.equal(returned.elites!.reserves, 2);
assert.equal(returned.tanks, 4);
assert.equal(returned.elites!.tanks, 2);
assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === dancer.leader)!.revealed);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
}
void test('Advanced: native CHOAM Reinforcements preserves the original Auditor response, payment and real inspection', () => {
const f = createNativeWorldBattleFixture({ advanced: true, route: 'choam-native' });
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'choamAudit');
assert.equal(game.decision?.player, 'choam');
assert.equal(game.lastBattleContext?.winner, 'choam');
assert.deepEqual(combatArmy(game, 'choam', f.location), { normal: 17, elite: 0 });
assert.equal(nativeWorldBattlePlayer(game, 'choam').tanks, 3);
const event = game.pendingAuditor!.event;
const held = structuredClone(nativeWorldBattlePlayer(game, 'guild').hand);
assert.equal(held.length, 1, 'One actual unused original card makes inspection deterministic without a replacement RNG');
game = applyAction(game, 'choam', { type: 'decision', event, audit: true });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'choamAuditPayment');
assert.equal(game.decision?.player, 'guild');
const before = structuredClone(game);
const count = viewGame(game, 'choam').auditor!.count;
assert.equal(count, 1);
const paid = applyAction(game, 'guild', { type: 'decision', event, pay: true, count });
assert.equal(nativeWorldBattlePlayer(paid, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice - 1);
assert.equal(nativeWorldBattlePlayer(paid, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice + 1);
assert.ok(!paid.auditorInsight, 'Payment cancels the inspection');
assertNativeWorldBattleInventory(paid, f.afterSetup);
game = applyAction(game, 'guild', { type: 'decision', event, pay: false });
assert.deepEqual(game.auditorInsight?.cards.map(c => c.id), held.map(c => c.id));
assert.equal(game.auditorInsight?.target, 'guild');
assert.deepEqual(nativeWorldBattlePlayer(game, 'guild').hand.map(c => c.id), held.map(c => c.id),
'Inspection transfers no physical card');
assert.equal(nativeWorldBattlePlayer(game, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice);
assert.equal(nativeWorldBattlePlayer(game, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice);
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
import {
createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan,
type NativeWorldMarkerFixture,
} from './fixture-native-world-marker';
import {
finishRicheseReinforcementsBattle, prepareRicheseReinforcementsPlan,
RICHESE_HARASS, RICHESE_REINFORCEMENTS, richeseReinforcementsPlayer,
sealRicheseReinforcementsPlan,
} from './fixture-richese-reinforcements';
function physicalCards(game: Game) {
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
}
function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) {
const home = viewGame(game, fixture.richese).homeworlds?.worlds
?.find(world => world.id === fixture.source.homeworld);
assert.ok(home);
return home.forces[fixture.richese];
}
function assertConserved(fixture: NativeWorldMarkerFixture, game: Game) {
assert.deepEqual(physicalCards(game), physicalCards(fixture.initial),
'Every original selected-deck/cache face retains one physical identity.');
for (const player of game.players) {
const before = nativeWorldMarkerInventory(fixture.afterSetup, player.id);
const after = nativeWorldMarkerInventory(game, player.id);
assert.deepEqual([after.total, after.normal, after.elite], [before.total, before.normal, before.elite],
`${player.faction}'s native reserves count once; special counters remain their own physical type.`);
assert.equal(after.total, 20);
if (player.faction === 'ixians') assert.equal(after.elite, 7);
assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4));
}
const original = richeseReinforcementsPlayer(fixture.afterSetup, fixture.richese).noField!;
const current = richeseReinforcementsPlayer(game, fixture.richese).noField!;
assert.deepEqual(current.tokens, original.tokens, 'No token is minted, replaced or relabeled.');
assert.equal(current.lastShipped, fixture.token.id, 'Reveal preserves the last real shipment identity.');
}
function assertSpent(game: Game, card: string) {
assert.equal(game.discard.filter(face => face.id === card).length, 1);
assert.ok(game.players.every(player => player.hand.every(face => face.id !== card)));
assert.ok(game.deck.every(face => face.id !== card));
}
/** Preview and the first seal must not perform the eventual native reveal. */
function assertStillConcealed(fixture: NativeWorldMarkerFixture, game: Game) {
const owner = richeseReinforcementsPlayer(game, fixture.richese);
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
assert.equal(game.battle!.revealed, false);
assert.deepEqual(owner.noField, before.noField);
assert.equal(owner.noFieldEvent, before.noFieldEvent);
assert.equal(owner.reserves, fixture.source.reservesBeforeReveal);
assert.deepEqual(primaryPool(game, fixture), { normal: owner.reserves, elite: 0 });
assert.equal(owner.forces[fixture.location] ?? 0, 0, 'A concealed marker is not a physical counter.');
assert.equal(owner.tanks, before.tanks);
}
for (const family of ['choam', 'ix+choam'] as const) {
// Opposite representative slots cover both paths without a Cartesian matrix.
const slots = [family === 'choam' ? 'weapon' : 'defense'] as const;
for (const slot of slots)
void test(`${family} World marker three funds R in the ${slot} slot only after its real primary reserve debit`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 });
const original = structuredClone(fixture.game);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.deepEqual(fixture.game, original, 'The owner forecast does not materialize forces or update history.');
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 3 });
assertStillConcealed(fixture, fixture.game);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
assert.equal(revealed.battle!.revealed, true);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 });
assert.equal(owner.reserves, 3, 'Materialization spends the same native primary counters once.');
assert.equal(owner.forces[fixture.location], 3);
assert.equal(owner.tanks, 0, 'The provisional fee settles at the actual result, not at first seal.');
assert.equal(owner.noField!.deployed, null);
assertConserved(fixture, revealed);
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
assert.deepEqual(primaryPool(done, fixture), { normal: 0, elite: 0 });
assert.equal(after.reserves, 0);
assert.equal(after.tanks, 6, 'Three materialized casualties plus the three-reserve fee, not nine counters.');
assert.equal(after.battleLosses, 6);
assert.equal(after.forces[fixture.location] ?? 0, 0);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [marker, reserves, materializes] of [[0, 6, 0], [5, 8, 5]] as const)
void test(`${family} World funded marker ${marker} keeps its physical token and pays from the remaining native reserves`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker, reserves });
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, materializes);
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, reserves - materializes - 3);
assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 });
assert.equal(after.tanks, materializes + 3);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [marker, reserves, materializes] of [[3, 5, 3], [5, 3, 3]] as const)
for (const slot of slots)
void test(`${family} World marker ${marker}/${reserves} refuses short R in ${slot} unchanged, without borrowing a future H return`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker, reserves });
const before = structuredClone(fixture.game);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.ok(preview.blocked);
assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes });
assert.equal(preview.normal + preview.elite, reserves - materializes);
assert.equal(viewGame(fixture.game, fixture.richese).battle!.harassWithdraw!.blocked, null);
assert.deepEqual(fixture.game, before);
// This exact original marker-three/reserves-five native position used
// to accept the raw first seal even though its owner preview refused it.
const rawR = nativeWorldMarkerPlan(fixture, 'reinforcements', slot);
delete rawR.action.reinforcementsSources;
for (const plan of [rawR, nativeWorldMarkerPlan(fixture, 'both', slot)]) {
assert.throws(() => applyAction(fixture.game, plan.actor, plan.action), RuleError);
assert.deepEqual(fixture.game, before, 'Rejection changes no wallet, cards, token, history, counters or windows.');
assert.deepEqual(fixture.game.battle!.plans, {});
}
assertStillConcealed(fixture, fixture.game);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'harass', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location], materializes,
'A five marker capped at three reserves reveals three, not five.');
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, reserves - materializes + 1);
assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 },
'The real undialed counter deposits in the native primary World, not a foreign visitor pool.');
assert.equal(after.tanks, 2, 'Only the dialed counters die under H-only fallback.');
assertSpent(done, RICHESE_HARASS);
assert.ok(after.hand.some(card => card.id === RICHESE_REINFORCEMENTS), 'The rejected R remains unspent.');
assertConserved(fixture, done);
});
for (const slot of slots)
void test(`${family} World R+H in ${slot} charges native reserves before depositing the real undialed survivor`, () => {
const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.deepEqual(primaryPool(done, fixture), { normal: 1, elite: 0 });
assert.equal(after.reserves, 1, 'Three pay first; the one real undialed survivor returns afterwards.');
assert.equal(after.tanks, 5);
assert.equal(after.battleLosses, 5);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
for (const marker of [0, 3, 5] as const)
for (const slot of slots)
void test(`${family} Advanced World marker ${marker} pays R from its funded turn-one primary World in ${slot}`, () => {
const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker });
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.deepEqual(preview.noField, { materializes: marker, reservesAfterReveal: 20 - marker });
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot));
assertStillConcealed(fixture, first);
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, marker);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 20 - marker, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, 17 - marker);
assert.deepEqual(primaryPool(done, fixture), { normal: 17 - marker, elite: 0 });
assert.equal(after.tanks, marker + 3);
assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support),
'The original physical Advanced support is still paid, not increased by two score strength.');
assertSpent(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
void test(`${family} Advanced World marker five settles R then deposits four H survivors with original support`, () => {
const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker: 5 });
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both'));
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.deepEqual(primaryPool(revealed, fixture), { normal: 15, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, 16, 'Fifteen minus the fee plus four actual undialed survivors.');
assert.deepEqual(primaryPool(done, fixture), { normal: 16, elite: 0 });
assert.equal(after.tanks, 4);
assert.equal(after.spice, before.spice - 1);
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
}
+229
View File
@@ -0,0 +1,229 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Action, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture,
nativeWorldRecruitsClean, nativeWorldRecruitsPlayer, requestNativeWorldRecruits,
} from './fixture-native-world-recruits';
const play = { type: 'card', card: RECRUITS_CARD_ID } as const;
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action), RuleError);
assert.equal(JSON.stringify(game), before);
}
function continueToShipment(game: Game): Game {
const next = advanceNativeWorldRecruits(game, g => g.turn === 2 && g.phase === 5 && nativeWorldRecruitsClean(g));
assert.ok(next.active, 'Original next Shipment must have its actual active seat');
assertNativeWorldRecruitsInventory(next);
return next;
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} native Ix deposits paid cyborgs and free suboids, reopens with Recruits, then reprices after population five`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
const native = fixture.native;
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10);
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).tanks, 14);
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).elites!.tanks, 4);
assert.deepEqual(combatArmy(fixture.game, fixture.visitor, fixture.location), { normal: 1, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(fixture.afterShipment, fixture.visitor).spice,
nativeWorldRecruitsPlayer(fixture.beforeShipment, fixture.visitor).spice - 1);
const paid = requestNativeWorldRecruits(fixture.game, native, 3, 1, 0);
assert.equal(paid.price, 3, 'Two free suboids leave one cyborg at its original three-spice price');
assert.equal(paid.walletDelta, 3);
assert.equal(paid.free, 2);
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
let game = applyAction(paid.game, native, { type: 'ready' });
game = applyAction(game, fixture.owner, play);
assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg');
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
assert.equal(crossing.price, 0);
assert.equal(crossing.free, 2);
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
game = crossing.game;
const rate = viewGame(game, native).revival;
assert.equal(rate.homeworldBonus, 0);
assert.equal(rate.freeRemaining, 0, 'A separate request uses the now-high native World, not the original four-free rate');
assert.equal(rate.eliteRemaining, 1, 'Ix retains its native multiple-cyborg allowance in both modes');
rejectsUnchanged(game, native, { type: 'revive', amount: 2, elite: 2 });
const last = requestNativeWorldRecruits(game, native, 2, 1, 0);
assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five');
assert.equal(last.walletDelta, 5);
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 7);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0);
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
continueToShipment(last.game);
});
void test(`${mode} the real mixed Ix Tanks retain the player's free-cyborg allocation and its distinct prices`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
const active = applyAction(fixture.game, fixture.owner, play);
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 8, elite: 3, freeElite: 0 });
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 7, elite: 3, freeElite: 4 });
for (const freeElite of [0, 3]) {
const returned = requestNativeWorldRecruits(structuredClone(active), fixture.native, 7, 3, freeElite);
assert.equal(returned.free, 4);
assert.equal(returned.price, freeElite === 0 ? 9 : 6);
assert.equal(returned.walletDelta, freeElite === 0 ? 9 : 6);
assert.deepEqual(returned.nativeAfter, { normal: 4, elite: 3 });
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).elites!.tanks, 1);
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 7);
assertNativeWorldRecruitsInventory(returned.game);
continueToShipment(returned.game);
}
});
void test(`${mode} native Tleilaxu accepts eleven in one current-rate request without the ordinary seven cap`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' });
let game = applyAction(fixture.game, fixture.owner, play);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5);
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 12, elite: 0 });
const returned = requestNativeWorldRecruits(game, fixture.native, 11);
assert.equal(returned.price, 5, 'Six free and five half-price paid forces retain their accepted invoice');
assert.equal(returned.free, 6);
assert.equal(returned.walletDelta, 4, 'Original own free-revival income pays one spice separately');
game = returned.game;
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 11);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 6);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).tanks, 9);
assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 });
assert.equal(viewGame(game, fixture.native).revival.limit, 20);
assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 9);
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0);
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
continueToShipment(game);
});
void test(`${mode} paid Tleilaxu returns are not refunded and a separate crossing request loses the low-World free bonus`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' });
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8);
assert.equal(first.price, 5);
assert.equal(first.free, 3);
assert.equal(first.walletDelta, 4);
let game = applyAction(first.game, fixture.owner, play);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1);
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
const crossing = requestNativeWorldRecruits(game, fixture.native, 1);
assert.equal(crossing.free, 1);
assert.equal(crossing.price, 0);
assert.equal(crossing.walletDelta, 0, 'Own free-revival income is not awarded a second time');
game = crossing.game;
assert.deepEqual(crossing.nativeAfter, { normal: 9, elite: 0 });
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0);
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 4);
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 2, elite: 0 });
const paid = requestNativeWorldRecruits(game, fixture.native, 1);
assert.equal(paid.price, 1);
assert.equal(paid.walletDelta, 1);
assert.equal(paid.free, 0);
assert.deepEqual(paid.nativeAfter, { normal: 10, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 4);
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, 0);
continueToShipment(paid.game);
});
void test(`${mode} native CHOAM keeps unlimited one-spice returns and prior paid/free ledgers after Recruits`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' });
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice, 15, 'Real CHOAM phase income funds the programme');
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8);
assert.equal(first.price, 7);
assert.equal(first.walletDelta, 7);
assert.equal(first.free, 1);
let game = applyAction(first.game, fixture.owner, play);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 8);
assert.equal(viewGame(game, fixture.native).revival.limit, 20);
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 1);
const free = requestNativeWorldRecruits(game, fixture.native, 1);
assert.equal(free.price, 0);
assert.equal(free.free, 1);
const paid = requestNativeWorldRecruits(free.game, fixture.native, 3);
assert.equal(paid.price, 3);
assert.equal(paid.walletDelta, 3);
assert.equal(paid.free, 0);
game = paid.game;
assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 2);
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 12);
assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 8);
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 6, elite: 0 });
continueToShipment(game);
});
void test(`${mode} native CHOAM can actually return seventeen with Recruits rather than merely quoting unlimited`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' });
const active = applyAction(fixture.game, fixture.owner, play);
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 18, elite: 0 });
const returned = requestNativeWorldRecruits(active, fixture.native, 17);
assert.equal(returned.free, 2);
assert.equal(returned.price, 15);
assert.equal(returned.walletDelta, 15);
assert.deepEqual(returned.nativeAfter, { normal: 17, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).spice, 0);
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 3);
assert.equal(viewGame(returned.game, fixture.native).revival.forcesRemaining, 3);
continueToShipment(returned.game);
});
void test(`${mode} ordinary native Richese reopens a ready revival, receives six free plus one paid, and still stops at seven`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' });
const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice;
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3);
assert.equal(first.price, 0);
let game = applyAction(first.game, fixture.native, { type: 'ready' });
game = applyAction(game, fixture.owner, play);
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 });
const free = requestNativeWorldRecruits(game, fixture.native, 3);
assert.equal(free.price, 0);
assert.equal(free.free, 3);
const paid = requestNativeWorldRecruits(free.game, fixture.native, 1);
assert.equal(paid.price, 2);
assert.equal(paid.walletDelta, 2);
assert.equal(paid.free, 0);
assert.deepEqual(paid.nativeAfter, { normal: 7, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 6);
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).tanks, 13);
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, spice - 2);
rejectsUnchanged(paid.game, fixture.native, { type: 'revive', amount: 1, elite: 0 });
continueToShipment(paid.game);
});
void test(`${mode} actual World battle Sardaukar casualties retain the ordinary elite cap despite Recruits`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians', imperialCasualties: true });
assert.equal(fixture.shipmentPrice, 2);
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).tanks, 2);
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).elites!.tanks, 2);
let game = applyAction(fixture.game, fixture.owner, play);
if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 2, elite: 2 });
const returned = requestNativeWorldRecruits(game, fixture.visitor, advanced ? 1 : 2, advanced ? 1 : 2);
assert.equal(returned.price, 0);
assert.equal(returned.walletDelta, 0);
game = returned.game;
const after = nativeWorldRecruitsPlayer(game, fixture.visitor);
assert.equal(after.elites!.tanks, advanced ? 1 : 0);
assert.equal(after.elites!.reserves, advanced ? 4 : 5);
const worlds = viewGame(game, fixture.visitor).homeworlds?.worlds;
assert.ok(worlds, 'The visitor needs its native World custody view');
const destination = worlds.find(w => w.native === fixture.visitor && w.secondary === advanced);
assert.ok(destination);
assert.equal((destination.forces[fixture.visitor] ?? { normal: 0, elite: 0 }).elite, advanced ? 4 : 5);
if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 1, elite: 1 });
continueToShipment(game);
});
}