Compose independent utility cards with classic Leader Skills

This commit is contained in:
Quintine committed 2026-10-17 14:17:24 +11:00
1 parent 40a5e51229
commit 5a32c1ac85
23 files changed
+1845 -87

No files matched your search

+73
View File
@@ -15,6 +15,7 @@ import {
} from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
createRicheseNoField,
deployRicheseNoField,
@@ -1156,3 +1157,75 @@ void test('canceling an allied traitor into a real explosion checks uninvolved f
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
function independentPlanetologistRole(
effect: 'reinforcements' | 'harassWithdraw' | 'recruits', alternate = false,
): BattleResolutionInput {
const game = input(), cards = ecazTreacheryCards();
const weapon = cards.find(card => card.effect === effect)!;
const defense = cards.find(card => card.effect === (effect === 'harassWithdraw' ? 'reinforcements' : 'harassWithdraw'))!;
game.physicalCards = [...baseDeck(), ...cards];
game.attacker.hand = [weapon, defense];
game.attacker.plan.weapon = weapon.id;
game.attacker.plan.defense = defense.id;
if (alternate) game.attacker.plan.planetologistSpecial = true;
game.attacker.leaderSkills = [{
skill: 'planetologist', leader: game.attacker.leader!.id, faceUp: false, captured: false,
}];
if ((effect === 'reinforcements' && !alternate) || defense.effect === 'reinforcements')
game.attacker.reinforcementsReserves = { normal: 3, elite: 0 };
if ((effect === 'harassWithdraw' && !alternate) || defense.effect === 'harassWithdraw')
game.attacker.harassWithdraw = { blocked: null, forces: game.attacker.forces,
locations: { 'arrakeen:10': { normal: 5, elite: 0 } } };
return game;
}
void test('Planetologist chooses between native Reinforcements and its green Special role without combining fees or bonuses', () => {
const native = quoteBattleResolution(independentPlanetologistRole('reinforcements'));
const alternate = quoteBattleResolution(independentPlanetologistRole('reinforcements', true));
assert.equal(native.scores!.attacker, 7);
assert.equal(alternate.scores!.attacker, 7);
assert.deepEqual(native.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
assert.deepEqual(alternate.reinforcements ?? [], []);
assert.equal(native.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
assert.equal(alternate.leaderSkillBonuses!.attacker.bonus, 2);
for (const quote of [native, alternate]) {
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 3, elite: 0 });
assert.equal(quote.payments.find(payment => payment.player === 'a')!.ownPayment, 2);
assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
assert.ok(quote.discarded.some(card => card.player === 'a' && card.card === 'ecaz-reinforcements'));
}
});
void test('Planetologist Harass substitution suppresses withdrawal but leaves a distinct defense-slot Reinforcements native', () => {
const quote = quoteBattleResolution(independentPlanetologistRole('harassWithdraw', true));
assert.equal(quote.scores!.attacker, 9);
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
assert.deepEqual(quote.harassWithdraw ?? [], []);
assert.deepEqual(quote.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
});
void test('green Recruits uses the ordinary Planetologist substitution and a killed trainer gains no alternate strength', () => {
const recruits = quoteBattleResolution(independentPlanetologistRole('recruits'));
assert.equal(recruits.leaderSkillBonuses!.attacker.bonus, 2);
assert.equal(recruits.scores!.attacker, 7);
assert.ok(recruits.discarded.some(card => card.card === 'ecaz-recruits'));
const doomed = independentPlanetologistRole('reinforcements', true);
play(doomed, 'defender', 'weapon', card => card.kind === 'poison');
const death = quoteBattleResolution(doomed);
assert.equal(death.leaderDeaths.attacker, true);
assert.equal(death.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
assert.equal(death.scores!.attacker, 2);
assert.deepEqual(death.reinforcements ?? [], []);
assert.deepEqual(death.harassWithdraw![0].returned, { normal: 3, elite: 0 });
});
void test('an explicit Planetologist alternate role cannot be forged without its actual trainer', () => {
const game = independentPlanetologistRole('reinforcements', true);
game.attacker.leaderSkills = [];
reject(game);
const malformed = independentPlanetologistRole('reinforcements', true);
Object.assign(malformed.attacker.plan, { planetologistSpecial: false });
reject(malformed);
});
+477
View File
@@ -0,0 +1,477 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { SukRescueOption } from '../game/suk-graduate';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { usesNativeBattleSpecial } from '../game/battle-card-slots';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type IndependentSkillsBattleStep = { actor: string; action: Action };
/** 'same-plan' trains the opposing Warmaster; 'traitor-cancel' adds its real
* Traitor call; 'suk' trains the owner's Suk Graduate for a real rescue choice. */
export type IndependentSkillsBattleCase = 'same-plan' | 'traitor-cancel' | 'suk';
export type IndependentSkillsBattleSkillSet = {
owner: LeaderSkillId;
opponent: LeaderSkillId;
observer: LeaderSkillId;
};
export type IndependentSkillsBattleOptions = {
/** Continue only the original fresh classic lobby built by this fixture. */
initial?: Game;
advanced?: boolean;
seatIds?: readonly string[];
case?: IndependentSkillsBattleCase;
/** Construction hook: request exact original skill cards for one seat. */
skills?: Partial<IndependentSkillsBattleSkillSet>;
};
export type IndependentSkillsBattleSetup = {
initial: Game;
offered: Game;
afterSetup: Game;
owner: string;
opponent: string;
observer: string;
skills: IndependentSkillsBattleSkillSet;
trainers: Record<'owner' | 'opponent' | 'observer', string>;
actions: IndependentSkillsBattleStep[];
staging: string[];
};
export type IndependentSkillsBattleFixture = IndependentSkillsBattleSetup & {
case: IndependentSkillsBattleCase;
advanced: boolean;
location: string;
first: string;
second: string;
beforeShipment: Game;
shipment: IndependentSkillsBattleStep;
afterShipment: Game;
move: IndependentSkillsBattleStep;
afterMove: Game;
opponentMove: IndependentSkillsBattleStep;
beforeBattle: Game;
battleStep: IndependentSkillsBattleStep;
/** Revealed window: both original plans sealed, postures committed. */
game: Game;
plans: IndependentSkillsBattleStep[];
returns: HarassWithdrawSelection;
/** Real posture decision per seat id: true hides the skilled leader behind the shield. */
hide: Record<string, boolean>;
/** Attacker leader disc the opposing Traitor matches, for the control case. */
traitor: string | null;
};
export const independentSkillsBattlePlayer = (game: Game, actor: string): Player => {
const player = game.players.find((seat) => seat.id === actor);
assert.ok(player, `Missing original classic seat ${actor}`);
return player;
};
export const independentSkillsBattleClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
/** Conserved original thirty-six cards, all fourteen skills and ordinary counters. */
export function assertIndependentSkillsBattleCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + sum(player.forces), 20, player.faction);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every((count) => Number.isSafeInteger(count) && count >= 0), player.faction);
if (player.elites) assert.equal(
player.elites.reserves + player.elites.tanks + sum(player.elites.forces),
player.faction === 'emperor' ? 5 : 3, player.faction);
assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4), player.faction);
for (const [key, count] of Object.entries(player.forces)) {
assert.ok(!key.includes('undefined') && !key.startsWith('homeworld:'), key);
assert.ok(Number.isInteger(count) && count >= 0, key);
}
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.equal(cards.length, 36);
assert.deepEqual(cards.map((card) => card.id).sort(),
[...treacheryDeck([]), ...ecazTreacheryCards()].map((card) => card.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((p) => p.traitors)].sort(),
traitorDeck(game.players).sort());
}
/** Actual Fisher-Yates draws for one desired order; never a held-card injection. */
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const working = [...source], draws: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296));
[working[index], working[from]] = [working[from], working[index]];
}
assert.deepEqual(working, desired);
return draws;
}
/** Only the original skill lottery is solved; later native shuffles keep real draws. */
function skillLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < draws.length ? draws[cursor++] : 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function defaultSkills(caseKind: IndependentSkillsBattleCase): IndependentSkillsBattleSkillSet {
return caseKind === 'suk'
? { owner: 'suk-graduate', opponent: 'master-of-assassins', observer: 'rihani-decipherer' }
: { owner: 'prana-bindu-adept', opponent: 'warmaster', observer: 'rihani-decipherer' };
}
/** Private two-card offers are dealt in player order, so the first two positions
* of the solved deck decide the requesting seat's real choice. */
function skillDeckOrder(skills: IndependentSkillsBattleSkillSet): LeaderSkillId[] {
const requested = [skills.owner, skills.opponent, skills.observer];
const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id));
return requested.flatMap((id) => [id, rest.shift()!]).concat(rest);
}
/** Genuine classic all-fourteen training from a fresh READY lobby whose independent
* three-card marker is staged only after every ready action. */
export function createIndependentSkillsBattleSetup(
options: IndependentSkillsBattleOptions = {},
): IndependentSkillsBattleSetup {
const caseKind = options.case ?? 'same-plan';
const ids = options.seatIds ?? ['emperor', 'guild', 'harkonnen'];
assert.equal(ids.length, 3);
const factions: FactionId[] = ['emperor', 'guild', 'harkonnen'];
let game = options.initial ? structuredClone(options.initial)
: createGame('INDEPENDENTSKILLSBATTLE', newPlayer(ids[0], 'Emperor', factions[0]), options.advanced ?? false, []);
if (!options.initial) for (let index = 1; index < ids.length; index++)
joinGame(game, newPlayer(ids[index], ids[index], factions[index]));
assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, []);
assert.ok(!game.leaderSkills && !game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards, 'Only the original classic composition is accepted.');
assert.equal(game.status, 'lobby', 'Continue only an original fresh classic lobby.');
const staging: string[] = [];
const actions: IndependentSkillsBattleStep[] = [];
const owner = game.players[0].id, opponent = game.players[1].id, observer = game.players[2].id;
const skills = { ...defaultSkills(caseKind), ...options.skills };
const deckOrder = skillDeckOrder(skills);
assert.equal(deckOrder.length, LEADER_SKILL_CARDS.length);
const initial = structuredClone(game);
for (const player of game.players) if (!player.ready) {
const action: Action = { type: 'ready' };
actions.push({ actor: player.id, action });
game = applyAction(game, player.id, action);
}
game.ecazTreachery = true;
staging.push('Ready actions precede the independent three-card marker: the original lobby rejects ecazTreachery before every seat is ready.');
const draws = permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), deckOrder);
game = skillLottery(draws, () => initializeLeaderSkillsGameForAudit(game));
staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws; every later single-word draw in the same initializer kept a fixed maximum value, and larger native requests were forwarded unchanged.');
assert.equal(game.status, 'setup');
assert.equal(game.turn, 1);
assert.equal(game.phase, 0);
const offered = structuredClone(game);
const requested: Record<string, LeaderSkillId> = {
[owner]: skills.owner, [opponent]: skills.opponent, [observer]: skills.observer,
};
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let step: IndependentSkillsBattleStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const wanted = requested[actor];
assert.ok(offer.cards.includes(wanted), 'The requested original skill must be in the real offer');
assert.ok(!view.unavailableSkills?.[wanted], 'The requested original skill must be selectable');
const leader = independentSkillsBattlePlayer(game, actor).leaders
.filter((disc) => view.eligibleLeaders.some((eligible) => eligible.id === disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id } };
} else {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'The original setup continuation stopped before training completed');
step = next;
}
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
assertIndependentSkillsBattleCustody(game);
const trained = (id: string) => game.leaderSkills!.assignments.find((a) => a.owner === id)!.leader;
for (const [actor, skill] of Object.entries(requested))
assert.equal(game.leaderSkills!.assignments.find((a) => a.owner === actor)!.skill, skill);
return { initial, offered, afterSetup: structuredClone(game), owner, opponent, observer, skills,
trainers: { owner: trained(owner), opponent: trained(opponent), observer: trained(observer) },
actions, staging };
}
export type IndependentSkillsBattlePolicy = {
hide?: Record<string, boolean>;
/** Real Guild choice of the Advanced movement slot: 'last' defers to the end. */
guildTiming?: 'first' | 'last';
};
/** Only genuine native decisions; a human plan, Traitor call, withdrawal allocation
* or Suk rescue choice is never invented by this helper. */
export function nextIndependentSkillsBattleStep(
game: Game,
policy: IndependentSkillsBattlePolicy = {},
): IndependentSkillsBattleStep | null {
if (!game.response && !game.phaseOpening) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: {
type: 'leaderSkillVisibility', event: decision.event, hide: policy.hide?.[decision.player] ?? false,
} };
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: (policy.guildTiming ?? 'last') === 'first' } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'sukRescue' || decision?.kind === 'harassWithdraw') return null;
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIndependentSkillsBattle(game: Game, until: (state: Game) => boolean,
actions?: IndependentSkillsBattleStep[], policy: IndependentSkillsBattlePolicy = {}): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextIndependentSkillsBattleStep(game, policy);
assert.ok(step, 'Supply the genuine human plan, allocation or rescue choice');
actions?.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original classic all-fourteen programme did not reach its boundary');
}
/** Conserved physical card custody: remaining deck or discard → a real hand. */
function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): Card {
const player = independentSkillsBattlePlayer(game, actor);
const held = player.hand.find(matches);
if (held) return held;
const from = game.deck.findIndex(matches);
assert.ok(from >= 0 || game.discard.some(matches), 'The required original card must remain in deck or discard custody');
const card = from >= 0 ? game.deck.splice(from, 1)[0] : game.discard.splice(game.discard.findIndex(matches), 1)[0];
assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : 4));
player.hand.push(card);
staging.push(`Conserved original ${card.id}: ${from >= 0 ? 'undealt deck' : 'discard'} → ${actor}'s hand`);
return card;
}
/** Conserved Traitor identity: only a real reserved or held leader exchanges. */
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = independentSkillsBattlePlayer(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((seat) => seat.id !== actor && seat.traitors.includes(identity));
assert.ok(reserve >= 0 || donor, 'The requested Traitor must retain original custody');
if (reserve >= 0) game.traitorReserve!.splice(reserve, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`);
}
function strongestOther(game: Game, actor: string, excluded: string): string {
const leader = independentSkillsBattlePlayer(game, actor).leaders
.filter((disc) => !disc.dead && !disc.usedAt && disc.id !== excluded)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
return leader.id;
}
/** Real training, a paid native shipment, real within-territory movement and both
* original Battle Plans. Only card and Traitor custody is staged. */
export function createIndependentSkillsBattleFixture(
options: IndependentSkillsBattleOptions = {},
): IndependentSkillsBattleFixture {
const caseKind = options.case ?? 'same-plan';
const setup = createIndependentSkillsBattleSetup(options);
const advanced = setup.afterSetup.advanced;
const staging = [...setup.staging];
const actions = [...setup.actions];
const { owner, opponent, trainers } = setup;
const location = 'pasty_mesa', first = 'pasty_mesa:5', second = 'pasty_mesa:6';
assert.deepEqual(territory(location).sectors, [5, 6, 7, 8]);
let game = advanceIndependentSkillsBattle(setup.afterSetup,
(state) => state.phase === 5 && state.active === owner && independentSkillsBattleClean(state), actions);
const beforeShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 20);
assert.equal(independentSkillsBattlePlayer(game, owner).spice, 10);
const shipment: IndependentSkillsBattleStep = { actor: owner, action: {
type: 'ship', territory: location, sector: 5, amount: 4, elite: 0, allyPayment: 0 } };
actions.push(structuredClone(shipment));
game = applyAction(game, owner, shipment.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
!state.pendingShipment && state.phase === 5 && state.active === owner, actions);
const afterShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).spice, 2);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 16);
assert.equal(independentSkillsBattlePlayer(game, owner).forces[first], 4);
assert.equal(independentSkillsBattlePlayer(game, opponent).spice, 5 + 8);
staging.push('The Emperor paid the original eight-spice desert shipment of four reserves into Pasty Mesa; that payment reached the Guild as printed shipping income.');
const move: IndependentSkillsBattleStep = { actor: owner, action: {
type: 'move', from: first, amount: 2, territory: location, sector: 6 } };
actions.push(structuredClone(move));
game = applyAction(game, owner, move.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === owner, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, owner).forces, { [first]: 2, [second]: 2 });
const afterMove = structuredClone(game);
staging.push('The Emperor then moved two of those four counters from sector 5 to sector 6 in the same movement turn, so the Harass & Withdraw return allocation is genuinely ambiguous between two physical sectors.');
const endMove: IndependentSkillsBattleStep = { actor: owner, action: { type: 'endMovement' } };
game = applyAction(game, owner, endMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === opponent, actions);
assert.equal(independentSkillsBattlePlayer(game, opponent).forces['tueks_sietch:5'], 5);
const opponentMove: IndependentSkillsBattleStep = { actor: opponent, action: {
type: 'move', from: 'tueks_sietch:5', amount: 5, territory: location, sector: 6 } };
actions.push(structuredClone(opponentMove));
game = applyAction(game, opponent, opponentMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === opponent, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, opponent).forces, { [second]: 5 });
staging.push('The Guild declined the first Advanced movement slot (a real legal Guild choice) and moved its whole army from Tuek’s Sietch into Pasty Mesa sector 6 during its own movement turn.');
const endOpponent: IndependentSkillsBattleStep = { actor: opponent, action: { type: 'endMovement' } };
game = applyAction(game, opponent, endOpponent.action);
game = advanceIndependentSkillsBattle(game, (state) => state.phase === 6 && independentSkillsBattleClean(state), actions);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
staging.push('At the real Battle window every original hand returned to the undealt deck before the physical Reinforcements and Harass & Withdraw cards were conserved to the Emperor.');
hold(game, owner, (card) => card.id === REINFORCEMENTS_CARD, staging);
hold(game, owner, (card) => card.id === HARASS_WITHDRAW_CARD, staging);
const worthless = caseKind === 'suk' ? null : hold(game, opponent, (card) => card.kind === 'worthless', staging);
const hide: Record<string, boolean> = { [owner]: true, [opponent]: caseKind !== 'suk' };
// The trained leader must be committed (so Warmaster/Suk take effect) exactly
// when the owner's posture decision hides it behind the shield.
const ownerLeader = caseKind === 'suk' ? trainers.owner : strongestOther(game, owner, trainers.owner);
const opponentLeader = caseKind === 'suk' ? strongestOther(game, opponent, trainers.opponent) : trainers.opponent;
let traitor: string | null = null;
if (caseKind === 'traitor-cancel') {
// The opposing Traitor must match the attacker's real committed leader disc.
conserveTraitor(game, opponent, ownerLeader, staging);
traitor = ownerLeader;
}
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find((candidate) => candidate.territory === location);
assert.ok(choice);
assert.equal(choice.attacker, owner);
assert.equal(choice.chooser, owner);
const battleStep: IndependentSkillsBattleStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: location, target: choice.defender } };
actions.push(structuredClone(battleStep));
game = applyAction(game, choice.chooser, battleStep.action);
game = advanceIndependentSkillsBattle(game,
(state) => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
actions, { hide });
const support = advanced ? 2 : 0;
const plans: IndependentSkillsBattleStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: 2, support, leader: ownerLeader,
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader,
...(worthless ? { weapon: worthless.id } : {}) } },
];
for (const plan of plans) {
actions.push(structuredClone(plan));
game = applyAction(game, plan.actor, plan.action);
}
assert.ok(game.battle?.revealed);
staging.push(`Both combatants made their real posture decisions before sealing: the Emperor hid its skilled leader behind the shield, the Guild ${caseKind === 'suk' ? 'kept its skilled leader face up and therefore committed another leader' : 'hid its skilled leader so that the committed Warmaster could resolve'}.`);
const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 1, elite: 0 } };
const fixture: IndependentSkillsBattleFixture = { ...setup, case: caseKind, advanced, location, first, second,
beforeShipment, shipment, afterShipment, move, afterMove, opponentMove, beforeBattle, battleStep,
game, plans, returns, hide, traitor, actions, staging };
assertIndependentSkillsBattleCustody(game);
return fixture;
}
/** Quote the real revealed plans, postures and physical custody; Traitor calls and
* the typed withdrawal allocation are the caller's current human choices. */
export function quoteIndependentSkillsBattle(game: Game,
options: { callers?: readonly string[]; returns?: HarassWithdrawSelection } = {}) {
const battle = game.battle;
assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = independentSkillsBattlePlayer(game, actor);
const view = viewGame(game, actor).battle!;
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
plan: battle.plans[actor],
leader: player.leaders.find((leader) => leader.id === battle.plans[actor].leader),
forces: view.ownForces!,
leaderSkills: game.leaderSkills!.assignments.filter((assignment) => assignment.owner === actor)
.map((assignment) => ({ skill: assignment.skill, leader: assignment.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true,
captured: false })),
...(view.reinforcements && usesNativeBattleSpecial(battle.plans[actor], REINFORCEMENTS_CARD)
? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0),
elite: player.elites?.reserves ?? 0 } } : {}),
...(view.harassWithdraw && usesNativeBattleSpecial(battle.plans[actor], HARASS_WITHDRAW_CARD) ? { harassWithdraw: { blocked: null,
forces: view.harassWithdraw.forces, locations: view.harassWithdraw.locations,
...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) },
...(options.returns ? { harassSelection: options.returns } : {}) } : {}) };
};
return quoteBattleResolution({
advanced: game.advanced,
turn: game.turn,
territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker),
defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map((id) => ({ id, beneficiary: id,
called: options.callers?.includes(id) ?? false,
traitors: independentSkillsBattlePlayer(game, id).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
});
}
/** Resolve the real aftermath: Traitor calls, the typed withdrawal allocation, the
* Suk rescue choice and mandatory disposal, stopping at the original Collection. */
export function finishIndependentSkillsBattle(game: Game,
options: { callers?: readonly string[]; return?: HarassWithdrawSelection;
rescue?: (options: readonly SukRescueOption[]) => number;
actions?: IndependentSkillsBattleStep[]; until?: (state: Game) => boolean } = {}): Game {
const until = options.until ?? ((state: Game) =>
state.phase === 7 && independentSkillsBattleClean(state));
for (let count = 0; count < 400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
let step: IndependentSkillsBattleStep | null;
if (game.decision?.kind === 'harassWithdraw') {
assert.ok(options.return, 'Supply the genuine typed undialed return allocation');
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event,
returns: options.return } };
} else if (game.decision?.kind === 'sukRescue') {
const decision = game.decision;
const choice = options.rescue ? options.rescue(decision.options)
: decision.options.findIndex((option) => option.normal + option.elite === 2 && option.kept);
assert.ok(choice >= 0, 'Choose one real Suk Graduate rescue option');
step = { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
} else {
const next = nextIndependentSkillsBattleStep(game);
step = next && next.action.type === 'traitorCall'
? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } }
: next;
}
assert.ok(step, 'Resolve both original plans before their physical aftermath');
options.actions?.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original classic battle aftermath did not reach Collection');
}
+521
View File
@@ -0,0 +1,521 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { forceRevivalQuote } from '../game/revival';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import {
nextIndependentCardModulesStep, type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
export type IndependentSkillsRevivalStep = IndependentCardModulesStep;
export type IndependentSkillsRevivalOptions = {
/** Original Basic or Advanced rules; the independent composition is the same. */
advanced?: boolean;
seatIds?: readonly string[];
};
/** Real consumer-visible numbers measured on the actual states, never assigned. */
export type IndependentSkillsRevivalPricing = {
/** Turn-two ordinary cap and remaining free rate before any return this phase. */
ordinary: { limit: number; freeRemaining: number; forcesRemaining: number };
/** After the payer's real prior free return, still before the Recruits play. */
priorReturn: { limit: number; freeRemaining: number; revived: number; freeUsed: number };
/** After the real Recruits activation. */
doubled: { limit: number; freeRemaining: number };
/** Independent original price of the real revival requested from that state. */
revival: { amount: number; free: number; cost: number };
};
export type IndependentSkillsRevivalControl = {
label: string;
playAction: Action;
revivalAction: Action;
/** Spice the payer actually spent across both real actions. */
paid: number;
game: Game;
};
export type IndependentSkillsRevivalFixture = {
/** Fresh ready classic lobby carrying the independent Ecaz marker. */
initial: Game;
/** Original admitted setup: the physical two-card skill offers are open. */
offered: Game;
/** Genuine native setup finished: original hands, traitors, forces and all14 skills. */
afterSetup: Game;
/** After the real first-turn battle sent the payer's counters to the Tanks. */
afterBattle: Game;
/** Immediately after the real out-of-turn bribe was declared. */
afterBribe: Game;
/** Turn-one Mentat Pause: the real bribe is spendable and no Bank income is owed. */
atMentat1: Game;
/** Real turn-two Revival with the physical Recruits card conserved in the payer's hand. */
atRevival: Game;
/** After the payer's real prior free return, before the Recruits play. */
beforeRecruits: Game;
/** After the actual Recruits activation, before the priced revival. */
beforeMainRevival: Game;
/** After the payer's real revival: Bank income is deferred and unpaid. */
afterRevival: Game;
/** Turn-two Spice Collection settled, immediately before the Mentat settlement. */
atMentat2: Game;
/** Turn-two Mentat Pause after the deferred income was credited exactly once. */
afterMentat: Game;
owner: string;
payer: string;
opponent: string;
/** Banker's own trained leader, alive and face up through the programme. */
bankerLeader: string;
card: typeof RECRUITS_CARD_ID;
territory: { id: string; sector: number; type: string };
battle: {
event: string;
attacker: string;
defender: string;
chooser: string;
moveAction: IndependentSkillsRevivalStep;
shipAction: IndependentSkillsRevivalStep;
battleAction: IndependentSkillsRevivalStep;
/** Real battle leaders; neither is a held traitor. */
payerLeader: string;
opponentLeader: string;
visibilityActions: IndependentSkillsRevivalStep[];
planActions: IndependentSkillsRevivalStep[];
traitorCalls: IndependentSkillsRevivalStep[];
};
bribeAction: IndependentSkillsRevivalStep;
priorReturnAction: IndependentSkillsRevivalStep;
playAction: IndependentSkillsRevivalStep;
revivalAction: IndependentSkillsRevivalStep;
/** Real below-threshold and free-only revivals on clones of atRevival. */
controls: IndependentSkillsRevivalControl[];
pricing: IndependentSkillsRevivalPricing;
/** Physical deltas the real actions produced. */
physical: {
payerBoardLosses: number;
payerTanksAtRevival: number;
priorFreeReturn: number;
revived: number;
freeReturned: number;
paidReturned: number;
};
/** The single deferred Bank income the real revival produced. */
grant: { kind: string; payer: string; amount: number; deferred: number };
/** Native decision windows acknowledged by real actions, in order. */
decisions: string[];
/** Every explicit physical staging step, including the conserved Recruits face. */
staging: string[];
steps: IndependentSkillsRevivalStep[];
/** Shared native continuation for continuing this exact programme. */
advanceUntil: (state: Game, until: (game: Game) => boolean) => Game;
};
const BANKER = 'atreides';
const PAYER = 'guild';
const OPPONENT = 'emperor';
const TERRITORY_ID = 'south_mesa';
const TERRITORY_SECTOR = 4;
const PRIOR_FREE_RETURN = 1;
const REVIVED = 4;
const BRIBE = 4;
const clean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
export const independentSkillsRevivalPlayer = (game: Game, id: string): Player => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
};
const boardCount = (player: Player): number =>
Object.values(player.forces).reduce((sum, count) => sum + count, 0);
const counterTotal = (player: Player): number => player.reserves + player.tanks + boardCount(player);
const allCards = (game: Game): Card[] => [
...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
];
/** Physical counters and one copy of every selected card stay conserved. */
export function assertIndependentSkillsRevivalInventory(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = [
...game.leaderSkills!.deck,
...game.leaderSkills!.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
];
assert.equal(skills.length, LEADER_SKILL_CARDS.length);
assert.equal(new Set(skills).size, LEADER_SKILL_CARDS.length);
for (const expected of ecazTreacheryCards()) {
const matching = allCards(game).filter(card => card.id === expected.id);
assert.equal(matching.length, 1, `${expected.id} must keep exactly one physical face`);
}
for (const player of game.players) {
assert.equal(counterTotal(player), 20, `${player.id} must conserve twenty normal counters`);
if (player.elites)
assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0),
player.faction === 'ixians' ? 7 : 5, `${player.id} must conserve its special counters`);
}
}
/** Deterministic only for the original admission and setup deal; play is real. */
function withCanonicalSetupEntropy<T>(deal: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
return deal();
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Physically conserve one existing face into a hand; no minted or replaced copy. */
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
const player = independentSkillsRevivalPlayer(game, actor);
const held = player.hand.find(card => card.id === id);
if (held) {
staging.push(`Physical staging: ${id} already held by ${actor}; no zone changed.`);
return held;
}
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;
}
/**
* Genuine classic Basic/Advanced all14 Leader Skills programme with the
* independent Ecaz Treachery cards. The real first Storm decides custody; the
* original skill deal is admitted by the ordinary initializer. One real
* Banker is trained, one different payer loses a native army in an actual
* first-turn desert battle, is bribed through the real out-of-turn action and
* revives at the real turn-two Revival after the physical Recruits card
* doubles the current free rates. No wallet, owner, phase, receipt or price is
* assigned; only the Recruits face is conserved into a hand.
*/
export function createIndependentSkillsRevivalFixture(
options: IndependentSkillsRevivalOptions = {},
): IndependentSkillsRevivalFixture {
const advanced = options.advanced ?? false;
const ids = options.seatIds ?? ['p0', 'p1', 'p2'];
assert.equal(ids.length, 3, 'The revival programme uses the Banker, payer and battle opponent seats');
const [owner, payer, opponent] = ids;
const steps: IndependentSkillsRevivalStep[] = [];
const staging: string[] = [];
const decisions: string[] = [];
const record = (game: Game, step: IndependentSkillsRevivalStep): Game => {
assert.ok(game.status === 'lobby' || game.status === 'setup' || game.status === 'playing');
if (game.decision) decisions.push(game.decision.kind);
const next = applyAction(game, step.actor, step.action);
steps.push({ actor: step.actor, action: structuredClone(step.action) });
return next;
};
let game = createGame('INDEPENDENTSKILLSREVIVAL', newPlayer(owner, 'Banker', BANKER), advanced);
joinGame(game, newPlayer(payer, 'Payer', PAYER));
joinGame(game, newPlayer(opponent, 'Opponent', OPPONENT));
for (const player of game.players) game = record(game, { actor: player.id, action: { type: 'ready' } });
// The marker is set after every lobby action, before the ordinary initializer.
game.ecazTreachery = true;
const initial = structuredClone(game);
let offeredSetup: Game | undefined;
/** Original admission plus the whole setup deal: this is the only fixed entropy. */
const settleSetup = (): Game => {
let setup = initializeLeaderSkillsGameForAudit(game);
offeredSetup = structuredClone(setup);
for (let count = 0; setup.status === 'setup' && count < 400; count++) {
if (setup.setupStage === 'leaderSkills') {
const actor = Object.keys(setup.leaderSkills!.offers)[0];
const offer = setup.leaderSkills!.offers[actor];
const view = viewGame(setup, actor).leaderSkills!;
const skill = actor === owner && offer.cards.includes('spice-banker')
? 'spice-banker' : offer.cards[0];
setup = record(setup, {
actor,
action: { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id },
});
} else if (setup.setupStage === 'traitors') {
const pending = setup.players.filter(player => player.traitorChoices.length);
assert.ok(pending.length, 'The original traitor stage must still be waiting for real choices');
for (const player of pending)
setup = record(setup, { actor: player.id, action: { type: 'traitor', leader: player.traitorChoices[0] } });
} else {
const step = nextIndependentCardModulesStep(setup);
assert.ok(step, `No native setup continuation at ${setup.setupStage}`);
setup = record(setup, step);
}
}
return setup;
};
game = withCanonicalSetupEntropy(settleSetup);
assert.ok(offeredSetup, 'The admitted setup must expose its physical skill offers');
const offered = structuredClone(offeredSetup);
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, []);
assert.equal(game.ecazTreachery, true);
assert.equal(TERRITORIES.find(entry => entry.id === TERRITORY_ID)?.type, 'sand');
assert.ok(TERRITORIES.find(entry => entry.id === TERRITORY_ID)!.sectors.includes(TERRITORY_SECTOR));
const assignment = game.leaderSkills!.assignments.find(entry => entry.skill === 'spice-banker');
assert.ok(assignment, 'The original skill deal must place the physical Spice Banker card');
assert.equal(game.status, 'playing');
assert.equal(assignment.owner, owner, 'The Banker must be trained in the Banker faction');
const bankerLeader = assignment.leader;
const payerTrained = game.leaderSkills!.assignments.find(entry => entry.owner === payer)?.leader;
const opponentTrained = game.leaderSkills!.assignments.find(entry => entry.owner === opponent)?.leader;
assertIndependentSkillsRevivalInventory(game);
staging.push('Initial scalar entropy: only the original admission and setup deal are fixed (a canonical first skill order and its traitor deal). Every play action, battle vote and later shuffle uses real entropy; no counter, wallet, phase, skill receipt or Traitor custody was assigned.');
const afterSetup = structuredClone(game);
const advanceUntil = (state: Game, until: (current: Game) => boolean): Game => {
let current = state;
for (let count = 0; count < 2400; count++) {
if (until(current)) return current;
assert.notEqual(current.status, 'finished', 'The original programme ended early');
// Advanced only: Guild takes its movement turn as the original window offers.
const step: IndependentSkillsRevivalStep | null =
current.decision?.kind === 'guildTiming'
? { actor: current.decision.player, action: { type: 'decision', take: true } }
: nextIndependentCardModulesStep(current);
assert.ok(step, `No native continuation at ${current.turn}/${current.phase}/${current.decision?.kind}`);
current = record(current, step);
}
throw Error('The original programme did not reach its requested boundary');
};
// Real first-turn battle: the payer's native army marches into an empty
// desert the opponent pays to enter, and loses it to the Tleilaxu Tanks.
game = advanceUntil(game, state => state.turn === 1 && state.phase === 5 && clean(state));
game = advanceUntil(game, state => state.phase === 5 && state.active === payer && clean(state));
const moveAction: IndependentSkillsRevivalStep = {
actor: payer,
action: { type: 'move', from: 'tueks_sietch:5', territory: TERRITORY_ID, sector: TERRITORY_SECTOR, amount: 5 },
};
game = record(game, moveAction);
game = advanceUntil(game, state => state.phase === 5 && state.active === opponent && clean(state));
const shipAction: IndependentSkillsRevivalStep = {
actor: opponent,
action: { type: 'ship', territory: TERRITORY_ID, sector: TERRITORY_SECTOR, amount: 2 },
};
game = record(game, shipAction);
game = advanceUntil(game, state => state.phase === 6 && clean(state));
const choice = viewGame(game, game.active!).battleChoices
.find(candidate => candidate.territory === TERRITORY_ID);
assert.ok(choice, 'The real movements must open a battle in that desert');
const chooser = choice.chooser;
assert.ok(chooser, 'The payer\'s march must leave it the aggressor');
assert.equal(chooser, payer);
assert.deepEqual([choice.attacker, choice.defender], [payer, opponent]);
const battleAction: Action = { type: 'chooseBattle', territory: TERRITORY_ID, target: choice.attacker === chooser ? choice.defender : choice.attacker };
game = record(game, { actor: chooser, action: battleAction });
const battleEvent = game.battle?.event;
assert.ok(battleEvent, 'The real battle choice must open an original battle event');
const visibilityActions: IndependentSkillsRevivalStep[] = [];
const planActions: IndependentSkillsRevivalStep[] = [];
const traitorCalls: IndependentSkillsRevivalStep[] = [];
// No real battle leader may be a held traitor, so no private vote can change
// the ordinary strength outcome this programme relies on.
const heldTraitors = new Set(game.players.flatMap(player => player.traitors));
/** A trained leader cannot be in its own plan while it stays face up. */
const untrainedLeader = (id: string, trained: string | undefined, prefer: 'weakest' | 'strongest'): string => {
const player = independentSkillsRevivalPlayer(game, id);
const leaders = player.leaders
.filter(candidate => candidate.id !== trained && !candidate.dead && !heldTraitors.has(candidate.id))
.sort((a, b) => prefer === 'weakest'
? a.strength - b.strength || a.id.localeCompare(b.id)
: b.strength - a.strength || a.id.localeCompare(b.id));
assert.ok(leaders.length, `${id} needs an unheld, untrained and available battle leader`);
return leaders[0].id;
};
const payerLeader = untrainedLeader(payer, payerTrained, 'weakest');
const opponentLeader = untrainedLeader(opponent, opponentTrained, 'strongest');
for (let count = 0; (game.response || game.decision || game.battle?.preLeader?.closed === false) && count < 60; count++) {
if (game.decision?.kind === 'leaderSkillVisibility') {
// Both trained leaders stay public and therefore outside these plans.
const step: IndependentSkillsRevivalStep = {
actor: game.decision.player,
action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: false },
};
visibilityActions.push(step);
game = record(game, step);
} else {
const step = nextIndependentCardModulesStep(game);
assert.ok(step, `No native pre-battle continuation at ${game.decision?.kind}`);
game = record(game, step);
}
}
assert.ok(!heldTraitors.has(payerLeader) && !heldTraitors.has(opponentLeader),
'Neither real battle leader may be a held traitor');
const payerPlan: IndependentSkillsRevivalStep = {
actor: payer, action: { type: 'battlePlan', dial: 0, support: 0, leader: payerLeader },
};
const opponentPlan: IndependentSkillsRevivalStep = {
actor: opponent, action: { type: 'battlePlan', dial: 1, support: 0, leader: opponentLeader },
};
planActions.push(payerPlan, opponentPlan);
game = record(game, payerPlan);
game = record(game, opponentPlan);
for (let count = 0; (game.response || game.decision || game.battle) && count < 120; count++) {
const step = nextIndependentCardModulesStep(game);
assert.ok(step, `No native battle continuation at ${game.decision?.kind}`);
if (step.action.type === 'traitorCall') traitorCalls.push(step);
game = record(game, step);
}
assert.ok(!game.battle, 'The battle must have resolved completely');
assert.equal(game.phase, 7);
const payerBoardLosses = independentSkillsRevivalPlayer(game, payer).tanks;
assert.equal(payerBoardLosses, 5,
'The real battle must send the payer\'s five physical counters to the Tanks');
assert.equal(boardCount(independentSkillsRevivalPlayer(game, payer)), 0);
const afterBattle = structuredClone(game);
// Real out-of-turn bribe funds the next revival; bribes become spendable at Mentat.
const bribeAction: IndependentSkillsRevivalStep = {
actor: opponent, action: { type: 'bribe', target: payer, amount: BRIBE },
};
game = record(game, bribeAction);
assert.equal(independentSkillsRevivalPlayer(game, payer).bribes, BRIBE);
const afterBribe = structuredClone(game);
game = advanceUntil(game, state => state.turn === 1 && state.phase === 8 && clean(state));
assert.equal(independentSkillsRevivalPlayer(game, payer).bribes, 0);
assert.deepEqual(viewGame(game, owner).spiceBankerIncome?.deferred, [],
'The first-turn bribe is a payment to a player, so no Bank income is owed yet');
const atMentat1 = structuredClone(game);
game = advanceUntil(game, state => state.turn === 2 && state.phase === 4 && clean(state));
const payerTanksAtRevival = independentSkillsRevivalPlayer(game, payer).tanks;
assert.equal(payerTanksAtRevival, payerBoardLosses,
'No counter may reach or leave the Tanks between the battle and the Revival');
conserveCard(game, payer, RECRUITS_CARD_ID, staging);
staging.push(`Conserved staging: the ${PAYER} payer holds the physical Recruits face after a real native programme; the Banker remains ${bankerLeader} in ${owner}.`);
assertIndependentSkillsRevivalInventory(game);
const atRevival = structuredClone(game);
const ordinaryView = viewGame(game, payer).revival;
const pricing: IndependentSkillsRevivalPricing = {
ordinary: { limit: ordinaryView.limit, freeRemaining: ordinaryView.freeRemaining, forcesRemaining: ordinaryView.forcesRemaining },
priorReturn: { limit: 0, freeRemaining: 0, revived: 0, freeUsed: 0 },
doubled: { limit: 0, freeRemaining: 0 },
revival: { amount: REVIVED, free: 0, cost: 0 },
};
assert.equal(pricing.ordinary.limit, 3, 'The ordinary force revival cap is still three');
assert.ok(pricing.ordinary.freeRemaining >= PRIOR_FREE_RETURN);
const priorReturnAction: IndependentSkillsRevivalStep = {
actor: payer, action: { type: 'revive', amount: PRIOR_FREE_RETURN },
};
game = record(game, priorReturnAction);
assert.ok(!game.pendingRevival, 'The real revival must settle without an open request');
const afterPrior = independentSkillsRevivalPlayer(game, payer);
const priorFree = afterPrior.freeForcesRevived;
assert.ok(priorFree !== undefined, 'The ordinary free-return ledger must exist');
assert.equal(afterPrior.tanks, payerTanksAtRevival - PRIOR_FREE_RETURN);
assert.equal(afterPrior.revived, PRIOR_FREE_RETURN);
assert.equal(priorFree, PRIOR_FREE_RETURN);
const priorView = viewGame(game, payer).revival;
pricing.priorReturn = {
limit: priorView.limit, freeRemaining: priorView.freeRemaining,
revived: afterPrior.revived, freeUsed: priorFree,
};
const beforeRecruits = structuredClone(game);
const playAction: IndependentSkillsRevivalStep = {
actor: payer, action: { type: 'card', card: RECRUITS_CARD_ID },
};
game = record(game, playAction);
assert.equal(game.recruits?.player, payer);
const doubledView = viewGame(game, payer).revival;
pricing.doubled = { limit: doubledView.limit, freeRemaining: doubledView.freeRemaining };
assert.equal(pricing.doubled.limit, 7, 'Recruits raises the ordinary cap to seven');
const beforeMainRevival = structuredClone(game);
const quote = forceRevivalQuote(game, independentSkillsRevivalPlayer(game, payer), REVIVED);
pricing.revival = { amount: REVIVED, free: quote.free, cost: quote.cost };
const revivalAction: IndependentSkillsRevivalStep = {
actor: payer, action: { type: 'revive', amount: REVIVED },
};
game = record(game, revivalAction);
assert.ok(!game.pendingRevival, 'The real revival must settle without an open request');
const afterMain = independentSkillsRevivalPlayer(game, payer);
assert.equal(afterMain.tanks, payerTanksAtRevival - PRIOR_FREE_RETURN - REVIVED);
assert.equal(afterMain.revived, PRIOR_FREE_RETURN + REVIVED);
assert.equal(afterMain.freeForcesRevived, PRIOR_FREE_RETURN + quote.free);
const grant = game.spiceBankerIncome!.sources.find(source => source.grant)!;
assert.ok(grant, 'The real revival payment must award the Banker income');
assert.equal(grant.source.kind, 'force-revival');
assert.deepEqual(grant.source.bankLegs, [{ payer, amount: quote.cost }]);
assert.deepEqual(viewGame(game, owner).spiceBankerIncome?.deferred, [{ owner, amount: 1 }],
'The Banker income is placed in front of the shield, not added to the wallet');
assert.equal(independentSkillsRevivalPlayer(game, owner).spice,
independentSkillsRevivalPlayer(beforeMainRevival, owner).spice,
'The Banker wallet must not advance when the income is deferred');
const afterRevival = structuredClone(game);
// The same real state also feeds two genuine no-grant controls.
const control = (label: string, action: Action): IndependentSkillsRevivalControl => {
let clone = structuredClone(atRevival) as Game;
const before = independentSkillsRevivalPlayer(clone, payer).spice;
clone = applyAction(clone, payer, structuredClone(playAction.action));
clone = applyAction(clone, payer, action);
return {
label, playAction: structuredClone(playAction.action), revivalAction: structuredClone(action),
paid: before - independentSkillsRevivalPlayer(clone, payer).spice, game: clone,
};
};
const controls = [
control('free-only revival stays inside the doubled free rate', { type: 'revive', amount: 2 }),
control('below-threshold revival pays less than four to the Bank', { type: 'revive', amount: 3 }),
];
for (const entry of controls)
assert.deepEqual(viewGame(entry.game, owner).spiceBankerIncome?.deferred, [],
`${entry.label} must not earn Bank income`);
const atMentat2 = advanceUntil(game, state => state.turn === 2 && state.phase === 7 && clean(state));
assert.deepEqual(viewGame(atMentat2, owner).spiceBankerIncome?.deferred, [{ owner, amount: 1 }],
'The deferred Bank income survives until the actual Mentat settlement');
const afterMentat = advanceUntil(atMentat2, state => state.turn === 2 && state.phase === 8 && clean(state));
return {
initial, offered, afterSetup, afterBattle, afterBribe, atMentat1, atRevival,
beforeRecruits, beforeMainRevival, afterRevival, atMentat2, afterMentat,
owner, payer, opponent, bankerLeader, card: RECRUITS_CARD_ID,
territory: { id: TERRITORY_ID, sector: TERRITORY_SECTOR, type: 'sand' },
battle: {
event: battleEvent, attacker: payer, defender: opponent, chooser: payer,
moveAction, shipAction, battleAction: { actor: chooser, action: structuredClone(battleAction) },
payerLeader, opponentLeader,
visibilityActions, planActions, traitorCalls,
},
bribeAction, priorReturnAction, playAction, revivalAction, controls, pricing,
physical: {
payerBoardLosses, payerTanksAtRevival, priorFreeReturn: PRIOR_FREE_RETURN,
revived: REVIVED, freeReturned: quote.free, paidReturned: REVIVED - quote.free,
},
grant: { kind: grant.source.kind, payer, amount: quote.cost, deferred: 1 },
decisions, staging, steps, advanceUntil,
};
}
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import {
advanceIndependentSkillsBattle, assertIndependentSkillsBattleCustody,
createIndependentSkillsBattleFixture, createIndependentSkillsBattleSetup, finishIndependentSkillsBattle,
independentSkillsBattleClean, independentSkillsBattlePlayer,
type IndependentSkillsBattleStep,
} from './fixture-independent-skills-battle';
export type PlanetologistIndependentOptions = {
advanced?: boolean;
effect?: 'reinforcements' | 'harassWithdraw' | 'recruits';
alternate?: boolean;
poison?: boolean;
};
function conserve(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): Card {
const player = independentSkillsBattlePlayer(game, actor);
const held = player.hand.find(matches);
if (held) return held;
assert.ok(player.hand.length < handLimit(player));
for (const [zone, label] of [[game.deck, 'undealt deck'], [game.discard, 'discard']] as const) {
const index = zone.findIndex(matches);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
player.hand.push(card);
staging.push(`Conserved original ${card.id} from ${label} to ${actor}; no purchase is claimed.`);
return card;
}
throw Error('The original selected card must remain in stock or already be held.');
}
export function createPlanetologistIndependentFixture(options: PlanetologistIndependentOptions = {}) {
const base = createIndependentSkillsBattleFixture({ advanced: options.advanced ?? true,
skills: { owner: 'planetologist' } });
let game = structuredClone(base.beforeBattle);
const staging = [...base.staging];
const before = base.actions.findIndex(step => step.action.type === 'chooseBattle');
assert.ok(before >= 0);
const actions = base.actions.slice(0, before);
const effect = options.effect ?? 'reinforcements';
const weapon = conserve(game, base.owner, card => card.effect === effect, staging);
const defense = conserve(game, base.owner, card => card.effect === (effect === 'harassWithdraw' ? 'reinforcements' : 'harassWithdraw'), staging);
const opposingWeapon = options.poison ? conserve(game, base.opponent, card => card.kind === 'poison', staging).id
: base.plans[1].action.weapon;
assertIndependentSkillsBattleCustody(game);
const beforeBattle = structuredClone(game);
actions.push(base.battleStep);
game = applyAction(game, base.battleStep.actor, base.battleStep.action);
game = advanceIndependentSkillsBattle(game,
state => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
actions, { hide: { [base.owner]: true, [base.opponent]: true } });
const plans: IndependentSkillsBattleStep[] = [
{ actor: base.owner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 2 : 0,
leader: base.trainers.owner, weapon: weapon.id, defense: defense.id,
...(effect === 'recruits' ? {} : { planetologistSpecial: options.alternate !== false }) } },
{ actor: base.opponent, action: { ...base.plans[1].action, weapon: opposingWeapon } },
];
staging.push('Branch from the original pre-battle position; make real hidden postures and select the trained Planetologist. No army, wallet, clock or skill assignment changes.');
return { initial: base.initial, offered: base.offered, beforeBattle, game,
owner: base.owner, opponent: base.opponent, observer: base.observer,
trainer: base.trainers.owner, location: base.location, first: base.first, second: base.second,
returns: base.returns, plans, actions, staging, effect, alternate: effect === 'recruits' || options.alternate !== false };
}
export type PlanetologistIndependentFixture = ReturnType<typeof createPlanetologistIndependentFixture>;
export function sealPlanetologistIndependent(fixture: PlanetologistIndependentFixture): Game {
let game = fixture.game;
for (const step of fixture.plans) game = applyAction(game, step.actor, step.action);
assert.ok(game.battle?.revealed);
return game;
}
export function finishPlanetologistIndependent(game: Game, fixture: PlanetologistIndependentFixture,
actions?: IndependentSkillsBattleStep[]): Game {
return finishIndependentSkillsBattle(game, { return: fixture.returns, actions });
}
/** Actual stronghold deployment empties the reserves before the later utility plan. */
export function createPlanetologistEmptyReserveFixture(): PlanetologistIndependentFixture {
const initial = createGame('PLANETEMPTY', newPlayer('h', 'Harkonnen', 'harkonnen'), true);
joinGame(initial, newPlayer('g', 'Guild', 'guild'));
joinGame(initial, newPlayer('e', 'Emperor', 'emperor'));
const setup = createIndependentSkillsBattleSetup({ initial, advanced: true,
skills: { owner: 'planetologist', opponent: 'rihani-decipherer', observer: 'prana-bindu-adept' } });
const actions = [...setup.actions], staging = [...setup.staging];
let game = setup.afterSetup;
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = applyAction(game, actor, action);
};
game = advanceIndependentSkillsBattle(game,
state => state.phase === 5 && state.active === 'h' && independentSkillsBattleClean(state), actions);
act('h', { type: 'ship', territory: 'carthag', sector: 11, amount: 10 });
game = advanceIndependentSkillsBattle(game, state => state.phase === 5 && state.active === 'h' &&
independentSkillsBattleClean(state) && !state.pendingShipment, actions);
act('h', { type: 'move', from: 'carthag:11', territory: 'imperial_basin', sector: 10, amount: 5 });
act('h', { type: 'endMovement' });
game = advanceIndependentSkillsBattle(game,
state => state.phase === 5 && state.active === 'e' && independentSkillsBattleClean(state), actions);
act('e', { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2 });
game = advanceIndependentSkillsBattle(game,
state => state.phase === 6 && independentSkillsBattleClean(state), actions);
assert.equal(independentSkillsBattlePlayer(game, 'h').reserves, 0);
conserve(game, 'h', card => card.id === 'ecaz-reinforcements', staging);
conserve(game, 'h', card => card.id === 'ecaz-harass-withdraw', staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'imperial_basin');
assert.ok(choice && choice.attacker === 'h');
act(choice.chooser, { type: 'chooseBattle', territory: choice.territory, target: choice.defender });
game = advanceIndependentSkillsBattle(game,
state => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
actions, { hide: { h: true, e: false } });
const other = independentSkillsBattlePlayer(game, 'e').leaders
.find(leader => !leader.dead && leader.id !== setup.trainers.observer)!;
const plans: IndependentSkillsBattleStep[] = [
{ actor: 'h', action: { type: 'battlePlan', leader: setup.trainers.owner,
weapon: 'ecaz-reinforcements', defense: 'ecaz-harass-withdraw',
dial: 0.5, support: 0, planetologistSpecial: true } },
{ actor: 'e', action: { type: 'battlePlan', leader: other.id, dial: 0, support: 0 } },
];
staging.push('Harkonnen paid its real ten-spice shipment into its own Carthag, then moved five original counters into Imperial Basin. Zero reserves and a half-strength unpaid dial are native outcomes, not staged resources.');
assertIndependentSkillsBattleCustody(game);
return { initial: setup.initial, offered: setup.offered, beforeBattle, game,
owner: 'h', opponent: 'e', observer: 'g', trainer: setup.trainers.owner,
location: 'imperial_basin', first: 'imperial_basin:10', second: 'imperial_basin:10',
returns: { 'imperial_basin:10': { normal: 4, elite: 0 } },
plans, actions, staging, effect: 'reinforcements', alternate: true };
}
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
assertIndependentSkillsBattleCustody, createIndependentSkillsBattleFixture,
createIndependentSkillsBattleSetup, advanceIndependentSkillsBattle, independentSkillsBattleClean,
finishIndependentSkillsBattle, independentSkillsBattlePlayer,
quoteIndependentSkillsBattle, type IndependentSkillsBattleFixture,
} from './fixture-independent-skills-battle';
const owner = (fixture: IndependentSkillsBattleFixture, game: Game) =>
independentSkillsBattlePlayer(game, fixture.owner);
const opponent = (fixture: IndependentSkillsBattleFixture, game: Game) =>
independentSkillsBattlePlayer(game, fixture.opponent);
const held = (game: Game, id: string) => [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
.filter((card) => card.id === id);
void test('the same-plan programme pays the three-counter fee, returns the undialed complement and scores the real Warmaster bonus', () => {
for (const advanced of [false, true]) {
const fixture = createIndependentSkillsBattleFixture({ case: 'same-plan', advanced });
assert.equal(fixture.location, 'pasty_mesa');
assert.equal(owner(fixture, fixture.beforeShipment).reserves, 20);
assert.equal(owner(fixture, fixture.beforeShipment).spice, 10);
assert.equal(owner(fixture, fixture.afterShipment).reserves, 16);
assert.equal(owner(fixture, fixture.afterShipment).spice, 2);
assert.equal(owner(fixture, fixture.afterShipment).forces[fixture.first], 4);
assert.equal(opponent(fixture, fixture.afterShipment).spice, 13, 'the paid eight-spice shipment reached the Guild');
assert.deepEqual(owner(fixture, fixture.beforeBattle).forces,
{ [fixture.first]: 2, [fixture.second]: 2 });
assert.deepEqual(opponent(fixture, fixture.beforeBattle).forces, { [fixture.second]: 5 });
const worthless = fixture.plans[1].action.weapon;
const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
assert.deepEqual(quote.reinforcements,
[{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.leaderSkillBonuses.defender,
{ bonus: 3, applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }] });
assert.deepEqual(quote.leaderSkillBonuses.attacker, { bonus: 0, applied: [] });
// Dial 2 + Reinforcements 2 + Captain Aramsham 5 beats Staban Tuek 5 + Warmaster 3.
assert.deepEqual(quote.scores, { attacker: 9, defender: 8 });
assert.deepEqual(quote.harassWithdraw?.map((entry) => ({
player: entry.player, returned: entry.returned,
remaining: { normal: entry.remaining.normal, elite: entry.remaining.elite }, locations: entry.locations })),
[{ player: fixture.owner, returned: { normal: 2, elite: 0 }, remaining: { normal: 2, elite: 0 },
locations: { [fixture.first]: { normal: 1, elite: 0 }, [fixture.second]: { normal: 1, elite: 0 } } }]);
assert.equal(fixture.game.decision?.kind, 'harassWithdraw');
assert.throws(() => quoteIndependentSkillsBattle(fixture.game,
{ returns: { [fixture.first]: { normal: 1, elite: 0 } } }),
/exact undialed complement of a legal physical commitment/);
const done = finishIndependentSkillsBattle(fixture.game, { return: fixture.returns });
assert.equal(done.phase, 7);
assert.equal(done.lastBattleContext?.winner, fixture.owner);
assert.equal(done.lastBattleContext?.result, 'normal');
assert.equal(owner(fixture, done).reserves, 15, 'twenty reserves, minus four shipped, minus the fee, plus two returned');
assert.equal(owner(fixture, done).tanks, 5, 'the three fee counters plus the two dialed casualties');
assert.deepEqual(owner(fixture, done).forces, {});
assert.equal(owner(fixture, done).spice, advanced ? 0 : 2);
assert.equal(opponent(fixture, done).tanks, 5, 'the defeated Guild army reached the Tanks');
assert.deepEqual(opponent(fixture, done).forces, {});
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, worthless as string]) {
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
assert.ok(done.players.every((player) => !player.hand.some((held) => held.id === card)), card);
}
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
[{ card: worthless, owner: fixture.opponent }]);
assertIndependentSkillsBattleCustody(done);
}
});
void test('the trained Suk Graduate rescues both committed casualties without refunding the fee or leaving phantom forces', () => {
for (const advanced of [false, true]) {
const fixture = createIndependentSkillsBattleFixture({ case: 'suk', advanced });
const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
// Dial 2 + Reinforcements 2 + Hasimir Fenring 6 against Guild Representative 1 + 2.
assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
const fee = finishIndependentSkillsBattle(fixture.game,
{ return: fixture.returns, until: (state) => state.decision?.kind === 'sukRescue' });
const rescue = fee.decision;
assert.equal(rescue?.kind, 'sukRescue');
assert.equal(rescue?.mode, 'skilled');
assert.deepEqual(owner(fixture, fee).forces,
{ [fixture.first]: 1, [fixture.second]: 1 }, 'the Harass return left exactly the committed two');
assert.equal(owner(fixture, fee).reserves, 15, 'the fee preceded the two returned counters');
assert.equal(owner(fixture, fee).tanks, 3, 'only the Reinforcements fee reached the Tanks');
assert.equal(owner(fixture, fee).spice, advanced ? 0 : 2);
const options = rescue?.kind === 'sukRescue' ? rescue.options : [];
const kept = options.find((option) => option.kept?.kind === 'normal' && option.normal + option.elite === 2);
assert.ok(kept, 'saving both casualties leaves one counter in its original sector');
assert.deepEqual(kept.kept, { key: fixture.first, kind: 'normal' });
assert.deepEqual(options.find((option) => option.normal + option.elite === 0),
{ normal: 0, elite: 0, kept: null });
const chosen = options.indexOf(kept);
const done = finishIndependentSkillsBattle(fee,
{ return: fixture.returns, rescue: () => chosen });
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
assert.deepEqual(owner(fixture, done).forces, { [fixture.first]: 1 },
'one rescued counter stayed in Pasty Mesa sector 5 and the rest returned to reserves');
assert.equal(owner(fixture, done).reserves, 16, 'the fee was not refunded by the rescue');
assert.equal(owner(fixture, done).tanks, 3);
assert.equal(done.pendingSukRescue, null);
assert.equal(opponent(fixture, done).tanks, 5);
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
assert.equal(held(done, card).length, 1, card);
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
}
assert.equal(done.discard.some((entry) => entry.kind === 'worthless'), false);
assertIndependentSkillsBattleCustody(done);
}
});
void test('an opposing real Traitor cancels the Harass return while the fee still reaches the Tanks', () => {
for (const advanced of [false, true]) {
const fixture = createIndependentSkillsBattleFixture({ case: 'traitor-cancel', advanced });
assert.equal(fixture.traitor, fixture.plans[0].action.leader);
assert.ok(opponent(fixture, fixture.game).traitors.includes(fixture.traitor!));
const canceled = quoteIndependentSkillsBattle(fixture.game,
{ callers: [fixture.opponent], returns: fixture.returns });
assert.equal(canceled.result, 'traitor');
assert.equal(canceled.winner, fixture.opponent);
assert.equal(canceled.scores, null, 'a revealed Traitor carries no battle totals');
assert.equal(canceled.harassWithdraw, undefined, 'an opposing successful traitor cancels the return');
assert.deepEqual(canceled.reinforcements,
[{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
const done = finishIndependentSkillsBattle(fixture.game,
{ callers: [fixture.opponent], return: fixture.returns });
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
assert.deepEqual(owner(fixture, done).forces, {}, 'the canceled return left no phantom counters');
assert.equal(owner(fixture, done).reserves, 13, 'twenty reserves, minus four shipped and the fee; nothing returned');
assert.equal(owner(fixture, done).tanks, 7, 'the fee plus the four counters left in Pasty Mesa');
assert.deepEqual(opponent(fixture, done).forces, { [fixture.second]: 5 });
assert.equal(opponent(fixture, done).tanks, 0);
assert.equal(opponent(fixture, done).spice, 18, 'five spice, the eight-spice shipment and the five-strength bounty');
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
[{ card: REINFORCEMENTS_CARD, owner: fixture.owner }, { card: HARASS_WITHDRAW_CARD, owner: fixture.owner }]);
assertIndependentSkillsBattleCustody(done);
}
});
void test('normal Bureaucrat remains available in independent all14 play and diverts a real bribe without advancing its escrow', () => {
const f = createIndependentSkillsBattleSetup({
skills: { owner: 'planetologist', opponent: 'warmaster', observer: 'bureaucrat' },
});
let game = advanceIndependentSkillsBattle(f.afterSetup,
state => state.phase === 4 && independentSkillsBattleClean(state));
const payer = independentSkillsBattlePlayer(game, f.owner).spice;
const payee = independentSkillsBattlePlayer(game, f.opponent).spice;
game = applyAction(game, f.owner, { type: 'bribe', target: f.opponent, amount: 5 });
const offer = viewGame(game, f.observer).bureaucrat!.pending!;
assert.ok(offer);
game = applyAction(game, f.observer, { type: 'decision', event: offer.event, redirect: true });
assert.equal(independentSkillsBattlePlayer(game, f.owner).spice, payer - 5);
assert.equal(independentSkillsBattlePlayer(game, f.opponent).spice, payee);
assert.equal(independentSkillsBattlePlayer(game, f.opponent).bribes, 3);
assert.equal(viewGame(game, f.observer).bureaucrat!.usedThisPhase, true);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import {
assertIndependentSkillsRevivalInventory, createIndependentSkillsRevivalFixture,
independentSkillsRevivalPlayer,
} from './fixture-independent-skills-revival';
const spiceOf = (game: Game, id: string): number =>
independentSkillsRevivalPlayer(game, id).spice;
void test('the payer\'s prior free return and the real Recruits activation price the paid revival exactly', () => {
const fixture = createIndependentSkillsRevivalFixture();
// Ordinary caps and rates on the real turn-two Revival state.
assert.deepEqual(fixture.pricing.ordinary, { limit: 3, freeRemaining: 1, forcesRemaining: 3 });
// A genuine free return before the card is retained, never washed away.
assert.deepEqual(fixture.pricing.priorReturn, { limit: 3, freeRemaining: 0, revived: 1, freeUsed: 1 });
const beforeRecruits = independentSkillsRevivalPlayer(fixture.beforeRecruits, fixture.payer);
assert.equal(beforeRecruits.reserves, 16);
assert.equal(beforeRecruits.tanks, 4);
assert.equal(spiceOf(fixture.beforeRecruits, fixture.payer), spiceOf(fixture.atRevival, fixture.payer),
'A free return costs no spice');
// The physical card is really played through the ordinary action.
assert.equal(fixture.beforeMainRevival.recruits?.player, fixture.payer);
assert.deepEqual(fixture.pricing.doubled, { limit: 7, freeRemaining: 1 });
// The independent original price of the requested return, from the real state.
const payer = independentSkillsRevivalPlayer(fixture.beforeMainRevival, fixture.payer);
assert.deepEqual(forceRevivalQuote(fixture.beforeMainRevival, payer, fixture.pricing.revival.amount),
{ free: 1, normalCost: 6, cost: 6 });
assert.deepEqual(fixture.pricing.revival, { amount: 4, free: 1, cost: 6 });
// Without the card the ordinary cap refuses the same request on that state.
const ordinaryOnly = structuredClone(fixture.atRevival);
assert.throws(() => applyAction(ordinaryOnly, fixture.payer, { type: 'revive', amount: 4 }));
// The payer's charge is exactly the quote and its counters move as quoted.
const after = independentSkillsRevivalPlayer(fixture.afterRevival, fixture.payer);
assert.equal(spiceOf(fixture.beforeMainRevival, fixture.payer) - after.spice, fixture.pricing.revival.cost);
assert.equal(after.tanks, beforeRecruits.tanks - fixture.pricing.revival.amount);
assert.equal(after.reserves, beforeRecruits.reserves + fixture.pricing.revival.amount);
assert.equal(after.revived, 1 + fixture.pricing.revival.amount);
assert.equal(after.freeForcesRevived, 1 + fixture.pricing.revival.free);
assertIndependentSkillsRevivalInventory(fixture.afterRevival);
});
void test('the real revival leaves one unavailable Banker spice that the Mentat Pause credits exactly once', () => {
const fixture = createIndependentSkillsRevivalFixture();
const view = viewGame(fixture.afterRevival, fixture.owner).spiceBankerIncome!;
assert.deepEqual(view.deferred, [{ owner: fixture.owner, amount: 1 }]);
assert.deepEqual(view.usedThisPhase, [fixture.owner]);
// Exactly one qualifying Bank payment was recorded, with its real legs.
const granted = fixture.afterRevival.spiceBankerIncome!.sources.filter(source => source.grant);
assert.equal(granted.length, 1);
assert.equal(granted[0].source.kind, 'force-revival');
assert.deepEqual(granted[0].source.bankLegs, [{ payer: fixture.payer, amount: fixture.pricing.revival.cost }]);
assert.equal(granted[0].grant!.owner, fixture.owner);
assert.equal(granted[0].grant!.amount, fixture.grant.deferred);
// The spice is in front of the shield: the wallet cannot advance early.
assert.equal(spiceOf(fixture.afterRevival, fixture.owner), spiceOf(fixture.beforeMainRevival, fixture.owner));
assert.equal(spiceOf(fixture.atMentat2, fixture.owner), spiceOf(fixture.beforeMainRevival, fixture.owner));
assert.deepEqual(viewGame(fixture.atMentat2, fixture.owner).spiceBankerIncome?.deferred,
[{ owner: fixture.owner, amount: 1 }]);
// The actual Mentat Pause pays it once and clears the ledger.
assert.deepEqual(viewGame(fixture.afterMentat, fixture.owner).spiceBankerIncome?.deferred, []);
assert.deepEqual(viewGame(fixture.afterMentat, fixture.owner).spiceBankerIncome?.usedThisPhase, []);
assert.equal(spiceOf(fixture.afterMentat, fixture.owner) - spiceOf(fixture.atMentat2, fixture.owner),
fixture.grant.deferred, 'Exactly the deferred spice arrives, once');
assertIndependentSkillsRevivalInventory(fixture.afterMentat);
});
void test('a free-only or below-threshold real revival on the same state earns no Bank income', () => {
const fixture = createIndependentSkillsRevivalFixture();
for (const control of fixture.controls) {
assert.ok(control.paid < 4, `${control.label}: the payer stays below the Bank threshold`);
assert.equal(control.game.spiceBankerIncome!.sources.filter(source => source.grant).length, 0,
`${control.label}: no Banker income may be earned`);
assert.deepEqual(viewGame(control.game, fixture.owner).spiceBankerIncome?.deferred, []);
assert.deepEqual(viewGame(control.game, fixture.owner).spiceBankerIncome?.usedThisPhase, []);
}
const freeOnly = fixture.controls.find(control => control.revivalAction.amount === 2)!;
assert.equal(freeOnly.paid, 0);
const returned = independentSkillsRevivalPlayer(freeOnly.game, fixture.payer);
assert.equal(returned.tanks, 3);
assert.equal(returned.freeForcesRevived, 2, 'Both returned forces really came from the doubled free rate');
const below = fixture.controls.find(control => control.revivalAction.amount === 3)!;
assert.equal(below.paid, 2);
assert.equal(independentSkillsRevivalPlayer(below.game, fixture.payer).tanks, 2);
});
void test('the Advanced programme preserves real paid revival and deferred Banker collection', () => {
const basic = createIndependentSkillsRevivalFixture();
const advanced = createIndependentSkillsRevivalFixture({ advanced: true });
// The covered revival and Banker mechanics are mode-independent.
assert.deepEqual(advanced.pricing, basic.pricing);
assert.deepEqual(advanced.grant, basic.grant);
assert.deepEqual(advanced.physical, basic.physical);
assert.equal(spiceOf(advanced.afterRevival, advanced.owner),
spiceOf(advanced.beforeMainRevival, advanced.owner));
assert.equal(spiceOf(advanced.afterMentat, advanced.owner) -
spiceOf(advanced.atMentat2, advanced.owner), advanced.grant.deferred);
assert.deepEqual(viewGame(advanced.afterMentat, advanced.owner).spiceBankerIncome?.deferred, []);
assertIndependentSkillsRevivalInventory(advanced.afterMentat);
});
+152
View File
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import {
createPlanetologistIndependentFixture, createPlanetologistEmptyReserveFixture, sealPlanetologistIndependent,
finishPlanetologistIndependent,
} from './fixture-planetologist-independent';
import {
assertIndependentSkillsBattleCustody, independentSkillsBattlePlayer,
quoteIndependentSkillsBattle,
} from './fixture-independent-skills-battle';
void test('native Reinforcements and its Planetologist alternative keep the same score but only the native role spends three reserves', () => {
for (const alternate of [false, true]) {
const f = createPlanetologistIndependentFixture({ alternate });
const before = independentSkillsBattlePlayer(f.game, f.owner);
const sealed = sealPlanetologistIndependent(f);
const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
const trainer = before.leaders.find(leader => leader.id === f.trainer)!;
assert.equal(quote.scores!.attacker, trainer.strength + 2 + 2);
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, alternate ? 2 : 0);
assert.equal(quote.reinforcements?.[0]?.normal ?? 0, alternate ? 0 : 3);
const done = finishPlanetologistIndependent(sealed, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.equal(done.phase, 7);
assert.equal(own.reserves, before.reserves - (alternate ? 0 : 3) + 2);
assert.equal(own.tanks, before.tanks + (alternate ? 0 : 3) + 2);
assert.deepEqual(own.forces, {});
assert.equal(own.spice, before.spice - 2);
for (const card of ['ecaz-reinforcements', 'ecaz-harass-withdraw'])
assert.equal(done.discard.filter(entry => entry.id === card).length, 1);
assertIndependentSkillsBattleCustody(done);
}
});
void test('Harass as the Planetologist Special makes no return while the distinct defense-slot Reinforcements still pays its own fee', () => {
const f = createPlanetologistIndependentFixture({ effect: 'harassWithdraw' });
const before = independentSkillsBattlePlayer(f.game, f.owner);
const sealed = sealPlanetologistIndependent(f);
const quote = quoteIndependentSkillsBattle(sealed);
assert.deepEqual(quote.harassWithdraw ?? [], []);
assert.deepEqual(quote.reinforcements, [{ player: f.owner, card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
const done = finishPlanetologistIndependent(sealed, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.equal(own.reserves, before.reserves - 3);
assert.equal(own.tanks, before.tanks + 3 + 2);
assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 2);
assertIndependentSkillsBattleCustody(done);
});
void test('Recruits can be spent as a green Planetologist Special without activating global Revival or spending an unused Reinforcements', () => {
const f = createPlanetologistIndependentFixture({ effect: 'recruits' });
const before = independentSkillsBattlePlayer(f.game, f.owner);
const sealed = sealPlanetologistIndependent(f);
const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
assert.deepEqual(quote.reinforcements ?? [], []);
const done = finishPlanetologistIndependent(sealed, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.ok(!done.recruits);
assert.equal(own.reserves, before.reserves + 2);
assert.equal(own.tanks, before.tanks + 2);
assert.equal(done.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
assert.ok(own.hand.some(card => card.id === 'ecaz-reinforcements'));
assertIndependentSkillsBattleCustody(done);
});
void test('a poisoned Planetologist loses alternate strength without retroactively activating Reinforcements or suppressing normal Harass', () => {
const f = createPlanetologistIndependentFixture({ poison: true });
const before = independentSkillsBattlePlayer(f.game, f.owner);
const sealed = sealPlanetologistIndependent(f);
const quote = quoteIndependentSkillsBattle(sealed, { returns: f.returns });
assert.equal(quote.leaderDeaths.attacker, true);
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 0);
assert.deepEqual(quote.reinforcements ?? [], []);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
const done = finishPlanetologistIndependent(sealed, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.equal(own.leaders.find(leader => leader.id === f.trainer)!.dead, true);
assert.equal(own.reserves, before.reserves + 2);
assert.equal(own.tanks, before.tanks + 2);
assert.ok(done.leaderSkills!.deck.includes('planetologist'));
assertIndependentSkillsBattleCustody(done);
});
void test('the independent green alternate role also preserves Basic support and cannot be declared using another leader', () => {
const f = createPlanetologistIndependentFixture({ advanced: false });
const own = independentSkillsBattlePlayer(f.game, f.owner);
const other = own.leaders.find(leader => !leader.dead && leader.id !== f.trainer)!;
assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, leader: other.id }));
const done = finishPlanetologistIndependent(sealPlanetologistIndependent(f), f);
assert.equal(done.phase, 7);
assert.equal(independentSkillsBattlePlayer(done, f.owner).spice, own.spice);
assert.equal(independentSkillsBattlePlayer(done, f.owner).tanks, own.tanks + 2);
assertIndependentSkillsBattleCustody(done);
});
void test('zero reserves block native Reinforcements before a later Harass return but not its printed Planetologist substitution', () => {
const f = createPlanetologistEmptyReserveFixture();
const before = independentSkillsBattlePlayer(f.game, f.owner);
assert.equal(before.reserves, 0);
assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, planetologistSpecial: false }));
const sealed = sealPlanetologistIndependent(f);
const quote = quoteIndependentSkillsBattle(sealed);
assert.deepEqual(quote.reinforcements ?? [], []);
assert.equal(quote.payments.find(payment => payment.player === f.owner)!.cost, 0);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 4, elite: 0 });
const done = finishPlanetologistIndependent(sealed, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.equal(done.phase, 7);
assert.equal(own.reserves, 4);
assert.equal(own.tanks, 1);
assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 15);
assertIndependentSkillsBattleCustody(done);
});
void test('a truthful named Reinforcements promise at zero reserves finds and executes the Planetologist alternative', () => {
const f = createPlanetologistEmptyReserveFixture();
let game = structuredClone(f.game);
const index = game.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(index >= 0);
const truth = game.deck.splice(index, 1)[0];
independentSkillsBattlePlayer(game, f.observer).hand.push(truth);
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!;
game = applyAction(game, actor.id, { type: 'truthPass' });
}
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
{ kind: 'weapon', name: 'Reinforcements' },
{ kind: 'dial', compare: 'eq', value: 0.5 },
{ kind: 'support', compare: 'eq', value: 0 },
] },
} });
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'));
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
const witness = viewGame(game, f.owner).battle!.compliantPlan;
assert.ok(witness);
game = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
game = applyAction(game, f.opponent, f.plans[1].action);
const done = finishPlanetologistIndependent(game, f);
const own = independentSkillsBattlePlayer(done, f.owner);
assert.equal(done.phase, 7);
assert.equal(own.reserves, 0);
assert.equal(own.tanks, 1);
assert.equal(Object.values(own.forces).reduce((sum, count) => sum + count, 0), 19);
assert.ok(own.hand.some(card => card.id === 'ecaz-harass-withdraw'));
assert.equal(done.discard.filter(card => card.id === 'ecaz-reinforcements').length, 1);
assertIndependentSkillsBattleCustody(done);
});