Compose independent utility cards with classic Leader Skills
This commit is contained in:
1 parent
40a5e51229
commit
5a32c1ac85
23 files changed
+1845
-87
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user