Connect clean native family Reinforcements battles

This commit is contained in:
Quintine committed 2026-10-16 14:22:50 +11:00
1 parent c64c08af89
commit 4313983a5c
14 files changed
+1306 -44

No files matched your search

@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { MOBILE_LOCATION, territory } from '../game/board';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NativeFamilyReinforcementsStep = { actor: string; action: Action };
export type NativeFamilyReinforcementsOptions = {
faction?: 'ixians' | 'tleilaxu' | 'choam';
advanced?: boolean;
expansions?: ('ix' | 'choam')[];
slot?: 'weapon' | 'defense';
outcome?: 'winner' | 'loser' | 'traitor' | 'explosion';
/** Nine legally paid suboids leave one suboid and four cyborgs in reserve. */
mixedFee?: boolean;
initial?: Game;
seatIds?: [string, string];
};
export type NativeFamilyReinforcementsFixture = {
initial: Game;
afterSetup: Game;
beforeBattle: Game;
game: Game;
owner: string;
opponent: string;
territory: string;
location: string;
shipments: { before: Game; step: NativeFamilyReinforcementsStep; after: Game }[];
battleStep: NativeFamilyReinforcementsStep;
planActions: [NativeFamilyReinforcementsStep, NativeFamilyReinforcementsStep];
nativeSteps: NativeFamilyReinforcementsStep[];
staging: string[];
fee: { normal: number; elite: number };
};
export function nativeFamilyReinforcementsPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const nativeFamilyReinforcementsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original native controls are consumed, not removed: HMS placement, Face
* Dancers, cyborg substitution and Auditor inspection remain ordinary decisions. */
export function nextNativeFamilyReinforcementsStep(game: Game): NativeFamilyReinforcementsStep | null {
if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextStrongholdFactionsNativeStep(game);
}
export function advanceNativeFamilyReinforcements(state: Game, until: (game: Game) => boolean,
actions: NativeFamilyReinforcementsStep[] = []): Game {
let game = state;
for (let n = 0; n < 600; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextNativeFamilyReinforcementsStep(game);
assert.ok(step, 'Seal the actual human Battle Plans before continuing');
actions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('Native family programme did not reach its requested boundary');
}
function initialize(game: Game): Game {
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 initializeEcazTreacheryGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function exchangeTraitor(game: Game, actor: string, leader: string, staging: string[]) {
const player = nativeFamilyReinforcementsPlayer(game, actor);
if (player.traitors.includes(leader)) return;
const returned = player.traitors[0]; assert.ok(returned);
const at = game.traitorReserve!.indexOf(leader);
const donor = game.players.find(p => p.traitors.includes(leader));
const dancer = game.players.flatMap(p => p.faceDancers ?? []).find(c => c.leader === leader && !c.revealed);
assert.ok(at >= 0 || donor || dancer);
if (at >= 0) game.traitorReserve![at] = returned;
else if (donor) donor.traitors[donor.traitors.indexOf(leader)] = returned;
else dancer!.leader = returned;
player.traitors[0] = leader;
staging.push(`Conserved original Traitor ${leader} exchanged for ${returned} into ${actor}'s hand.`);
}
/** Fresh selected-family lobby → independent-card initializer → every original
* setup choice → real phase readiness → paid native shipments → chooseBattle.
* Only conserved hands/Traitors and unopened first-Blow card positions are
* staged. No force, wallet, phase, history or preview marker is assigned. */
export function createNativeFamilyReinforcementsFixture(
options: NativeFamilyReinforcementsOptions = {},
): NativeFamilyReinforcementsFixture {
const faction = options.faction ?? 'ixians', advanced = options.advanced ?? options.initial?.advanced ?? false;
const expansions = options.expansions ?? options.initial?.expansions ?? [faction === 'choam' ? 'choam' : 'ix'];
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam'));
assert.equal(new Set(expansions).size, expansions.length);
assert.ok(expansions.includes(faction === 'choam' ? 'choam' : 'ix'));
assert.ok(!options.mixedFee || faction === 'ixians');
const ids = options.seatIds ?? [faction, 'guild'];
let game = options.initial ? structuredClone(options.initial) :
createGame('NATIVEFAMILYREINFORCEMENTS', newPlayer(ids[0], faction, faction), advanced, expansions);
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
assert.equal(game.status, 'lobby', 'Supply the original undealt native lobby');
assert.equal(game.advanced, advanced);
assert.deepEqual([...game.expansions].sort(), [...expansions].sort());
assert.deepEqual(game.players.map(p => p.faction).sort(), [faction, 'guild'].sort());
assert.ok(!game.ecazTreachery && !game.homeworlds && !game.nexusCards && !game.leaderSkills &&
!game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.discoveries);
const owner = game.players.find(p => p.faction === faction)!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const nativeSteps: NativeFamilyReinforcementsStep[] = [];
const act = (step: NativeFamilyReinforcementsStep) => {
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
};
for (const p of game.players) if (!p.ready) act({ actor: p.id, action: { type: 'ready' } });
const initial = structuredClone(game);
// This initializer selects independent cards before native setup and deals
// exactly the selected physical inventory; never retrofit a module flag.
game = initialize(game);
game = advanceNativeFamilyReinforcements(game, g => g.status === 'playing', nativeSteps);
const afterSetup = structuredClone(game);
const staging = ['Independent cards selected by the original initializer before native setup; original HMS army, Face Dancers and Advanced Auditor retained.'];
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 unopened original non-worm first-Blow cards; no phase or draw is assigned.');
const reach = (until: (g: Game) => boolean) => {
game = advanceNativeFamilyReinforcements(game, until, nativeSteps);
};
const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
const shipments: NativeFamilyReinforcementsFixture['shipments'] = [];
const waiting = new Set([owner, opponent]);
reach(g => g.phase === 5 && nativeFamilyReinforcementsClean(g));
while (waiting.size) {
const actor = game.active!; assert.ok(waiting.has(actor));
const amount = actor === owner && options.mixedFee ? 9 : 3;
const elite = actor === owner && faction === 'ixians' && !options.mixedFee ? 2 : 0;
const step: NativeFamilyReinforcementsStep = { actor, action: {
type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
} };
const before = structuredClone(game); act(step); reach(nativeFamilyReinforcementsClean);
shipments.push({ before, step, after: structuredClone(game) });
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
reach(g => (g.phase === 5 || g.phase === 6) && nativeFamilyReinforcementsClean(g));
}
reach(g => g.phase === 6 && nativeFamilyReinforcementsClean(g));
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const take = (actor: string, id: string) => {
const at = game.deck.findIndex(c => c.id === id); assert.ok(at >= 0);
const player = nativeFamilyReinforcementsPlayer(game, actor);
assert.ok(player.hand.length < (player.faction === 'choam' ? 5 : 4));
player.hand.push(game.deck.splice(at, 1)[0]); return id;
};
take(owner, REINFORCEMENTS_CARD);
if (faction === 'choam' && advanced) {
take(opponent, game.deck.find(c => c.kind === 'projectile')!.id);
staging.push('Conserve one unused opposing projectile card so the original Advanced Auditor inspection control has a real target.');
}
const outcome = options.outcome ?? 'winner';
let laser: string | null = null, shield: string | null = null;
if (outcome === 'explosion') {
laser = take(opponent, game.deck.find(c => c.kind === 'lasgun')!.id);
shield = take(opponent, game.deck.find(c => c.kind === 'shield')!.id);
}
staging.push('Return original hands to undealt stock and conserve physical Reinforcements (plus opposing Lasgun/Shield only for explosion) into legal hands.');
const own = nativeFamilyReinforcementsPlayer(game, owner);
const ownLeader = faction === 'ixians' ? own.leaders.find(l => l.strength === (options.mixedFee ? 4 : 1))!
: faction === 'choam' && advanced ? own.leaders.find(isAuditorLeader)!
: own.leaders.find(l => l.strength === 2)!;
const otherLeader = nativeFamilyReinforcementsPlayer(game, opponent).leaders.find(l => l.strength === 5)!;
assert.ok(ownLeader && otherLeader);
if (faction === 'tleilaxu' && outcome === 'loser' &&
!own.faceDancers!.some(c => c.leader === otherLeader.id)) {
const dancer = own.faceDancers![0], returned = dancer.leader;
const at = game.traitorReserve!.indexOf(otherLeader.id);
const donor = game.players.find(p => p.traitors.includes(otherLeader.id));
assert.ok(at >= 0 || donor);
if (at >= 0) game.traitorReserve![at] = returned;
else donor!.traitors[donor!.traitors.indexOf(otherLeader.id)] = returned;
dancer.leader = otherLeader.id;
staging.push(`Conserved original Face Dancer ${otherLeader.id} exchanged for ${returned}; the actual post-victory native window remains available.`);
}
if (outcome === 'traitor') exchangeTraitor(game, opponent, ownLeader.id, staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === kind); assert.ok(choice);
const battleStep: NativeFamilyReinforcementsStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
} };
act(battleStep);
reach(g => !!g.battle && nativeFamilyReinforcementsClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const slot = options.slot ?? 'weapon';
const dial = faction === 'ixians' ? options.mixedFee ? 0.5 : 2.5 : 2;
const support = advanced ? faction === 'ixians' ? options.mixedFee ? 0 : 1 : 2 : 0;
const planActions: NativeFamilyReinforcementsFixture['planActions'] = [
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : null,
defense: slot === 'defense' ? REINFORCEMENTS_CARD : null } },
{ actor: opponent, action: { type: 'battlePlan', dial: outcome === 'loser' ? 3 : 0,
support: outcome === 'loser' && advanced ? 3 : 0, leader: otherLeader.id,
weapon: laser, defense: shield } },
];
const normal = Math.min(3, own.reserves - (own.elites?.reserves ?? 0));
const fee = { normal, elite: 3 - normal };
assertNativeFamilyReinforcementsInventory(game);
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: kind, location,
shipments, battleStep, planActions, nativeSteps, staging, fee };
}
export function sealNativeFamilyReinforcementsPlan(state: Game, step: NativeFamilyReinforcementsStep): Game {
return advanceNativeFamilyReinforcements(applyAction(state, step.actor, step.action), g =>
!g.response && !g.phaseOpening && (!g.decision || g.decision.kind !== 'fullPlanOffer'));
}
export function revealNativeFamilyReinforcementsBattle(fixture: NativeFamilyReinforcementsFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) game = sealNativeFamilyReinforcementsPlan(game, step);
assert.ok(game.battle?.revealed); return game;
}
export function finishNativeFamilyReinforcementsBattle(state: Game, options: {
callers?: string[];
actions?: NativeFamilyReinforcementsStep[];
} = {}): Game {
let game = state;
for (let n = 0; n < 240; n++) {
if (!game.battle && nativeFamilyReinforcementsClean(game)) return game;
const step = nextNativeFamilyReinforcementsStep(game); assert.ok(step);
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
options.actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('Native Reinforcements aftermath did not finish');
}
export function assertNativeFamilyReinforcementsInventory(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(),
[...treacheryDeck(game.expansions), ...ecazTreacheryCards()].map(c => c.id).sort());
assert.equal(cards.length, game.expansions.includes('ix') ? 50 : 38);
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p =>
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader)])].sort(),
traitorDeck(game.players, game.expansions.includes('ix')).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
assert.ok(p.hand.length <= (p.faction === 'choam' ? 5 : 4));
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
if (p.faction === 'ixians') {
assert.equal(p.forces[MOBILE_LOCATION], 6, 'Original HMS army is never repositioned by the fixture');
assert.equal(p.elites!.forces[MOBILE_LOCATION], 3);
}
if (p.faction === 'tleilaxu') assert.equal(p.faceDancers!.length, 3);
if (p.faction === 'choam' && game.advanced) assert.ok(p.leaders.some(isAuditorLeader));
}
}
+510
View File
@@ -0,0 +1,510 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import type { NoFieldValue } from '../game/richese-no-field';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export const RICHESE_REINFORCEMENTS = 'ecaz-reinforcements';
export const RICHESE_HARASS = 'ecaz-harass-withdraw';
export const RICHESE_STONE_BURNER = 'richese-stone-burner';
export const RICHESE_MIRROR_WEAPON = 'richese-mirror-weapon';
/** Independent battle machines a caller may prepare in the machine user's hand. */
export type RicheseMachine = 'reinforcements' | 'harass' | 'stone' | 'mirror';
/** Clean selected native families; classic opponents come from the base set. */
export type RicheseFamily = 'choam' | 'ix+choam';
export type RicheseReinforcementsStep = { actor: string; action: Action };
/**
* This programme parks its first token on the initializer's opening Spice Blow.
* Actual collection funds its later tuning shipment without granting bank spice.
*/
export const RICHESE_BLOW = { territory: 'broken_land', sector: 12 } as const;
/** Empty strongholds: cheapest to ship into and free of any starting army. */
export const RICHESE_CITIES = [
{ territory: 'carthag', sector: 11 },
{ territory: 'sietch_tabr', sector: 14 },
{ territory: 'habbanya_ridge_sietch', sector: 17 },
] as const;
/** The untouched starting armies a real attack can meet. */
export const RICHESE_ARMIES = [
{ faction: 'atreides', territory: 'arrakeen', sector: 10 },
{ faction: 'guild', territory: 'tueks_sietch', sector: 5 },
] as const;
export type RicheseReinforcementsOptions = {
advanced?: boolean;
family?: RicheseFamily;
/**
* Natural multi-turn route. 'tuning' is the deterministic Basic route: two
* real No-Field reveals plus one real tuning shipment reach a low reserve
* budget off the first Spice Blow's collection. 'immediate' is the turn-one
* route used for Advanced, where the original storm card makes later turns
* nondeterministic: it ships the final token straight from twenty reserves.
*/
route?: 'tuning' | 'immediate';
/** The final concealed token shipped into the actual battle. */
marker?: NoFieldValue;
/** Own physical reserves the 'tuning' route must leave before that shipment. */
reserves?: number;
/** Own counters dialed at the final battle; derived from the physical army. */
dial?: number;
opponentDial?: number;
/** Independent machines positioned in the machine user's hand after shipment. */
machines?: readonly RicheseMachine[];
/** Conserve the real Richese Stone Burner into the opponent's hand for Mirror Weapon. */
opponentStone?: boolean;
seatIds?: [string, string, string];
/** Continue one fresh original initializer snapshot; never a redeal. */
initial?: Game;
};
export type RicheseReinforcementsFixture = {
initial: Game;
afterSetup: Game;
/** Immediately before Richese declares the final concealed No-Field shipment. */
beforeShipment: Game;
shipmentStep: RicheseReinforcementsStep;
afterShipment: Game;
/** Immediately before the actual ordinary battle admission. */
beforeBattle: Game;
battleStep: RicheseReinforcementsStep;
/** Open original battle: native preparation complete, neither plan sealed. */
game: Game;
richese: string;
opponent: string;
observer: string;
family: RicheseFamily;
advanced: boolean;
route: 'tuning' | 'immediate';
/** Stronghold the final concealed token was shipped into. */
location: string;
/** Empty stronghold the interleaved reveal/tuning shipments used, if any. */
city: string | null;
token: { id: string; value: NoFieldValue };
/** Capped physical counters the concealed marker materializes on reveal. */
materializes: number;
/** Own counters shipped into the empty stronghold on the tuning turn. */
tuning: number;
/** Each seat's own physical reserves immediately before the final shipment. */
reservesBefore: Record<string, number>;
machines: RicheseMachine[];
/** Physical machine faces positioned into the machine user's hand. */
hand: Partial<Record<RicheseMachine, string>>;
plans: {
both: RicheseReinforcementsStep;
reinforcements: RicheseReinforcementsStep;
harass: RicheseReinforcementsStep;
plain: RicheseReinforcementsStep;
opponent: RicheseReinforcementsStep;
};
/** Every real native action the programme took, in order. */
nativeSteps: RicheseReinforcementsStep[];
/** Every conserved card exchange the programme staged, in order. */
staging: string[];
};
export function richeseReinforcementsPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
}
export function richeseReinforcementsSeat(game: Game, faction: FactionId) {
const player = game.players.find(p => p.faction === faction);
assert.ok(player, `Missing native ${faction} seat`);
return player;
}
export const richeseReinforcementsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision;
/** Only consumes live native windows; a null result with a live battle means a
* human must seal a plan. The optional Stone mode never invents a decision. */
export function nextRicheseReinforcementsStep(
game: Game,
stoneMode: 'kill' | 'ignore' = 'ignore',
): RicheseReinforcementsStep | null {
const step = nextRicheseStrongholdsNativeStep(game);
if (step && game.decision?.kind === 'stoneBurner' &&
step.action.type === 'decision' && step.action.mode !== undefined)
return { actor: step.actor, action: { ...step.action, mode: stoneMode } };
return step;
}
/** Determinism is scoped to the original native initializer only. */
function initialize(game: Game): Game {
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 initializeEcazTreacheryGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Richese cache faces must never enter the general deck; every other original
* face may wait there. Keeps a real hand inside its printed limit. */
function makeRoom(game: Game, actor: string, staging: string[],
protect: readonly string[] = []): void {
const player = richeseReinforcementsPlayer(game, actor);
const limit = player.faction === 'harkonnen' ? 8 : 4;
while (player.hand.length >= limit) {
const index = player.hand.findIndex(card =>
!card.id.startsWith('richese-') && !protect.includes(card.id));
assert.ok(index >= 0, `${actor}'s conserved cache faces leave no deckable hand room`);
const [card] = player.hand.splice(index, 1);
game.deck.push(card);
staging.push(`Conserved ${card.id}: ${actor}'s hand → remaining deck to keep the printed hand limit`);
}
}
/** Move one conserved physical face from wherever it still lives. No face is
* ever minted, purchased or duplicated. */
function positionCard(game: Game, actor: string, cardId: string, staging: string[],
protect: readonly string[] = []): void {
const player = richeseReinforcementsPlayer(game, actor);
if (player.hand.some(card => card.id === cardId)) return;
const zones: { label: string; zone: Card[] }[] = [
{ label: 'remaining deck', zone: game.deck },
{ label: 'Richese cache', zone: game.richeseCache ?? [] },
{ label: 'discard pile', zone: game.discard },
];
for (const { label, zone } of zones) {
const index = zone.findIndex(card => card.id === cardId);
if (index < 0) continue;
makeRoom(game, actor, staging, protect);
player.hand.push(zone.splice(index, 1)[0]);
staging.push(`Conserved ${cardId}: ${label} → ${actor}'s hand (no purchase, payment or replacement)`);
return;
}
assert.fail(`Conserved physical ${cardId} is missing for ${actor}`);
}
/** The conserved Stone Burner may be held by the real cache acquisition first. */
function transferCard(game: Game, actor: string, cardId: string, staging: string[],
protect: readonly string[] = []): void {
const holder = game.players.find(player => player.hand.some(card => card.id === cardId));
if (!holder) { positionCard(game, actor, cardId, staging, protect); return; }
if (holder.id === actor) return;
const index = holder.hand.findIndex(card => card.id === cardId);
const [card] = holder.hand.splice(index, 1);
makeRoom(game, actor, staging, protect);
richeseReinforcementsPlayer(game, actor).hand.push(card);
staging.push(`Conserved ${cardId}: ${holder.id}'s hand → ${actor}'s hand (no purchase, payment or replacement)`);
}
function availableLeaders(game: Game, actor: string, battleTerritory: string) {
return richeseReinforcementsPlayer(game, actor).leaders
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
}
export function createRicheseReinforcementsFixture(
options: RicheseReinforcementsOptions = {},
): RicheseReinforcementsFixture {
const family = options.family ?? 'choam';
const advanced = options.advanced ?? false;
const route = options.route ?? (advanced ? 'immediate' : 'tuning');
if (route === 'tuning')
assert.equal(advanced, false,
'The Advanced storm card makes every turn after the first nondeterministic; use the immediate route');
assert.ok(route !== 'immediate' || options.reserves === undefined || options.reserves === 20,
'The immediate native route starts with twenty real reserves; tuning is a separate timeline');
const staging: string[] = [];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' ||
(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.players.every(player => player.hand.length === 0)),
'Continue only the original fresh undealt independent-card setup');
assert.equal(game.advanced, advanced);
assert.deepEqual([...game.expansions].sort(), family === 'choam' ? ['choam'] : ['choam', 'ix']);
assert.ok(game.players.some(player => player.faction === 'richese'));
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
} else {
const roster: FactionId[] = family === 'choam'
? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'];
const ids = options.seatIds ?? roster;
game = createGame('RICHESEREINFORCEMENTS', newPlayer(ids[0], 'Richese', roster[0]),
advanced, family === 'choam' ? ['choam'] : ['ix', 'choam']);
joinGame(game, newPlayer(ids[1], roster[1], roster[1]));
joinGame(game, newPlayer(ids[2], roster[2], roster[2]));
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
let next: RicheseReinforcementsStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const richese = richeseReinforcementsSeat(game, 'richese').id;
const marker = options.marker ?? 3;
// Order A keeps the zero token for the reuse turn; otherwise the three token
// is the reuse turn and the final hidden token is zero or five.
const reuseValue: NoFieldValue = marker === 3 ? 0 : 3;
const baseline = marker === 3 ? 15 : 12;
const target = options.reserves ?? (marker === 3 ? 6 : 8);
const tuning = route === 'tuning' ? baseline - target : 0;
assert.ok(route === 'immediate' || (target >= 0 && target <= baseline),
'The tuning route can only leave these reserve budgets');
assert.ok(tuning <= 10, 'The tuning shipment must stay inside the spice the real route collected');
const nativeSteps: RicheseReinforcementsStep[] = [];
const act = (step: RicheseReinforcementsStep) => {
nativeSteps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
};
const rich = () => richeseReinforcementsPlayer(game, richese);
const tokenOf = (value: NoFieldValue) => rich().noField!.tokens.find(token => token.value === value)!;
const stormFreeCity = () => {
const city = RICHESE_CITIES.find(candidate => candidate.sector !== game.storm);
assert.ok(city, 'The natural route keeps one empty stronghold outside the storm');
return city;
};
const battleTarget = () => {
for (const army of RICHESE_ARMIES) {
const seat = game.players.find(player => player.faction === army.faction);
if (!seat || army.sector === game.storm) continue;
if ((seat.forces[`${army.territory}:${army.sector}`] ?? 0) > 0) return { ...army, seat: seat.id };
}
assert.fail('The timeline needs one untouched starting army outside the storm');
};
const done = { shipment: {} as Record<number, boolean>, reveal: {} as Record<number, boolean> };
let open = false;
let beforeShipment: Game | undefined;
let afterShipment: Game | undefined;
let shipmentStep: RicheseReinforcementsStep | undefined;
let beforeBattle: Game | undefined;
let battleStep: RicheseReinforcementsStep | undefined;
let reservesBefore: Record<string, number> | undefined;
let city: string | null = null;
let army: { faction: string; territory: string; sector: number; seat: string } | undefined;
const clear = () => richeseReinforcementsClean(game);
// Native actions only: no counter is placed by hand and no wallet is set.
// Richese's first real concealed shipment parks the token on the one
// deterministic opening Spice Blow (Broken Land, sector 12). Its real reveal
// materializes five counters that then collect that blow over two Mentat
// pauses (5, then the remaining 3). A second real reveal plus one real paid
// tuning shipment into an empty stronghold leave the requested reserve
// budget, with a real charity claim when a low budget needs the top-up.
for (let count = 0; count < 2400 && !open; count++) {
assert.equal(game.status, 'playing');
if (done.shipment[4] && !afterShipment && clear()) afterShipment = structuredClone(game);
const choices = game.phase === 6 && clear()
? viewGame(game, richese).battleChoices : [];
if (!open && choices.some(choice => choice.territory === army?.territory)) {
const choice = choices.find(candidate => candidate.territory === army!.territory)!;
beforeBattle = structuredClone(game);
battleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: army!.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
open = true;
break;
}
if (game.phase === 2 && game.turn === 4 && clear() &&
rich().charityTurn !== game.turn && rich().spice < 2) {
act({ actor: richese, action: { type: 'charity' } });
continue;
}
if (game.phase === 5 && game.active === richese && clear()) {
const turn = game.turn;
if (route === 'tuning' && turn === 1 && !done.shipment[1]) {
act({ actor: richese, action: { type: 'ship', noField: tokenOf(5).id,
event: rich().noFieldEvent, territory: RICHESE_BLOW.territory, sector: RICHESE_BLOW.sector } });
done.shipment[1] = true;
continue;
}
if (route === 'tuning' && turn === 2 && rich().noField!.deployed && !done.reveal[2]) {
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
token: rich().noField!.deployed!.tokenId } });
done.reveal[2] = true;
continue;
}
if (route === 'tuning' && turn === 2 && !done.shipment[2] && !rich().shipped) {
const cityChoice = stormFreeCity();
city = `${cityChoice.territory}:${cityChoice.sector}`;
act({ actor: richese, action: { type: 'ship', noField: tokenOf(reuseValue).id,
event: rich().noFieldEvent, territory: cityChoice.territory, sector: cityChoice.sector } });
done.shipment[2] = true;
continue;
}
if (route === 'tuning' && turn === 3 && rich().noField!.deployed && !done.reveal[3]) {
assert.equal(rich().noField!.deployed!.tokenId, tokenOf(reuseValue).id,
'The reuse token must still be the concealed one this timeline reveals');
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
token: rich().noField!.deployed!.tokenId } });
done.reveal[3] = true;
continue;
}
if (route === 'tuning' && turn === 3 && !done.shipment[3] && !rich().shipped) {
const cityChoice = stormFreeCity();
act({ actor: richese, action: { type: 'ship', territory: cityChoice.territory,
sector: cityChoice.sector, amount: tuning } });
done.shipment[3] = true;
continue;
}
const finalTurn = route === 'tuning' ? 4 : 1;
if (turn === finalTurn && !done.shipment[4] && !rich().shipped) {
army = battleTarget();
beforeShipment = structuredClone(game);
reservesBefore = Object.fromEntries(game.players.map(player => [player.id, player.reserves]));
shipmentStep = { actor: richese, action: { type: 'ship', noField: tokenOf(marker).id,
event: rich().noFieldEvent, territory: army.territory, sector: army.sector } };
act(shipmentStep);
done.shipment[4] = true;
continue;
}
assert.ok(turn <= (route === 'tuning' ? 4 : 1),
'The natural timeline must reach its battle by the scheduled turn');
}
const step = nextRicheseReinforcementsStep(game);
assert.ok(step, `Native Richese continuation stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
act(step);
}
assert.ok(open, 'The final concealed shipment must reach a real ordinary battle');
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep &&
reservesBefore && army, 'The timeline must record its final shipment, army and battle admission');
for (let count = 0; count < 120; count++) {
const step = nextRicheseReinforcementsStep(game);
if (!step) break;
act(step);
}
assert.ok(game.battle && !game.battle.revealed && clear());
assert.deepEqual(game.battle.plans, {});
const opponent = army.seat;
const observer = game.players.find(player => player.faction !== 'richese' &&
player.id !== opponent)!.id;
const machines: RicheseMachine[] = [...(options.machines ?? ['reinforcements', 'harass'])];
if (options.opponentStone && !machines.includes('mirror')) machines.push('mirror');
const hand: Partial<Record<RicheseMachine, string>> = {};
const requested = machines.map(machine => machine === 'reinforcements' ? RICHESE_REINFORCEMENTS
: machine === 'harass' ? RICHESE_HARASS
: machine === 'stone' ? RICHESE_STONE_BURNER : RICHESE_MIRROR_WEAPON);
if (options.opponentStone) transferCard(game, opponent, RICHESE_STONE_BURNER, staging, requested);
for (const machine of machines) {
const cardId = machine === 'reinforcements' ? RICHESE_REINFORCEMENTS
: machine === 'harass' ? RICHESE_HARASS
: machine === 'stone' ? RICHESE_STONE_BURNER : RICHESE_MIRROR_WEAPON;
positionCard(game, richese, cardId, staging, requested);
hand[machine] = cardId;
}
const location = `${army.territory}:${army.sector}`;
const enemyForces = richeseReinforcementsPlayer(game, opponent).forces[location] ?? 0;
assert.ok(enemyForces > 0, 'The battle army must still hold the stronghold');
const reserves = reservesBefore[richese];
if (route === 'tuning')
assert.equal(reserves, target, 'The tuning route must leave the requested reserve budget');
const materializes = Math.min(tokenOf(marker).value, reserves);
const ownLeader = availableLeaders(game, richese, army.territory)[0];
const otherLeader = availableLeaders(game, opponent, army.territory)[0];
assert.ok(ownLeader && otherLeader);
const ownDial = options.dial ?? (advanced ? Math.min(1, Math.floor(materializes / 2))
: Math.min(2, materializes));
const enemyDial = options.opponentDial ?? Math.min(5, enemyForces);
const machineSlot = (machine: RicheseMachine | undefined) =>
machine === undefined ? null : hand[machine] ?? null;
const plan = (weapon: RicheseMachine | undefined, defense: RicheseMachine | undefined):
RicheseReinforcementsStep => ({
actor: richese,
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
leader: ownLeader.id, weapon: machineSlot(weapon), defense: machineSlot(defense) },
});
const plans = {
both: plan('reinforcements', 'harass'),
reinforcements: plan('reinforcements', undefined),
harass: plan(undefined, 'harass'),
plain: plan(undefined, undefined),
opponent: { actor: opponent, action: { type: 'battlePlan', dial: enemyDial,
support: advanced ? enemyDial : 0, leader: otherLeader.id } },
} satisfies RicheseReinforcementsFixture['plans'];
return {
initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
game, richese, opponent, observer, family, advanced, route, location, city,
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore,
machines, hand, plans, nativeSteps, staging,
};
}
/** Seal one real plan and consume only its pre-reveal preparation windows; a
* live post-reveal Stone/withdrawal decision is left for the caller. */
export function prepareRicheseReinforcementsPlan(game: Game, step: RicheseReinforcementsStep): Game {
let state = applyAction(game, step.actor, step.action);
for (let count = 0; count < 120; count++) {
if (state.battle?.revealed) return state;
const pending = !!state.response || !!state.phaseOpening || !!state.decision ||
(!!state.battle?.preLeader && !state.battle.preLeader.closed) || !!state.battle?.preparation;
if (!pending) return state;
const next = nextRicheseReinforcementsStep(state);
if (!next) return state;
state = applyAction(state, next.actor, next.action);
}
throw Error('Sealed plan did not finish its pre-reveal preparation');
}
/** Seal one real plan and consume only the native windows it opens. */
export function sealRicheseReinforcementsPlan(game: Game, step: RicheseReinforcementsStep,
stoneMode: 'kill' | 'ignore' = 'ignore'): Game {
let state = applyAction(game, step.actor, step.action);
for (let count = 0; count < 120; count++) {
if (!state.response && !state.phaseOpening && !state.decision) return state;
const next = nextRicheseReinforcementsStep(state, stoneMode);
assert.ok(next, `Sealed plan left an unconsumed native window at ${state.decision?.kind}`);
state = applyAction(state, next.actor, next.action);
}
throw Error('Sealed plan did not finish its native preparation');
}
/** Both real plans sealed; the native reveal and any live decisions remain. */
export function sealRicheseReinforcementsBoth(fixture: RicheseReinforcementsFixture,
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'reinforcements',
state = fixture.game, stoneMode: 'kill' | 'ignore' = 'ignore'): Game {
const first = sealRicheseReinforcementsPlan(state, fixture.plans[plan], stoneMode);
return sealRicheseReinforcementsPlan(first, fixture.plans.opponent, stoneMode);
}
/** Resolve the programme's own reveal and casualties, before any later battle. */
export function finishRicheseReinforcementsBattle(game: Game,
options: { callers?: string[]; stoneModes?: Record<string, 'kill' | 'ignore'> } = {}): Game {
const event = game.battle?.event;
assert.ok(event, 'Finish only a live original battle');
let state = game;
for (let count = 0; count < 240; count++) {
if (!state.battle && state.lastBattleContext?.event === event &&
richeseReinforcementsClean(state) && !state.pendingTreacheryDiscard) return state;
let step: RicheseReinforcementsStep | null;
if (!state.response && !state.phaseOpening && state.decision?.kind === 'stoneBurner') {
step = { actor: state.decision.player, action: { type: 'decision', event: state.decision.event,
mode: options.stoneModes?.[state.decision.player] ?? 'ignore' } };
} else {
const next = nextRicheseReinforcementsStep(state);
step = next && next.action.type === 'traitorCall'
? { actor: next.actor, action: { ...next.action, call: options.callers?.includes(next.actor) ?? false } }
: next;
}
assert.ok(step, `Native battle continuation stalled at ${state.decision?.kind}`);
state = applyAction(state, step.actor, step.action);
}
throw Error('Native Richese battle did not finish');
}
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { MOBILE_LOCATION } from '../game/board';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
assertNativeFamilyReinforcementsInventory,
createNativeFamilyReinforcementsFixture,
finishNativeFamilyReinforcementsBattle,
nativeFamilyReinforcementsClean,
nativeFamilyReinforcementsPlayer as player,
revealNativeFamilyReinforcementsBattle,
sealNativeFamilyReinforcementsPlan,
type NativeFamilyReinforcementsFixture,
type NativeFamilyReinforcementsStep,
} from './fixture-native-family-reinforcements';
function assertDisposed(game: Game,
fixture: NativeFamilyReinforcementsFixture): void {
assert.equal(game.battle, null);
assert.ok(nativeFamilyReinforcementsClean(game));
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(!player(game, fixture.owner).hand.some(c => c.id === REINFORCEMENTS_CARD));
assertNativeFamilyReinforcementsInventory(game);
}
for (const advanced of [false, true]) {
for (const faction of ['ixians', 'tleilaxu', 'choam'] as const) {
for (const slot of ['weapon', 'defense'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} native ${faction} ${slot}: fee3/+2 wins without more physical dial or support`, () => {
const f = createNativeFamilyReinforcementsFixture({ faction, advanced, slot });
const own = player(f.game, f.owner), opponent = player(f.game, f.opponent);
const preview = viewGame(f.game, f.owner).battle!.reinforcements;
assert.equal(preview?.blocked, null);
const first = sealNativeFamilyReinforcementsPlan(structuredClone(f.game), f.planActions[0]);
assert.equal(player(first, f.owner).reserves, own.reserves, 'A private plan does not spend the eventual reveal fee');
assert.equal(player(first, f.owner).tanks, own.tanks);
const revealed = sealNativeFamilyReinforcementsPlan(first, f.planActions[1]);
assert.deepEqual(player(revealed, f.owner).forces, own.forces, 'Reinforcements has not shipped an army into the battle');
assert.deepEqual(player(revealed, f.owner).elites?.forces, own.elites?.forces);
const continuation: NativeFamilyReinforcementsStep[] = [];
const done = finishNativeFamilyReinforcementsBattle(revealed, { actions: continuation });
const after = player(done, f.owner);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(after.reserves, own.reserves - 3);
assert.equal(after.elites?.reserves, own.elites?.reserves, 'Normal reserves pay before untouched cyborg reserves');
assert.equal(after.tanks, 5, 'Fee3 plus exactly two original physical committed tokens');
assert.equal(after.battleLosses, 5);
assert.equal(after.elites?.tanks ?? 0, faction === 'ixians' ? 1 : 0);
assert.equal(after.forces[f.location], 1);
assert.equal(after.elites?.forces[f.location] ?? 0, faction === 'ixians' ? 1 : 0);
assert.equal(player(done, f.opponent).reserves, opponent.reserves);
assert.equal(player(done, f.opponent).tanks, 3);
if (faction === 'choam' && advanced) assert.ok(continuation.some(s =>
s.action.type === 'decision' && s.action.audit === false), 'The surviving original Auditor still offers its real inspection');
assertDisposed(done, f);
// Holding all original armies, leaders, dial and support fixed, remove
// only R: total 3.5 (Ix) or 4 (others) loses to the same printed five.
// R adds precisely two strength, not extra supported physical tokens.
const plain = structuredClone(f.planActions[0]);
plain.action.weapon = null; plain.action.defense = null;
let control = sealNativeFamilyReinforcementsPlan(structuredClone(f.game), plain);
control = sealNativeFamilyReinforcementsPlan(control, f.planActions[1]);
control = finishNativeFamilyReinforcementsBattle(control);
assert.equal(control.lastBattleContext!.winner, f.opponent);
assert.equal(player(control, f.owner).reserves, own.reserves);
assert.equal(player(control, f.owner).tanks, 3);
assertNativeFamilyReinforcementsInventory(control);
const impossible = structuredClone(f.planActions[0].action);
impossible.dial = faction === 'ixians' ? 6.5 : 5;
impossible.support = advanced ? faction === 'ixians' ? 2 : 3 : 0;
const unchanged = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, impossible), 'The +2 card does not allow dialing two additional tokens');
assert.deepEqual(f.game, unchanged);
if (faction === 'ixians' && advanced) {
const overSupported = { ...f.planActions[0].action, support: 3 };
assert.throws(() => applyAction(f.game, f.owner, overSupported), 'The original suboid cannot receive spice support');
assert.deepEqual(f.game, unchanged);
}
});
}
}
// Distinct native consequences, rather than repeated roster/default rows:
// cyborg losses/explosion, Tleilaxu's genuine Face Dancer window, and the
// Auditor's exceptional-outcome continuation all retain the revealed fee.
for (const { faction, outcome } of [
{ faction: 'ixians', outcome: 'loser' },
{ faction: 'ixians', outcome: 'explosion' },
{ faction: 'tleilaxu', outcome: 'loser' },
{ faction: 'tleilaxu', outcome: 'traitor' },
{ faction: 'choam', outcome: 'traitor' },
{ faction: 'choam', outcome: 'explosion' },
] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} native ${faction} ${outcome}: revealed fee and mandatory discard survive native aftermath`, () => {
const f = createNativeFamilyReinforcementsFixture({ faction, advanced, outcome,
slot: advanced ? 'defense' : 'weapon' });
const before = player(f.game, f.owner), continuation: NativeFamilyReinforcementsStep[] = [];
const done = finishNativeFamilyReinforcementsBattle(revealNativeFamilyReinforcementsBattle(f), {
callers: outcome === 'traitor' ? [f.opponent] : [], actions: continuation,
});
const own = player(done, f.owner);
assert.equal(done.lastBattleContext!.result, outcome === 'loser' ? 'normal' : outcome);
assert.equal(done.lastBattleContext!.winner, outcome === 'explosion' ? null : f.opponent);
assert.equal(own.reserves, before.reserves - 3);
assert.equal(own.elites?.reserves, before.elites?.reserves);
assert.equal(own.tanks, 6, 'Fee3 and only the three original losing/exploding counters');
assert.equal(own.battleLosses, 6);
assert.equal(own.elites?.tanks ?? 0, faction === 'ixians' ? 2 : 0);
assert.equal(own.forces[f.location] ?? 0, 0);
assert.equal(own.spice, before.spice - Number(f.planActions[0].action.support), 'Only the printed physical support is paid, with no support for fee counters or +2');
if (faction === 'choam' && advanced) assert.ok(continuation.some(s =>
s.action.type === 'decision' && s.action.audit === false), 'The dead or surviving Auditor is handled by its original control');
if (faction === 'tleilaxu' && outcome === 'loser') assert.ok(continuation.some(s =>
s.action.type === 'decision' && s.action.reveal === false), 'The actual matching native Face Dancer is offered after the Guild victory');
assertDisposed(done, f);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
const f = createNativeFamilyReinforcementsFixture({ advanced, faction: 'ixians', mixedFee: true,
expansions: ['ix', 'choam'], slot: advanced ? 'weapon' : 'defense' });
const own = player(f.game, f.owner);
assert.equal(own.reserves, 5);
assert.equal(own.elites!.reserves, 4);
const done = finishNativeFamilyReinforcementsBattle(revealNativeFamilyReinforcementsBattle(f));
const after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(after.reserves, 2);
assert.equal(after.elites!.reserves, 2, 'Exactly two original cyborgs supplement the one remaining suboid');
assert.equal(after.tanks, 4, 'Three fee tokens plus one half-strength dialed suboid');
assert.equal(after.elites!.tanks, 2, 'Reserve cyborg fee enters matching elite Tanks, not normal Tanks');
assert.equal(after.battleLosses, 4);
assert.equal(after.forces[f.location], 8);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(after.forces[MOBILE_LOCATION], 6);
assert.equal(after.elites!.forces[MOBILE_LOCATION], 3);
assertDisposed(done, f);
});
}
-22
View File
@@ -2,8 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHarassNoFieldFixture, sealHarassNoFieldPlan } from './fixture-harass-no-field';
import {
noFieldReinforcementsFixture, sealNoFieldReinforcements, finishNoFieldReinforcements,
NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS, type NoFieldReinforcementsFixture,
@@ -186,23 +184,3 @@ void test('Original Advanced native inventory without the selected Occupy profil
assert.deepEqual(before, fixture.game);
assertConserved(fixture, before);
});
void test('Ordinary Basic Richese Harass with a real concealed marker does not widen Reinforcements composition support', () => {
const fixture = createHarassNoFieldFixture({ advanced: false, marker: 3, reserves: 6 });
stageEcazOccupyCard(fixture.game, fixture.owner, card => card.id === NO_FIELD_REINFORCEMENTS);
const before = structuredClone(fixture.game);
const preview = viewGame(before, fixture.owner).battle!;
assert.equal(before.advanced, false);
assert.equal(preview.harassWithdraw!.blocked, null);
assert.ok(preview.reinforcements!.blocked);
assert.throws(() => applyAction(before, fixture.owner, {
...fixture.planActions[0].action, weapon: NO_FIELD_REINFORCEMENTS, defense: null,
}), RuleError);
assert.deepEqual(before, fixture.game);
const legal = sealHarassNoFieldPlan(before, fixture.planActions[0]);
assert.ok(legal.battle!.plans[fixture.owner]);
assert.equal(legal.battle!.revealed, false);
assert.equal(occupyRicheseSeat(legal, fixture.owner).reserves, 6);
assert.deepEqual(occupyRicheseSeat(legal, fixture.owner).noField,
occupyRicheseSeat(before, fixture.owner).noField);
});
-10
View File
@@ -161,13 +161,3 @@ void test('insufficient reserves and unsupported modules reject without mutating
assert.throws(() => applyAction(g, 'a',
{ type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);
});
void test('other expansion factions remain ineligible for the paired Ecaz card prototype', () => {
for (const owner of ['ixians', 'choam'] as const) {
const g = harassWithdrawGame({ advanced: true, factions: [owner, 'atreides', 'harkonnen'] });
takeHarassCard(g, 'a', cardId);
assert.throws(() => applyAction(g, 'a', {
type: 'battlePlan', dial: 0, support: 0, leader: `${owner}-0`, weapon: cardId,
}), RuleError);
}
});
+275
View File
@@ -0,0 +1,275 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
import {
createRicheseReinforcementsFixture, finishRicheseReinforcementsBattle,
prepareRicheseReinforcementsPlan, RICHESE_HARASS, RICHESE_MIRROR_WEAPON,
RICHESE_REINFORCEMENTS, RICHESE_STONE_BURNER, richeseReinforcementsPlayer,
sealRicheseReinforcementsBoth, sealRicheseReinforcementsPlan,
type RicheseReinforcementsFixture,
} from './fixture-richese-reinforcements';
/** Every conserved physical card, counter pool and hand limit of the programme. */
function assertConserved(fixture: RicheseReinforcementsFixture, game: Game): void {
const cards = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []),
...(state.richeseRemoved ?? []), ...state.players.flatMap(player => player.hand)]
.map(card => card.id).sort();
assert.deepEqual(cards(game), cards(fixture.initial),
'Every original deck, cache, discard and hand face keeps physical custody.');
const pools = (state: Game) => state.players.map(player => [player.id,
player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
player.elites
? player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
: 0,
]);
assert.deepEqual(pools(game), pools(fixture.afterSetup),
'Every physical counter stays in its own native pool.');
for (const player of game.players)
assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4),
`${player.faction} stays inside its printed hand limit.`);
}
function assertNoUtilityCustody(game: Game, card: string): void {
assert.equal(game.discard.filter(face => face.id === card).length, 1);
assert.equal(game.deck.filter(face => face.id === card).length, 0);
assert.ok(game.players.every(player => !player.hand.some(face => face.id === card)));
}
/** Read one selected plan leader without trusting the loose action shape. */
function actionLeader(action: Action): string {
const leader = action.leader;
assert.ok(typeof leader === 'string', 'The prepared plan selects one physical leader');
return leader;
}
for (const slot of ['weapon', 'defense'] as const)
void test(`Funded marker 3 leaves exactly three reserves after the real reveal and charges them to the Tanks in the ${slot} slot`, () => {
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 6 });
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.noField!.materializes, 3);
assert.equal(preview.noField!.reservesAfterReveal, 3);
const plan = slot === 'weapon' ? fixture.plans.reinforcements : {
...fixture.plans.reinforcements,
action: { ...fixture.plans.reinforcements.action, weapon: null, defense: RICHESE_REINFORCEMENTS },
};
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
assert.equal(first.battle!.revealed, false);
assert.equal(richeseReinforcementsPlayer(first, fixture.richese).reserves, 6,
'The reserve fee is not charged before the native reveal.');
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
assert.equal(revealed.battle!.revealed, true);
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
assert.equal(owner.reserves, 3, 'The reveal materializes three capped reserves.');
assert.equal(owner.forces[fixture.location], 3);
assert.equal(owner.tanks, 0);
assert.equal(owner.noField!.deployed, null);
assert.equal(owner.noField!.lastShipped, fixture.token.id,
'Revelation preserves the real most-recent-shipment identity.');
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
assert.equal(after.reserves, 0);
assert.equal(after.tanks, 6, 'Three physical losses plus the three-reserve fee reached the Tanks.');
assert.equal(after.battleLosses, 6);
assert.equal(after.forces[fixture.location] ?? 0, 0);
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [marker, reserves, materializes, finalReserves, finalTanks] of
[[0, 6, 0, 3, 3], [5, 8, 5, 0, 8], [3, 8, 3, 2, 6]] as const)
void test(`Funded No-Field ${marker} with ${reserves} reserves reveals ${materializes} counters and pays the fee`, () => {
const fixture = createRicheseReinforcementsFixture({ marker, reserves });
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.noField!.materializes, materializes);
assert.equal(preview.noField!.reservesAfterReveal, reserves - materializes);
assert.equal(preview.normal, Math.min(3, reserves - materializes));
const revealed = sealRicheseReinforcementsBoth(fixture, 'reinforcements');
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
assert.equal(owner.forces[fixture.location] ?? 0, materializes);
assert.equal(owner.noField!.deployed, null);
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, finalReserves);
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).tanks, finalTanks);
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
void test('A reserve-capped marker five at three reserves materializes only three and refuses the plan', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 3 });
const before = structuredClone(fixture.game);
const preview = viewGame(before, fixture.richese).battle!.reinforcements!;
assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves.');
assert.equal(preview.noField!.reservesAfterReveal, 0);
assert.equal(preview.normal + preview.elite, 0);
assert.ok(preview.blocked);
assert.throws(() => applyAction(before, fixture.richese, fixture.plans.reinforcements.action), RuleError);
assert.deepEqual(before, fixture.game, 'A refused forecast reserves nothing.');
assertConserved(fixture, fixture.game);
});
void test('A prospective reveal leaving fewer than three reserves refuses the first seal but the real Harass fallback still plays', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 5 });
const before = structuredClone(fixture.game);
const saved = structuredClone(before);
const preview = viewGame(before, fixture.richese).battle!.reinforcements!;
assert.ok(preview.blocked);
assert.equal(preview.normal + preview.elite, 2);
assert.equal(preview.noField!.materializes, 3);
assert.equal(preview.noField!.reservesAfterReveal, 2);
assert.equal(viewGame(before, fixture.richese).battle!.harassWithdraw!.blocked, null);
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
assert.throws(() => applyAction(before, fixture.richese, plan.action), RuleError);
assert.deepEqual(before, saved, 'A refused plan reserves no card, spice, marker or window.');
assert.equal(before.battle!.plans[fixture.richese], undefined);
}
const harass = sealRicheseReinforcementsPlan(before, fixture.plans.harass);
const revealed = sealRicheseReinforcementsPlan(harass, fixture.plans.opponent);
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, 3,
'The concealed marker still reveals under the legal fallback.');
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, 3,
'The undialed counter returns through the real Harass allocation.');
assertNoUtilityCustody(done, RICHESE_HARASS);
assert.equal(done.discard.filter(face => face.id === RICHESE_REINFORCEMENTS).length, 0);
assertConserved(fixture, done);
});
void test('Same-plan Reinforcements and Harass charge the three-reserve fee before the native undialed return', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 6 });
const revealed = sealRicheseReinforcementsBoth(fixture, 'both');
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).reserves, 3);
const done = finishRicheseReinforcementsBattle(revealed);
const owner = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(owner.reserves, 1, 'Three reserves paid, one undialed counter returned afterwards.');
assert.equal(owner.tanks, 5, 'Only the dialed counters and the fee left the owner.');
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertNoUtilityCustody(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
void test('Reinforcements adds two to the dialed battle strength without adding a physical undialed counter', () => {
const funded = createRicheseReinforcementsFixture({ marker: 3, reserves: 6, dial: 3, opponentDial: 4 });
const withMachine = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(funded, 'reinforcements'));
assert.equal(withMachine.lastBattleContext!.winner, funded.richese,
'The committed two strength wins the identical physical contest.');
assert.equal(richeseReinforcementsPlayer(withMachine, funded.richese).tanks, 6);
assert.equal(richeseReinforcementsPlayer(withMachine, funded.richese).forces[funded.location] ?? 0, 0,
'No bonus counter ever appears on the board.');
const without = createRicheseReinforcementsFixture({ marker: 3, reserves: 6, dial: 3, opponentDial: 4 });
const plain = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(without, 'plain'));
assert.equal(plain.lastBattleContext!.winner, without.opponent,
'The identical physical commitment loses without the machine.');
assert.equal(richeseReinforcementsPlayer(plain, without.richese).tanks, 3);
assert.equal(richeseReinforcementsPlayer(plain, without.richese).reserves, 3);
});
for (const marker of [0, 3, 5] as const)
void test(`Advanced native marker ${marker} pays its fee without changing the original physical support`, () => {
const fixture = createRicheseReinforcementsFixture({ advanced: true, marker });
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
assert.equal(viewGame(fixture.game, fixture.richese).battle!.reinforcements!.blocked, null);
const done = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(fixture, 'reinforcements'));
const after = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(after.reserves, 17 - marker, 'The materialized physical counters leave before the three-reserve fee');
assert.equal(after.tanks, marker + 3);
assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support));
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
for (const [label, options] of [['Basic', { family: 'ix+choam' as const, marker: 3 as const, reserves: 6 }],
['Advanced', { family: 'ix+choam' as const, advanced: true, marker: 3 as const }]] as const)
void test(`The union Ix and CHOAM selection is admitted and resolves a clean Richese ${label} programme`, () => {
const fixture = createRicheseReinforcementsFixture(options);
assert.equal(viewGame(fixture.game, fixture.richese).battle!.reinforcements!.blocked, null);
const revealed = sealRicheseReinforcementsBoth(fixture, 'both');
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(done.battle, null);
const owner = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(owner.reserves, fixture.advanced ? 16 : 1);
assert.equal(owner.tanks, fixture.advanced ? 4 : 5);
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertNoUtilityCustody(done, RICHESE_HARASS);
assertConserved(fixture, done);
});
void test('The original physical cache Stone compares undialed counters, ignores two score strength and still charges the fee', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
machines: ['reinforcements', 'stone'] });
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
weapon: RICHESE_STONE_BURNER, defense: RICHESE_REINFORCEMENTS } };
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
const revealed = applyAction(first, fixture.plans.opponent.actor, fixture.plans.opponent.action);
assert.equal(revealed.decision?.kind, 'stoneBurner');
const afterChoice = applyAction(revealed, revealed.decision!.player, {
type: 'decision', event: revealed.decision!.event, mode: 'ignore',
});
const done = finishRicheseReinforcementsBattle(afterChoice);
const owner = richeseReinforcementsPlayer(done, fixture.richese);
assert.equal(done.lastBattleContext!.winner, fixture.richese,
'The undialed physical token count decides, not the ordinary strength total.');
assert.equal(owner.tanks, 5, 'The three-reserve fee still applies under the Stone comparison.');
assert.equal(owner.reserves, 0);
assert.equal(owner.forces[fixture.location], 3, 'Only the surviving real counters remain.');
assert.equal(done.discard.filter(face => face.id === RICHESE_STONE_BURNER).length, 0,
'The winning Stone Burner stays with its owner.');
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
void test('The native Stone kill mode still charges the fee and kills both participating leaders', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
machines: ['reinforcements', 'stone'] });
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
weapon: RICHESE_STONE_BURNER, defense: RICHESE_REINFORCEMENTS } };
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
const revealed = applyAction(first, fixture.plans.opponent.actor, fixture.plans.opponent.action);
assert.equal(revealed.decision?.kind, 'stoneBurner');
const afterChoice = applyAction(revealed, revealed.decision!.player, {
type: 'decision', event: revealed.decision!.event, mode: 'kill',
});
const done = finishRicheseReinforcementsBattle(afterChoice);
const owner = richeseReinforcementsPlayer(done, fixture.richese);
const enemy = richeseReinforcementsPlayer(done, fixture.opponent);
assert.equal(done.lastBattleContext!.winner, fixture.richese);
assert.equal(owner.leaders.find(leader => leader.id === actionLeader(fixture.plans.reinforcements.action))!.dead, true);
assert.equal(enemy.leaders.find(leader => leader.id === actionLeader(fixture.plans.opponent.action))!.dead, true);
assert.equal(owner.tanks, 5, 'The reserve fee is independent of the chosen Stone mode.');
assert.equal(owner.reserves, 0);
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});
void test('A clean Mirror Weapon copies the real opposing Stone Burner into a native reveal choice while Reinforcements still pays', () => {
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
machines: ['reinforcements'], opponentStone: true });
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
weapon: RICHESE_MIRROR_WEAPON, defense: RICHESE_REINFORCEMENTS } };
const opponent = { actor: fixture.opponent,
action: { ...fixture.plans.opponent.action, weapon: RICHESE_STONE_BURNER } };
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
const revealed = applyAction(first, opponent.actor, opponent.action);
assert.equal(revealed.decision?.kind, 'stoneBurner');
assert.equal(revealed.decision!.player, fixture.richese,
'A copied Stone chooses before the original physical Stone.');
const afterChoice = applyAction(revealed, revealed.decision!.player, {
type: 'decision', event: revealed.decision!.event, mode: 'ignore',
});
const done = finishRicheseReinforcementsBattle(afterChoice);
assert.equal(done.lastBattleContext!.winner, fixture.richese);
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).tanks, 5);
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, 0);
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
assertConserved(fixture, done);
});