Compose independent cards with original phase modules
This commit is contained in:
1 parent
4313983a5c
commit
4a67e8c7be
17 files changed
+1005
-26
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { TECH_TOKENS } from '../game/tech-tokens';
|
||||
import {
|
||||
MODULE_HARASS, MODULE_REINFORCEMENTS, battleCardModulesPlayer,
|
||||
callBattleCardModules, createBattleCardModulesFixture, finishBattleCardModules,
|
||||
sealBattleCardModules, type BattleCardModulesFixture,
|
||||
} from './fixture-battle-card-modules';
|
||||
|
||||
const physicalCards = (game: Game) => [
|
||||
...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
].map(card => card.id).sort();
|
||||
|
||||
function assertUtilitiesDiscarded(game: Game) {
|
||||
for (const id of [MODULE_REINFORCEMENTS, MODULE_HARASS]) {
|
||||
assert.equal(game.discard.filter(card => card.id === id).length, 1);
|
||||
assert.ok(game.players.every(player => player.hand.every(card => card.id !== id)));
|
||||
}
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
}
|
||||
function assertMandatoryTechTransfer(fixture: BattleCardModulesFixture, done: Game,
|
||||
winner: string, loser: string) {
|
||||
const lost = TECH_TOKENS.filter(token => fixture.game.techTokens![token.id].owner === loser);
|
||||
assert.equal(lost.length, 1, 'The original three-seat distribution gives this loser one actual token');
|
||||
for (const token of TECH_TOKENS) {
|
||||
assert.equal(done.techTokens![token.id].owner,
|
||||
lost.includes(token) ? winner : fixture.game.techTokens![token.id].owner);
|
||||
assert.equal(done.techTokens![token.id].spice, 0);
|
||||
}
|
||||
}
|
||||
function assertOriginalShipmentIncome(fixture: BattleCardModulesFixture) {
|
||||
const { beforeShipment: before, beforeBattle: settled } = fixture;
|
||||
const recipient = before.techTokens!.heighliners.owner;
|
||||
assert.ok(recipient);
|
||||
for (const player of before.players) {
|
||||
const expected: number = player.spice - (player.id === fixture.opponent ? 4 : 0) +
|
||||
(player.faction === 'guild' ? 4 : 0) + (player.id === recipient ? 1 : 0);
|
||||
assert.equal(battleCardModulesPlayer(settled, player.id).spice, expected,
|
||||
'Paid native shipment, Guild income and exactly one original Heighliners phase-end payment');
|
||||
}
|
||||
assert.equal(settled.techTokens!.heighliners.triggeredTurn, 2);
|
||||
assert.equal(settled.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(battleCardModulesPlayer(settled, fixture.opponent).forces[fixture.location], 4);
|
||||
}
|
||||
|
||||
void test('earned Arrakeen pays only actual support: bank-only two versus bank two plus personal one, with genuine R+H losses', () => {
|
||||
for (const dial of [2, 3]) {
|
||||
const fixture = createBattleCardModulesFixture({ dial,
|
||||
slot: dial === 2 ? 'defense' : 'weapon' });
|
||||
const { game, owner, opponent } = fixture;
|
||||
assert.equal(fixture.beforeMentat.turn, 1);
|
||||
assert.equal(fixture.beforeMentat.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(fixture.beforeMentat.strongholdCards!.owners).every(id => id === null));
|
||||
const earned = applyAction(fixture.beforeMentat, fixture.mentatStep.actor, fixture.mentatStep.action);
|
||||
assert.equal(earned.strongholdCards!.claimedTurn, 1);
|
||||
assert.equal(earned.strongholdCards!.owners.arrakeen, owner);
|
||||
assert.equal(fixture.afterMentat.strongholdCards!.owners.arrakeen, owner);
|
||||
assert.equal(viewGame(game, owner).battle!.strongholdEffects[owner], 'arrakeen');
|
||||
assertOriginalShipmentIncome(fixture);
|
||||
assert.deepEqual(physicalCards(game), physicalCards(fixture.afterSetup));
|
||||
const before = battleCardModulesPlayer(game, owner);
|
||||
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
|
||||
const after = battleCardModulesPlayer(done, owner);
|
||||
assert.equal(done.lastBattleContext?.winner, owner);
|
||||
assert.equal(after.spice, before.spice - Math.max(0, dial - 2),
|
||||
'The subsidy is not a personal two-spice grant and R adds no support cost');
|
||||
assert.equal(after.reserves, before.reserves - 3 + 10 - dial);
|
||||
assert.equal(after.tanks, before.tanks + 3 + dial);
|
||||
assert.equal(after.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, owner,
|
||||
'Harass vacates physical control without fabricating a mid-turn card transfer');
|
||||
assert.equal(done.strongholdCards!.claimedTurn, 1);
|
||||
assertMandatoryTechTransfer(fixture, done, owner, opponent);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assert.equal(done.lastBattleContext?.winnerDiscards?.completed, true);
|
||||
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Ix earns Sietch Tabr, spends original mixed reserves before returning suboids, then receives real victory income and Tech', () => {
|
||||
const fixture = createBattleCardModulesFixture({ route: 'ix-sietch' });
|
||||
const { game, owner, opponent } = fixture;
|
||||
assert.equal(fixture.afterSetup.players.find(player => player.id === owner)!.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(fixture.afterMentat.strongholdCards!.owners.sietch_tabr, owner);
|
||||
assert.equal(battleCardModulesPlayer(fixture.afterMentat, owner).forces[fixture.location], 8);
|
||||
assertOriginalShipmentIncome(fixture);
|
||||
const before = battleCardModulesPlayer(game, owner);
|
||||
const preview = viewGame(game, owner).battle!;
|
||||
assert.equal(preview.strongholdEffects[owner], 'sietch_tabr');
|
||||
assert.equal(preview.reinforcements!.normal, 2);
|
||||
assert.equal(preview.reinforcements!.elite, 1);
|
||||
assert.equal(before.reserves, 6);
|
||||
assert.equal(before.elites!.reserves, 4);
|
||||
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
|
||||
const after = battleCardModulesPlayer(done, owner);
|
||||
assert.equal(done.lastBattleContext?.winner, owner);
|
||||
assert.equal(after.reserves, 9, 'Six original reserves minus fee three plus six undialed suboids');
|
||||
assert.equal(after.elites!.reserves, 3,
|
||||
'Returned normal suboids cannot replace the original cyborg in the normal-first fee');
|
||||
assert.equal(after.tanks, 5, 'Three reserve-fee counters plus two dialed suboids');
|
||||
assert.equal(after.elites!.tanks, 1);
|
||||
assert.equal(after.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(after.forces['hidden_mobile_stronghold:0'], 6, 'Original unrelated mobile army stays untouched');
|
||||
assert.equal(after.spice, before.spice + 2, 'Earned Sietch Tabr pays the actual opposing dial');
|
||||
assertMandatoryTechTransfer(fixture, done, owner, opponent);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assert.equal(done.lastBattleContext?.winnerDiscards?.completed, true);
|
||||
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
|
||||
});
|
||||
|
||||
void test('Basic Tech battle gets the decisive score-only R bonus while Harass returns only the seven undialed counters', () => {
|
||||
const fixture = createBattleCardModulesFixture({ advanced: false, ownerLeader: 'weakest', opponentDial: 3 });
|
||||
const { owner, opponent, game } = fixture;
|
||||
assert.equal(game.strongholdCards, undefined);
|
||||
assertOriginalShipmentIncome(fixture);
|
||||
let withoutR = applyAction(game, owner, { ...fixture.plans.both.action, weapon: null });
|
||||
withoutR = applyAction(withoutR, opponent, fixture.plans.opponent.action);
|
||||
const plain = finishBattleCardModules(callBattleCardModules(withoutR));
|
||||
assert.equal(plain.lastBattleContext?.winner, opponent);
|
||||
assert.equal(battleCardModulesPlayer(plain, owner).reserves, 17);
|
||||
assert.equal(battleCardModulesPlayer(plain, owner).tanks, 3);
|
||||
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
|
||||
const after = battleCardModulesPlayer(done, owner);
|
||||
assert.equal(done.lastBattleContext?.winner, owner);
|
||||
assert.equal(after.reserves, 14);
|
||||
assert.equal(after.tanks, 6);
|
||||
assert.equal(after.spice, battleCardModulesPlayer(game, owner).spice);
|
||||
assertMandatoryTechTransfer(fixture, done, owner, opponent);
|
||||
assertUtilitiesDiscarded(done);
|
||||
});
|
||||
|
||||
void test('an actual conserved opposing traitor cancels Harass, not the Basic reserve fee or the actual winner Tech award', () => {
|
||||
const fixture = createBattleCardModulesFixture({ advanced: false, opposingTraitor: true });
|
||||
const { owner, opponent } = fixture;
|
||||
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture), [opponent]));
|
||||
const after = battleCardModulesPlayer(done, owner);
|
||||
assert.equal(done.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext?.winner, opponent);
|
||||
assert.equal(after.reserves, 7, 'The original fee is paid even on the traitor outcome');
|
||||
assert.equal(after.tanks, 13, 'All ten on-board counters die when the opposing traitor cancels the return');
|
||||
assert.equal(after.forces[fixture.location] ?? 0, 0);
|
||||
assertMandatoryTechTransfer(fixture, done, opponent, owner);
|
||||
assertUtilitiesDiscarded(done);
|
||||
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
|
||||
});
|
||||
|
||||
void test('the genuine two-seat Advanced Strongholds-only battle earns Arrakeen without requiring or manufacturing Tech rewards', () => {
|
||||
const fixture = createBattleCardModulesFixture({ players: 2, tech: false });
|
||||
const { owner } = fixture;
|
||||
assert.equal(fixture.game.techTokens, undefined);
|
||||
assert.equal(fixture.afterMentat.strongholdCards!.owners.arrakeen, owner);
|
||||
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
|
||||
const after = battleCardModulesPlayer(done, owner);
|
||||
assert.equal(done.lastBattleContext?.winner, owner);
|
||||
assert.equal(after.spice, battleCardModulesPlayer(fixture.game, owner).spice - 1);
|
||||
assert.equal(after.reserves, 14);
|
||||
assert.equal(after.tanks, 6);
|
||||
assert.equal(done.techTokens, undefined);
|
||||
assertUtilitiesDiscarded(done);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import {
|
||||
advanceIndependentCardModules, createIndependentCardModulesFixture,
|
||||
nextIndependentCardModulesStep, type IndependentCardModulesStep,
|
||||
} from './fixture-independent-card-modules';
|
||||
|
||||
export const MODULE_REINFORCEMENTS = 'ecaz-reinforcements';
|
||||
export const MODULE_HARASS = 'ecaz-harass-withdraw';
|
||||
export type BattleCardModulesStep = IndependentCardModulesStep;
|
||||
export type BattleCardModulesOptions = {
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Native Ixian normal-first mixed fee plus earned Sietch Tabr income. */
|
||||
route?: 'arrakeen' | 'ix-sietch';
|
||||
dial?: number;
|
||||
opponentDial?: number;
|
||||
ownerLeader?: 'strongest' | 'weakest';
|
||||
/** Conserved physical identity exchange, not a synthetic traitor receipt. */
|
||||
opposingTraitor?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
/** Two-seat Advanced Strongholds without Tech uses the same original route. */
|
||||
players?: 2 | 3;
|
||||
};
|
||||
export type BattleCardModulesFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeMentat: Game;
|
||||
mentatStep: BattleCardModulesStep;
|
||||
afterMentat: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: BattleCardModulesStep;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
battleStep: BattleCardModulesStep;
|
||||
/** Both original combatants may now seal their plans. */
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
location: string;
|
||||
route: 'arrakeen' | 'ix-sietch';
|
||||
plans: { both: BattleCardModulesStep; opponent: BattleCardModulesStep };
|
||||
nativeSteps: BattleCardModulesStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
export function battleCardModulesPlayer(game: Game, actor: string) {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing original ${actor} seat`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Only actual native decisions; unsealed plans deliberately remain human work. */
|
||||
export function nextBattleCardModulesStep(game: Game): BattleCardModulesStep | null {
|
||||
return nextIndependentCardModulesStep(game);
|
||||
}
|
||||
export function advanceBattleCardModules(game: Game, until: (state: Game) => boolean,
|
||||
steps: BattleCardModulesStep[] = []): Game {
|
||||
return advanceIndependentCardModules(game, until, steps);
|
||||
}
|
||||
|
||||
/** Explicit physical staging only. All original cards retain exactly one zone. */
|
||||
function positionCard(game: Game, actor: string, id: string, staging: string[]) {
|
||||
const player = battleCardModulesPlayer(game, actor);
|
||||
if (player.hand.some(card => card.id === id)) return;
|
||||
const zones: { label: string; cards: Card[] }[] = [
|
||||
{ label: 'remaining original deck', cards: game.deck },
|
||||
{ label: 'original discard', cards: game.discard },
|
||||
...game.players.filter(p => p.id !== actor).map(p => ({ label: `${p.id}'s hand`, cards: p.hand })),
|
||||
];
|
||||
const source = zones.find(zone => zone.cards.some(card => card.id === id));
|
||||
assert.ok(source, `Original physical ${id} is missing`);
|
||||
if (player.hand.length >= 4) {
|
||||
const index = player.hand.findIndex(card => ![MODULE_HARASS, MODULE_REINFORCEMENTS].includes(card.id));
|
||||
assert.ok(index >= 0);
|
||||
const [returned] = player.hand.splice(index, 1);
|
||||
game.deck.push(returned);
|
||||
staging.push(`Physical staging: ${returned.id}, ${actor}'s hand → remaining original deck (printed hand limit).`);
|
||||
}
|
||||
player.hand.push(source.cards.splice(source.cards.findIndex(card => card.id === id), 1)[0]);
|
||||
staging.push(`Physical staging: conserved ${id}, ${source.label} → ${actor}'s hand; no purchase or replacement face.`);
|
||||
}
|
||||
function positionTraitor(game: Game, actor: string, identity: string, staging: string[]) {
|
||||
const player = battleCardModulesPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0];
|
||||
assert.ok(returned);
|
||||
const reserve = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(reserve >= 0 || donor, 'Requested original identity must still have physical custody');
|
||||
if (reserve >= 0) game.traitorReserve![reserve] = returned;
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Physical staging: conserved traitor ${identity} from ${donor?.id ?? 'original reserve'} exchanged for ${actor}'s ${returned}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Original setup → paid first-turn Ix shipment (when selected) → original END
|
||||
* Mentat ownership → paid next-turn attack → original Heighliners settlement →
|
||||
* native battle/preparation. No phase, wallet, force, owner, control or history
|
||||
* assignment. Original Storm Cards remain unmodified; every possible turn-two
|
||||
* distance leaves these printed strongholds safe after zero opening dials.
|
||||
*/
|
||||
export function createBattleCardModulesFixture(options: BattleCardModulesOptions = {}): BattleCardModulesFixture {
|
||||
const route = options.route ?? 'arrakeen';
|
||||
const advanced = options.advanced ?? true;
|
||||
const tech = options.tech ?? true;
|
||||
const strongholds = options.strongholds ?? advanced;
|
||||
const players = options.players ?? 3;
|
||||
assert.ok(!strongholds || advanced, 'Stronghold Cards require Advanced');
|
||||
assert.ok(!tech || players === 3, 'Original Tech setup requires three seats');
|
||||
assert.ok(route !== 'ix-sietch' || advanced, 'The mixed native fee route is Advanced');
|
||||
assert.ok(route !== 'ix-sietch' || players === 3, 'The native Ix route retains its original three-seat roster');
|
||||
const source = createIndependentCardModulesFixture({ advanced, tech, strongholds,
|
||||
factions: route === 'ix-sietch' ? ['ixians', 'atreides', 'guild'] :
|
||||
players === 2 ? ['emperor', 'atreides'] : ['emperor', 'atreides', 'guild'],
|
||||
expansions: route === 'ix-sietch' ? ['ix'] : [],
|
||||
});
|
||||
let game = source.game;
|
||||
const afterSetup = structuredClone(game);
|
||||
const nativeSteps = source.steps;
|
||||
const staging: string[] = [];
|
||||
const owner = route === 'ix-sietch' ? 'ixians' : 'atreides';
|
||||
const opponent = route === 'ix-sietch' ? 'atreides' : 'emperor';
|
||||
const territory = route === 'ix-sietch' ? 'sietch_tabr' : 'arrakeen';
|
||||
const sector = route === 'ix-sietch' ? 14 : 10;
|
||||
const location = `${territory}:${sector}`;
|
||||
const act = (step: BattleCardModulesStep): Game => {
|
||||
nativeSteps.push(structuredClone(step));
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
return game;
|
||||
};
|
||||
const advance = (until: (state: Game) => boolean) => {
|
||||
game = advanceBattleCardModules(game, until, nativeSteps);
|
||||
};
|
||||
if (route === 'ix-sietch') {
|
||||
advance(g => g.turn === 1 && g.phase === 5 && g.active === owner && clean(g));
|
||||
// The original fourteen reserves contain ten normal and four cyborgs.
|
||||
// Shipping eight normal leaves precisely two normal for the three-force fee.
|
||||
act({ actor: owner, action: { type: 'ship', territory, sector, amount: 8, elite: 0 } });
|
||||
}
|
||||
advance(g => g.turn === 1 && g.phase === 8 && clean(g));
|
||||
if (strongholds) {
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(game.strongholdCards!.owners).every(id => id === null));
|
||||
}
|
||||
let beforeMentat: Game | undefined;
|
||||
let mentatStep: BattleCardModulesStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const next = nextBattleCardModulesStep(game);
|
||||
assert.ok(next);
|
||||
const before = structuredClone(game);
|
||||
const after = act(next);
|
||||
if (after.turn === 2) { beforeMentat = before; mentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeMentat && mentatStep);
|
||||
if (strongholds) {
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 1);
|
||||
assert.equal(game.strongholdCards!.owners[territory], owner);
|
||||
}
|
||||
const afterMentat = structuredClone(game);
|
||||
advance(g => g.turn === 2 && g.phase === 5 && g.active === opponent && clean(g));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const shipmentStep: BattleCardModulesStep = { actor: opponent,
|
||||
action: { type: 'ship', territory, sector, amount: 4, elite: 0 } };
|
||||
act(shipmentStep);
|
||||
const afterShipment = structuredClone(game);
|
||||
advance(g => g.phase === 6 && clean(g));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
|
||||
assert.ok(choice, 'Only the actual paid native armies may open this battle');
|
||||
const battleStep: BattleCardModulesStep = { actor: choice.chooser,
|
||||
action: { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep);
|
||||
advance(g => !!g.battle && !g.battle.preparation &&
|
||||
(!g.battle.preLeader || g.battle.preLeader.closed) && clean(g));
|
||||
positionCard(game, owner, MODULE_REINFORCEMENTS, staging);
|
||||
positionCard(game, owner, MODULE_HARASS, staging);
|
||||
const available = (actor: string) => battleCardModulesPlayer(game, actor).leaders
|
||||
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === territory));
|
||||
const ownLeader = available(owner).sort((a, b) =>
|
||||
(options.ownerLeader === 'weakest' ? a.strength - b.strength : b.strength - a.strength) ||
|
||||
a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = available(opponent).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
if (options.opposingTraitor) positionTraitor(game, opponent, ownLeader.id, staging);
|
||||
const dial = options.dial ?? (route === 'ix-sietch' ? 1 : 3);
|
||||
const opponentDial = options.opponentDial ?? (route === 'ix-sietch' ? 2 : 0);
|
||||
const slot = options.slot ?? 'weapon';
|
||||
const plans = {
|
||||
both: { actor: owner, action: { type: 'battlePlan', dial, support: advanced && route !== 'ix-sietch' ? dial : 0,
|
||||
leader: ownLeader.id, weapon: slot === 'weapon' ? MODULE_REINFORCEMENTS : MODULE_HARASS,
|
||||
defense: slot === 'weapon' ? MODULE_HARASS : MODULE_REINFORCEMENTS } },
|
||||
opponent: { actor: opponent, action: { type: 'battlePlan', dial: opponentDial,
|
||||
support: advanced ? opponentDial : 0, leader: otherLeader.id } },
|
||||
} satisfies Record<string, BattleCardModulesStep>;
|
||||
return { initial: source.initial, afterSetup, beforeMentat, mentatStep, afterMentat,
|
||||
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, game,
|
||||
owner, opponent, location, route, plans, nativeSteps, staging };
|
||||
}
|
||||
|
||||
export function sealBattleCardModules(fixture: BattleCardModulesFixture,
|
||||
steps: BattleCardModulesStep[] = []): Game {
|
||||
let game = fixture.game;
|
||||
for (const step of [fixture.plans.both, fixture.plans.opponent]) {
|
||||
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function callBattleCardModules(game: Game, calls: readonly string[] = [],
|
||||
steps: BattleCardModulesStep[] = []): Game {
|
||||
for (const actor of viewGame(game, game.host).battle!.traitorVoters) {
|
||||
const step: BattleCardModulesStep = { actor, action: { type: 'traitorCall', call: calls.includes(actor) } };
|
||||
steps.push(step); game = applyAction(game, actor, step.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
/** Completes original mandatory Tech/cleanup/aftercare, including automatic phase income. */
|
||||
export function finishBattleCardModules(game: Game, steps: BattleCardModulesStep[] = []): Game {
|
||||
return advanceBattleCardModules(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard, steps);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type IndependentCardModulesStep = { actor: string; action: Action };
|
||||
export type IndependentCardModulesOptions = {
|
||||
advanced?: boolean;
|
||||
factions?: readonly FactionId[];
|
||||
expansions?: Game['expansions'];
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
|
||||
/** Select original modules and independent cards before the undealt native setup.
|
||||
* No armies, wallets, module owners, phase or history are assigned afterward. */
|
||||
export function createIndependentCardModulesFixture(options: IndependentCardModulesOptions = {}) {
|
||||
const factions = options.factions ?? ['emperor', 'atreides', 'guild'];
|
||||
const advanced = options.advanced ?? false;
|
||||
assert.ok(factions.length >= 2);
|
||||
let game = createGame('INDEPENDENTCARDMODULES', newPlayer(factions[0], factions[0], factions[0]),
|
||||
advanced, options.expansions ?? []);
|
||||
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (options.tech ?? true) game.techTokens = createTechTokens();
|
||||
if (options.strongholds) game.strongholdCards = createStrongholdCards();
|
||||
const steps: IndependentCardModulesStep[] = [];
|
||||
for (const player of game.players) {
|
||||
const step: IndependentCardModulesStep = { actor: player.id, action: { type: 'ready' } };
|
||||
steps.push(step); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
const initial = structuredClone(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 { game = initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
game = advanceIndependentCardModules(game, state => state.status === 'playing', steps);
|
||||
return { initial, game, steps };
|
||||
}
|
||||
|
||||
/** Reuse original native choices, including all phase/module windows. */
|
||||
export function nextIndependentCardModulesStep(game: Game): IndependentCardModulesStep | null {
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
|
||||
export function advanceIndependentCardModules(game: Game, until: (state: Game) => boolean,
|
||||
steps: IndependentCardModulesStep[] = []): Game {
|
||||
let state = game;
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(state)) return state;
|
||||
assert.notEqual(state.status, 'finished', 'Programme ended before its requested native boundary');
|
||||
const step = nextIndependentCardModulesStep(state);
|
||||
assert.ok(step, 'Supply the actual human Battle Plans before continuing');
|
||||
steps.push(structuredClone(step)); state = applyAction(state, step.actor, step.action);
|
||||
}
|
||||
throw Error('Independent module programme did not reach its requested boundary');
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import type { Card } from '../game/cards';
|
||||
import {
|
||||
advanceIndependentCardModules, createIndependentCardModulesFixture,
|
||||
nextIndependentCardModulesStep, type IndependentCardModulesStep,
|
||||
} from './fixture-independent-card-modules';
|
||||
|
||||
export type RecruitsModulesStep = IndependentCardModulesStep;
|
||||
export type RecruitsModulesRoster = 'classic' | 'ix-native';
|
||||
export type RecruitsModulesOptions = {
|
||||
/** Original Advanced rules; the independent profile still refuses other overlays. */
|
||||
advanced?: boolean;
|
||||
/** Original Advanced Stronghold Cards; requires Advanced. */
|
||||
strongholds?: boolean;
|
||||
/** Clean classic roster, or the native Ixian three-seat pool with its own token. */
|
||||
roster?: RecruitsModulesRoster;
|
||||
};
|
||||
export type RecruitsModulesFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
afterBattle: Game;
|
||||
/** Quiet turn-two Revival before any Recruits activation, with the card held. */
|
||||
game: Game;
|
||||
/** After the actual Recruits activation and before any revival. */
|
||||
beforeRevival: Game;
|
||||
/** After the holder's real revival; Axlotl income is accrued but unpaid. */
|
||||
afterRevival: Game;
|
||||
/** After the opponent's real revival in the same phase; income must not repeat. */
|
||||
afterSecondRevival: Game;
|
||||
/** Turn-two Shipment after the original Revival phase settled its tokens. */
|
||||
afterPhaseEnd: Game;
|
||||
/** Actual Axlotl custodian chosen by the original first Storm. */
|
||||
owner: string;
|
||||
opponent: string;
|
||||
location: { territory: string; sector: number };
|
||||
/** Ordinary rate/limit before the activation, measured on the real clone. */
|
||||
base: { freeRate: number; limit: number };
|
||||
/** Rate/limit the actual activation produced. */
|
||||
doubled: { freeRate: number; limit: number };
|
||||
tanks: { reviver: number; opponent: number };
|
||||
amount: number;
|
||||
/** Tokens the custodian actually owns when the free revival accrues income. */
|
||||
accrual: number;
|
||||
playAction: Action;
|
||||
revivalAction: Action;
|
||||
secondRevivalAction: Action;
|
||||
battle: {
|
||||
attacker: string; defender: string; territory: string;
|
||||
reviverPlan: RecruitsModulesStep; opponentPlan: RecruitsModulesStep;
|
||||
};
|
||||
steps: RecruitsModulesStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
const CLASSIC_ROSTER: FactionId[] = ['emperor', 'atreides', 'guild'];
|
||||
const IX_NATIVE_ROSTER: FactionId[] = ['ixians', 'atreides', 'guild'];
|
||||
/** The printed native stronghold positions that start occupied on the board. */
|
||||
const NATIVE_STRONGHOLD: Readonly<Record<string, { territory: string; sector: number }>> = {
|
||||
atreides: { territory: 'arrakeen', sector: 10 },
|
||||
guild: { territory: 'tueks_sietch', sector: 5 },
|
||||
};
|
||||
|
||||
export const recruitsModulesPlayer = (game: Game, id: string): Player => {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing original ${id} seat`);
|
||||
return player;
|
||||
};
|
||||
export const recruitsModulesClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
|
||||
const boardCount = (player: Player): number =>
|
||||
Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
const eliteCount = (player: Player): number =>
|
||||
player.elites
|
||||
? player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
|
||||
: 0;
|
||||
|
||||
/** Reuse the shared original native continuation unchanged. */
|
||||
export function advanceRecruitsModules(
|
||||
game: Game, until: (state: Game) => boolean, steps: RecruitsModulesStep[] = [],
|
||||
): Game {
|
||||
return advanceIndependentCardModules(game, until, steps);
|
||||
}
|
||||
|
||||
/** Physically conserve one existing face between original zones; no minted copy. */
|
||||
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
|
||||
const player = recruitsModulesPlayer(game, actor);
|
||||
if (player.hand.some(card => card.id === id)) return player.hand.find(card => card.id === id)!;
|
||||
const zones: { label: string; cards: Card[] }[] = [
|
||||
{ label: 'remaining original deck', cards: game.deck },
|
||||
{ label: 'original discard', cards: game.discard },
|
||||
...game.players.filter(other => other.id !== actor)
|
||||
.map(other => ({ label: `${other.id}'s hand`, cards: other.hand })),
|
||||
];
|
||||
const source = zones.find(zone => zone.cards.some(card => card.id === id));
|
||||
assert.ok(source, `Original physical ${id} is missing from every retained zone`);
|
||||
const limit = player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4;
|
||||
if (player.hand.length >= limit) {
|
||||
const index = player.hand.findIndex(card => card.id !== id);
|
||||
assert.ok(index >= 0, `${actor} must have a non-Recruits card to return`);
|
||||
const [returned] = player.hand.splice(index, 1);
|
||||
game.deck.push(returned);
|
||||
staging.push(`Physical staging: ${returned.id}, ${actor}'s hand → remaining original deck (printed hand limit).`);
|
||||
}
|
||||
const [card] = source.cards.splice(source.cards.findIndex(candidate => candidate.id === id), 1);
|
||||
player.hand.push(card);
|
||||
staging.push(`Physical staging: conserved ${id}, ${source.label} → ${actor}'s hand; no purchase, deal or replacement face.`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Original counters, tokens and Treachery faces stay conserved against the baseline. */
|
||||
export function assertRecruitsModulesInventory(game: Game, baseline: Game): void {
|
||||
for (const expected of baseline.players) {
|
||||
const player = recruitsModulesPlayer(game, expected.id);
|
||||
assert.equal(player.reserves + player.tanks + boardCount(player),
|
||||
expected.reserves + expected.tanks + boardCount(expected),
|
||||
`${player.id} must retain its physical normal counters`);
|
||||
assert.equal(eliteCount(player), eliteCount(expected),
|
||||
`${player.id} must retain its physical special counters`);
|
||||
}
|
||||
const physical = (state: Game) => allCardZones(state).map(card => card.id).sort();
|
||||
assert.deepEqual(physical(game), physical(baseline),
|
||||
'Every original Treachery face must retain exactly its one physical zone');
|
||||
assert.equal(allCardZones(game).filter(card => card.id === RECRUITS_CARD_ID).length, 1,
|
||||
'The independent Recruits face must stay a single physical card');
|
||||
for (const [id, token] of Object.entries(game.techTokens ?? {}))
|
||||
assert.ok(token.owner === null || game.players.some(player => player.id === token.owner),
|
||||
`${id} must keep seated token custody`);
|
||||
}
|
||||
/** Every original Treachery face that is still held: hands, deck, discard and unsold auction. */
|
||||
function allCardZones(state: Game): Card[] {
|
||||
return [
|
||||
...state.deck,
|
||||
...state.discard,
|
||||
...state.players.flatMap(player => player.hand),
|
||||
...(state.auction?.cards.slice(state.auction.index) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genuine independent Recruits + original Tech programme. The original first
|
||||
* Storm decides Axlotl custody; that custodian commits real counters in a won
|
||||
* battle during the same turn, putting its actual dialed casualties in the
|
||||
* Tanks. Next turn's real Revival plays the physical Recruits and returns them.
|
||||
* Advanced Strongholds and the native Ixian pool are selected through the
|
||||
* shared setup factory; no wallet, owner, phase, receipt or history is assigned.
|
||||
*/
|
||||
export function createRecruitsModulesFixture(
|
||||
options: RecruitsModulesOptions = {},
|
||||
): RecruitsModulesFixture {
|
||||
const roster = options.roster ?? 'classic';
|
||||
const advanced = options.advanced ?? false;
|
||||
const strongholds = options.strongholds ?? false;
|
||||
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
|
||||
const source = createIndependentCardModulesFixture({
|
||||
advanced, strongholds, tech: true,
|
||||
factions: roster === 'classic' ? [...CLASSIC_ROSTER] : [...IX_NATIVE_ROSTER],
|
||||
expansions: roster === 'classic' ? [] : ['ix'],
|
||||
});
|
||||
const steps: RecruitsModulesStep[] = source.steps;
|
||||
const staging: string[] = [];
|
||||
const initial = source.initial;
|
||||
const afterSetup = structuredClone(source.game);
|
||||
let game = source.game;
|
||||
const act = (actor: string, action: Action) => {
|
||||
steps.push({ actor, action: structuredClone(action) });
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
const advance = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return;
|
||||
assert.notEqual(game.status, 'finished', 'The original programme ended before its requested boundary');
|
||||
const step = nextIndependentCardModulesStep(game);
|
||||
assert.ok(step, 'The fixture must supply the actual human Battle Plans before continuing');
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
throw Error('The original independent-card programme did not reach its requested boundary');
|
||||
};
|
||||
// Fix only original first-Storm randomness; the engine still assigns custody.
|
||||
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 { advance(state => state.turn === 1 && state.phase === 1 && recruitsModulesClean(state)); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
const owner = game.techTokens?.axlotl.owner;
|
||||
assert.ok(owner, 'The original first Storm must decide Axlotl custody');
|
||||
conserveCard(game, owner, RECRUITS_CARD_ID, staging);
|
||||
staging.push(`Conserved the physical Recruits after setup: ${owner} is the actual Axlotl custodian the original first Storm selected.`);
|
||||
const reviver = recruitsModulesPlayer(game, owner);
|
||||
const others = game.players.filter(player => player.id !== owner).map(player => player.id);
|
||||
const ownStronghold = NATIVE_STRONGHOLD[reviver.faction];
|
||||
// Only real board positions with at least four printed counters can absorb the
|
||||
// winner's dialed losses; everyone else must ship an attacking force instead.
|
||||
const defends = !!ownStronghold && boardCount(reviver) >= 4;
|
||||
const target = defends
|
||||
? reviver.faction
|
||||
: (others.some(id => NATIVE_STRONGHOLD[recruitsModulesPlayer(game, id).faction]?.territory === 'tueks_sietch')
|
||||
? 'guild' : 'atreides');
|
||||
const opponent = defends
|
||||
? (roster === 'ix-native'
|
||||
? others.find(id => recruitsModulesPlayer(game, id).faction === 'ixians')!
|
||||
: others.find(id => !!NATIVE_STRONGHOLD[recruitsModulesPlayer(game, id).faction])!)
|
||||
: others.find(id => recruitsModulesPlayer(game, id).faction === target)!;
|
||||
const location = NATIVE_STRONGHOLD[target];
|
||||
assert.ok(location, 'The selected battle needs an original stronghold position');
|
||||
const base = viewGame(game, owner).revival;
|
||||
const baseFreeRate = base.freeRemaining;
|
||||
const baseLimit = base.limit;
|
||||
const doubledFreeRate = Math.min(baseFreeRate * 2, 7);
|
||||
const wallet = reviver.spice;
|
||||
let amount = 0;
|
||||
for (let candidate = Math.min(6, defends ? boardCount(reviver) : 6); candidate >= 4; candidate--) {
|
||||
const paid = Math.max(0, candidate - doubledFreeRate);
|
||||
// The attacker also pays the original one-spice stronghold transport per force.
|
||||
if (wallet >= (defends ? 0 : candidate) + 2 * paid + 1) { amount = candidate; break; }
|
||||
}
|
||||
assert.ok(amount >= 4,
|
||||
`The actual ${owner} wallet and board must support a four-force revival beyond the ordinary limit`);
|
||||
const aggressor = defends ? opponent : owner;
|
||||
advance(state => state.turn === 1 && state.phase === 5 && state.active === aggressor && recruitsModulesClean(state));
|
||||
act(aggressor, {
|
||||
type: 'ship', territory: location.territory, sector: location.sector,
|
||||
amount: defends ? 1 : amount, elite: 0,
|
||||
});
|
||||
advance(state => state.phase === 6 && recruitsModulesClean(state));
|
||||
const choice = viewGame(game, aggressor).battleChoices
|
||||
.find(candidate => candidate.territory === location.territory);
|
||||
assert.ok(choice, 'The actual shipment must open a real battle in that stronghold');
|
||||
assert.deepEqual([choice.attacker, choice.defender].sort(), [owner, opponent].sort(),
|
||||
'Only the actual Axlotl custodian and its chosen opponent may be in this battle');
|
||||
act(choice.chooser, { type: 'chooseBattle', territory: location.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
advance(state => !!state.battle && !state.battle.revealed && !state.battle.preparation &&
|
||||
(!state.battle.preLeader || state.battle.preLeader.closed) && recruitsModulesClean(state));
|
||||
const available = (id: string) => recruitsModulesPlayer(game, id).leaders
|
||||
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === location.territory));
|
||||
const strongest = available(owner).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
const weakest = available(opponent).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(strongest && weakest, 'Both real combatants need an available leader');
|
||||
// Typed original pools count the dial in half-strength steps; the winner's
|
||||
// losses still equal the committed physical counters it actually dialed.
|
||||
const typedDial = advanced || reviver.faction === 'ixians' ? amount / 2 : amount;
|
||||
const reviverPlan: RecruitsModulesStep = { actor: owner,
|
||||
action: { type: 'battlePlan', dial: typedDial, support: 0, leader: strongest.id } };
|
||||
const opponentPlan: RecruitsModulesStep = { actor: opponent,
|
||||
action: { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id } };
|
||||
act(reviverPlan.actor, reviverPlan.action);
|
||||
act(opponentPlan.actor, opponentPlan.action);
|
||||
advance(state => !state.battle && !state.decision && state.phase >= 7 && recruitsModulesClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
const tanks = { reviver: recruitsModulesPlayer(game, owner).tanks,
|
||||
opponent: recruitsModulesPlayer(game, opponent).tanks };
|
||||
assert.equal(tanks.reviver, amount, 'The real battle must send exactly the committed counters to the Tanks');
|
||||
assert.ok(tanks.opponent >= 1, 'The actual losing force must reach the Tanks');
|
||||
// Next turn's real Revival phase; the original first-Storm and battle history
|
||||
// are untouched, so the token assignment the collision produced is real.
|
||||
advance(state => state.turn === 2 && state.phase === 4 && recruitsModulesClean(state));
|
||||
const gameAtRevival = structuredClone(game);
|
||||
const accrual = game.techTokens ? ownedTech(game.techTokens, owner).length : 0;
|
||||
assert.ok(accrual >= 1, 'The actual Axlotl custodian must own its physical tokens');
|
||||
const playAction: Action = { type: 'card', card: RECRUITS_CARD_ID };
|
||||
act(owner, playAction);
|
||||
const beforeRevival = structuredClone(game);
|
||||
assert.equal(beforeRevival.recruits?.player, owner, 'The physical Recruits activation must be recorded');
|
||||
const revivalAction: Action = { type: 'revive', amount, elite: 0 };
|
||||
act(owner, revivalAction);
|
||||
const afterRevival = structuredClone(game);
|
||||
const secondRevivalAction: Action = { type: 'revive', amount: 1, elite: 0 };
|
||||
act(opponent, secondRevivalAction);
|
||||
const afterSecondRevival = structuredClone(game);
|
||||
assert.equal(game.techTokens?.axlotl.spice, accrual,
|
||||
'The accrued income must still be held by the token immediately before the phase ends');
|
||||
advance(state => state.phase === 5 && recruitsModulesClean(state));
|
||||
const afterPhaseEnd = structuredClone(game);
|
||||
assertRecruitsModulesInventory(gameAtRevival, afterSetup);
|
||||
assertRecruitsModulesInventory(afterBattle, afterSetup);
|
||||
assertRecruitsModulesInventory(afterRevival, afterSetup);
|
||||
assertRecruitsModulesInventory(afterSecondRevival, afterSetup);
|
||||
assertRecruitsModulesInventory(afterPhaseEnd, afterSetup);
|
||||
return {
|
||||
initial, afterSetup, afterBattle, game: gameAtRevival, beforeRevival, afterRevival,
|
||||
afterSecondRevival, afterPhaseEnd, owner, opponent, location,
|
||||
base: { freeRate: baseFreeRate, limit: baseLimit },
|
||||
doubled: { freeRate: doubledFreeRate, limit: 7 },
|
||||
tanks, amount, accrual, playAction, revivalAction, secondRevivalAction,
|
||||
battle: {
|
||||
attacker: choice.attacker, defender: choice.defender,
|
||||
territory: location.territory, reviverPlan, opponentPlan,
|
||||
},
|
||||
steps, staging,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import {
|
||||
advanceRecruitsModules, assertRecruitsModulesInventory, createRecruitsModulesFixture,
|
||||
recruitsModulesClean, recruitsModulesPlayer, type RecruitsModulesOptions,
|
||||
} from './fixture-recruits-modules';
|
||||
|
||||
/**
|
||||
* GF9 Ecaz & Moritani pp11,16: Recruits doubles every player's current free
|
||||
* revival rate and raises the ordinary force-revival limit to seven. The
|
||||
* independent-profile programme uses the real original first-Storm Axlotl
|
||||
* custodian, real battle Tanks and a real Revival; Tech Tokens stay original
|
||||
* and the Axlotl payout waits for that Revival phase to end.
|
||||
*/
|
||||
const CONFIGURATIONS: { label: string; options: RecruitsModulesOptions }[] = [
|
||||
{ label: 'Basic classic', options: {} },
|
||||
{ label: 'Advanced classic with Stronghold Cards', options: { advanced: true, strongholds: true } },
|
||||
{ label: 'Basic native Ixian', options: { roster: 'ix-native' } },
|
||||
{ label: 'Advanced native Ixian with Stronghold Cards', options: { roster: 'ix-native', advanced: true, strongholds: true } },
|
||||
];
|
||||
|
||||
for (const { label, options } of CONFIGURATIONS) {
|
||||
void test(`${label}: doubled physical revival retains its price and pays Axlotl once at phase end`, () => {
|
||||
const fixture = createRecruitsModulesFixture(options);
|
||||
const ordinary = new Map(fixture.game.players.map(player =>
|
||||
[player.id, viewGame(fixture.game, player.id).revival.freeRemaining]));
|
||||
const original = structuredClone(fixture.game);
|
||||
assert.throws(() => applyAction(original, fixture.owner, fixture.revivalAction));
|
||||
assert.deepEqual(original, fixture.game, 'The old three-force allowance cannot consume these counters');
|
||||
|
||||
const activated = applyAction(fixture.game, fixture.owner, fixture.playAction);
|
||||
assert.equal(activated.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
for (const player of activated.players) {
|
||||
const preview = viewGame(activated, player.id).recruitsPreview;
|
||||
const rate = preview?.rates.find(entry => entry.player === player.id);
|
||||
assert.equal(rate?.limit, 7);
|
||||
assert.equal(rate?.freeRate, Math.min(2 * ordinary.get(player.id)!, 7),
|
||||
'Every seat retains its own doubled current rate');
|
||||
}
|
||||
const before = recruitsModulesPlayer(activated, fixture.owner);
|
||||
const returned = applyAction(activated, fixture.owner, fixture.revivalAction);
|
||||
const owner = recruitsModulesPlayer(returned, fixture.owner);
|
||||
assert.equal(owner.revived, fixture.amount);
|
||||
assert.equal(owner.freeForcesRevived, fixture.doubled.freeRate);
|
||||
assert.equal(owner.reserves, before.reserves + fixture.amount);
|
||||
assert.equal(owner.tanks, 0);
|
||||
assert.equal(owner.spice, before.spice - 2 * (fixture.amount - fixture.doubled.freeRate),
|
||||
'Only paid counters cost spice; pending token income cannot fund this request');
|
||||
const accrual = ownedTech(returned.techTokens!, fixture.owner).length;
|
||||
assert.equal(returned.techTokens!.axlotl.spice, accrual);
|
||||
|
||||
const second = applyAction(returned, fixture.opponent, fixture.secondRevivalAction);
|
||||
assert.equal(recruitsModulesPlayer(second, fixture.opponent).freeForcesRevived, 1);
|
||||
assert.equal(second.techTokens!.axlotl.spice, accrual,
|
||||
'A second actual free revival does not repeat income');
|
||||
assert.equal(recruitsModulesPlayer(second, fixture.owner).spice, owner.spice,
|
||||
'Neither free return collects the pending income');
|
||||
|
||||
const settled = advanceRecruitsModules(second, state => state.phase === 5 && recruitsModulesClean(state));
|
||||
assert.equal(recruitsModulesPlayer(settled, fixture.owner).spice, owner.spice + accrual,
|
||||
'The phase boundary pays the original held income exactly once');
|
||||
assert.equal(settled.techTokens!.axlotl.spice, 0);
|
||||
assertRecruitsModulesInventory(settled, fixture.afterSetup);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user