Connect clean native family Reinforcements battles
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user