Files
Dune/tests/nexus-skills-modules-battles-runtime.test.ts
T

412 lines
29 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { casualtyOptions, maxCombatDial } from '../game/combat';
import { TECH_TOKENS, ownedTech } from '../game/tech-tokens';
import { strongholdControllers } from '../game/stronghold-cards';
import { nexusInventory } from './fixture-nexus-cards';
import {
advanceNexusSkillsModulesBattle, beginNexusSkillsModulesCunning,
createNexusSkillsModulesBattleFixture, finishNexusSkillsModulesBattle,
nexusSkillsModulesBattlePlayer as player, prepareNexusSkillsModulesBattle,
quoteNexusSkillsModulesBattle, revealNexusSkillsModulesBattle, stepNexusSkillsModulesBattle,
type NexusSkillsModulesBattleFixture,
} from './fixture-nexus-skills-modules-battles';
function custody(game: Game): void {
nexusInventory(game);
const skills = game.leaderSkills!;
assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.equal(cards.length, 33); assert.equal(new Set(cards.map(card => card.id)).size, 33);
for (const seat of game.players) {
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
if (seat.elites) {
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0),
seat.faction === 'fremen' ? 3 : 5);
assert.ok(seat.elites.reserves <= seat.reserves && seat.elites.tanks <= seat.tanks);
for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0));
}
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function moneyAtCleanup(fixture: NexusSkillsModulesBattleFixture, revealed: Game, cleanup: Game): void {
const quote = quoteNexusSkillsModulesBattle(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards');
assert.equal(cleanup.lastBattleContext!.winner, quote.winner);
for (const seat of revealed.players) {
const debit = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0);
const income = quote.strongholdIncome.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.amount, 0);
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
assert.equal(player(cleanup, seat.id).spice, seat.spice - debit + income + bounty,
'Actual payer debit and printed-disc bounty settle once; the bank subsidy is not personal income');
}
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Winner Tech reward waits for original card cleanup');
assert.deepEqual(cleanup.strongholdCards, fixture.afterFirstMentat.strongholdCards, 'Battle does not acquire or steal a held Stronghold Card');
custody(cleanup);
}
for (const advanced of [false, true]) for (const count of [3, 4, 5, 6]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Tech ${count}: original Storm assignment, saved hands/offers, alliance and closing Emperor draw are replayable`, () => {
const fixture = createNexusSkillsModulesBattleFixture({ advanced, tech: true, strongholds: advanced,
seatIds: Array.from({ length: count }, (_, index) => `human-${advanced}-${count}-${index}`) });
for (const game of [fixture.initial, fixture.afterSetup, fixture.afterFirstStorm, fixture.afterFirstMentat, fixture.afterDraw, fixture.beforeBattle]) custody(game);
assert.ok(TECH_TOKENS.some(token => fixture.beforeFirstStorm.techTokens![token.id].owner === null));
const storm = stepNexusSkillsModulesBattle(fixture.beforeFirstStorm, fixture.firstStormStep);
assert.deepEqual(storm, fixture.afterFirstStorm);
assert.ok(TECH_TOKENS.every(token => storm.techTokens![token.id].owner));
assert.equal(new Set(TECH_TOKENS.map(token => storm.techTokens![token.id].owner)).size, 3);
const mentat = stepNexusSkillsModulesBattle(fixture.beforeFirstMentat, fixture.firstMentatStep);
assert.deepEqual(mentat, fixture.afterFirstMentat);
if (advanced) {
assert.deepEqual(mentat.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, false));
assert.equal(mentat.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(strongholdControllers(fixture.beforeBattle.players, false).arrakeen, null,
'Real later contested positions differ from retained first-Mentat custody');
}
let allied = fixture.beforeAlliance;
for (const action of fixture.allianceActions) allied = stepNexusSkillsModulesBattle(allied, action);
assert.equal(player(allied, fixture.observer).ally, fixture.partner);
assert.equal(player(allied, fixture.partner).ally, fixture.observer);
assert.equal(player(fixture.beforeDraw, fixture.owner).ally, null);
const drawn = stepNexusSkillsModulesBattle(fixture.beforeDraw, fixture.drawAction);
assert.deepEqual(drawn, fixture.afterDraw);
assert.equal(drawn.nexusCards!.cards!.hands[fixture.owner], 'emperor');
let saved = structuredClone(fixture.initial);
while (saved.setupStage === 'prediction') saved = stepNexusSkillsModulesBattle(saved);
const resumed = createNexusSkillsModulesBattleFixture({ initial: saved, skill: 'warmaster' });
assert.deepEqual(resumed.initial, saved);
assert.deepEqual(resumed.initial.players.map(seat => [seat.id, seat.hand]), saved.players.map(seat => [seat.id, seat.hand]));
assert.deepEqual(resumed.initial.leaderSkills!.offers, saved.leaderSkills!.offers);
const unavailable = LEADER_SKILL_CARDS.find(card => !saved.leaderSkills!.offers[fixture.owner]?.cards.includes(card.id))!;
reject(fixture.beforeBattle, fixture.observer, fixture.battleAction.action);
assert.throws(() => createNexusSkillsModulesBattleFixture({ initial: saved, skill: unavailable.id }));
assert.deepEqual(saved, resumed.initial);
});
}
for (const band of ['normal', 'skilled'] as const) {
void test(`held Arrakeen + ${band} Warmaster: bank two reduces debit, not three supported temporary-counter casualties or printed bounty`, () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, band, ownerWeapon: 'projectile', ownerDefense: 'worthless' });
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const forces = viewGame(revealed, fixture.owner).battle!.ownForces!;
const quote = quoteNexusSkillsModulesBattle(revealed);
assert.equal(forces.normal, 7); assert.equal(forces.elite, 0); assert.equal(forces.temporaryElite, 5);
assert.equal(maxCombatDial(forces), 12);
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1); assert.equal(payment.allyPayment, 0);
assert.deepEqual(quote.casualties!.options.map(loss => [loss.normal, loss.elite, loss.paidNormal, loss.paidElite]),
[[3, 0, 3, 0], [4, 0, 3, 0], [5, 0, 3, 0]]);
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.equal(bonus.bonus, band === 'skilled' ? 3 : 1);
const opposingDisc = player(revealed, fixture.opponent).leaders.find(disc => disc.id === revealed.battle!.plans[fixture.opponent].leader)!;
assert.equal(quote.bounty!.amount, opposingDisc.strength);
let losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
assert.ok(losses.decision?.kind === 'battleLosses');
reject(losses, fixture.opponent, { type: 'decision', choice: 2 });
losses = applyAction(losses, fixture.owner, { type: 'decision', choice: 2 });
assert.equal(player(losses, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 5);
assert.deepEqual(player(losses, fixture.owner).elites, player(revealed, fixture.owner).elites);
assert.equal(losses.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete');
moneyAtCleanup(fixture, revealed, losses);
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
const done = finishNexusSkillsModulesBattle(losses);
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
custody(done);
});
}
for (const band of ['normal', 'skilled'] as const) for (const support of [3, 6]) {
void test(`${band} Suk with held Arrakeen support ${support}: original support eligibility survives bank subsidy and rescue settles before winner reward`, () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', band,
support, ownerDial: support === 6 ? 10 : 6, ownerDefense: 'shield' });
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const quote = quoteNexusSkillsModulesBattle(revealed);
const physicalLoss = support === 6 ? 6 : band === 'normal' ? 4 : 5;
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, support - 2);
if (support === 6) {
assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]);
assert.notDeepEqual(quote.casualties!.options, casualtyOptions(quote.casualties!.forces, 10, payment.ownPayment));
}
let game: Game;
if (support === 3) {
const losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
assert.ok(losses.decision?.kind === 'battleLosses');
game = applyAction(losses, fixture.owner, { type: 'decision', choice: band === 'normal' ? 1 : 2 });
} else game = finishNexusSkillsModulesBattle(revealed, { stop: band === 'skilled' ? 'rescue' : 'cleanup' });
const before = player(revealed, fixture.owner);
if (band === 'skilled') {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
const pending = game.pendingSukRescue!;
assert.equal(pending.commitment.forces.temporaryElite, 5);
assert.ok(pending.losses);
assert.equal(pending.losses.normal, physicalLoss); assert.equal(pending.losses.paidNormal, support);
assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'pending');
assert.equal(player(game, fixture.owner).tanks, before.tanks);
assert.deepEqual(game.techTokens, revealed.techTokens);
const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key && option.kept.kind === 'normal');
assert.ok(choice >= 0);
const action: Action = { type: 'decision', event: decision.event, choice };
reject(game, fixture.opponent, action);
reject(game, fixture.owner, { ...action, event: `${decision.event}-stale` });
game = applyAction(game, fixture.owner, action);
reject(game, fixture.owner, action);
}
const saved = band === 'skilled' ? 3 : 1, kept = band === 'skilled' ? 1 : 0;
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + kept);
assert.equal(player(game, fixture.owner).reserves, before.reserves + saved - kept);
assert.equal(player(game, fixture.owner).tanks, before.tanks + physicalLoss - saved);
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + physicalLoss - saved);
assert.deepEqual(player(game, fixture.owner).elites, before.elites);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete');
moneyAtCleanup(fixture, revealed, game);
custody(finishNexusSkillsModulesBattle(game));
});
}
void test('actual starred Sardaukar block ordinary-as-Sardaukar Cunning; native typed Suk rescue keeps the selected star rather than creating an elite Tank', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', ownerElites: 1,
ownerDial: 5, support: 3, ownerDefense: 'shield' });
const prepared = prepareNexusSkillsModulesBattle(fixture);
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
assert.equal(prepared.nexusCards!.cards!.hands[fixture.owner], 'emperor');
const revealed = revealNexusSkillsModulesBattle(fixture, { state: prepared });
const own = viewGame(revealed, fixture.owner).battle!.ownForces!;
assert.equal(own.normal, 7); assert.equal(own.elite, 1); assert.equal(own.temporaryElite ?? 0, 0);
let game = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' });
assert.ok(game.decision?.kind === 'battleLosses');
const loss = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 1); assert.ok(loss >= 0);
game = applyAction(game, fixture.owner, { type: 'decision', choice: loss });
const rescue = game.decision; assert.ok(rescue?.kind === 'sukRescue');
const choice = rescue.options.findIndex(option => option.normal === 2 && option.elite === 1 && option.kept?.kind === 'elite'); assert.ok(choice >= 0);
const original = player(revealed, fixture.owner);
const physicalRescue = structuredClone(game);
game = applyAction(game, fixture.owner, { type: 'decision', event: rescue.event, choice });
assert.equal(player(game, fixture.owner).forces[fixture.key], 4);
assert.equal(player(game, fixture.owner).elites!.forces[fixture.key], 1);
assert.equal(player(game, fixture.owner).reserves, original.reserves + 2);
assert.equal(player(game, fixture.owner).elites!.reserves, original.elites!.reserves);
assert.equal(player(game, fixture.owner).tanks, original.tanks + 2);
assert.equal(player(game, fixture.owner).elites!.tanks, original.elites!.tanks);
moneyAtCleanup(fixture, revealed, game);
custody(finishNexusSkillsModulesBattle(game));
const normalChoice = rescue.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.kind === 'normal');
assert.ok(normalChoice >= 0);
const ordinary = applyAction(physicalRescue, fixture.owner, { type: 'decision', event: rescue.event, choice: normalChoice });
assert.equal(player(ordinary, fixture.owner).forces[fixture.key], 4);
assert.equal(player(ordinary, fixture.owner).elites!.forces[fixture.key] ?? 0, 0);
assert.equal(player(ordinary, fixture.owner).reserves, original.reserves + 2);
assert.equal(player(ordinary, fixture.owner).elites!.reserves, original.elites!.reserves);
assert.equal(player(ordinary, fixture.owner).tanks, original.tanks + 2);
assert.equal(player(ordinary, fixture.owner).elites!.tanks, original.elites!.tanks + 1,
'An unsaved actual starred casualty enters starred Tanks; temporary ordinary roles never do');
moneyAtCleanup(fixture, revealed, ordinary);
custody(finishNexusSkillsModulesBattle(ordinary));
});
void test('held Carthag physical Shield provides poison protection after first-Mentat positions change, preserving the actual skilled Suk entitlement', () => {
for (const strongholds of [false, true]) {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds, kind: 'carthag', skill: 'suk-graduate',
ownerDefense: 'shield', opponentWeapon: 'poison' });
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const quote = quoteNexusSkillsModulesBattle(revealed);
const death = revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender;
assert.equal(death, !strongholds);
assert.equal(!!quote.sukGraduate, strongholds);
if (strongholds) {
assert.equal(fixture.afterFirstMentat.strongholdCards!.owners.carthag, fixture.owner);
assert.equal(strongholdControllers(fixture.beforeBattle.players, false).carthag, null);
const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' });
assert.equal(rescue.decision?.kind, 'sukRescue');
const cleanup = finishNexusSkillsModulesBattle(rescue, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, false);
moneyAtCleanup(fixture, revealed, cleanup);
} else {
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, true);
assert.equal(cleanup.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.trainer), false);
assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1);
assert.equal(cleanup.pendingSukRescue ?? null, null);
const printed = player(revealed, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.strength;
assert.equal(quote.bounty!.amount, printed);
moneyAtCleanup(fixture, revealed, cleanup);
}
}
});
void test('several losing-owner tokens are actually earned by first-turn combat; original rescue, winner disposal, loser retention, mandatory token selection and inspection remain separate', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, opponentTokens: 'several', opponentNexus: 'moritani',
skill: 'suk-graduate', ownerDefense: 'shield', opponentDefense: 'shield' });
assert.equal(fixture.warmupAfterBattle!.lastBattleContext!.winner, fixture.opponent);
assert.equal(ownedTech(fixture.afterFirstStorm.techTokens, fixture.opponent).length, 1);
const original = ownedTech(fixture.game.techTokens, fixture.opponent); assert.equal(original.length, 2);
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
moneyAtCleanup(fixture, revealed, cleanup);
const defense = fixture.planActions[0].action.defense!;
const disposal: Action = { type: 'decision', discard: [defense] };
reject(cleanup, fixture.opponent, disposal);
const disposed = applyAction(cleanup, fixture.owner, disposal);
assert.equal(player(disposed, fixture.owner).hand.some(card => card.id === defense), false);
assert.equal(disposed.discard.filter(card => card.id === defense).length, 1);
const retention = finishNexusSkillsModulesBattle(disposed, { stop: 'retention' });
assert.ok(retention.decision?.kind === 'moritaniRetention' && retention.decision.source === 'nexus');
assert.deepEqual(retention.techTokens, revealed.techTokens);
const keep: Action = { type: 'decision', event: retention.decision.event, keep: fixture.planActions[1].action.defense! };
reject(retention, fixture.owner, keep);
reject(retention, fixture.opponent, { ...keep, event: `${retention.decision.event}-old` });
const retained = applyAction(retention, fixture.opponent, keep);
assert.equal(player(retained, fixture.opponent).hand.some(card => card.id === keep.keep), true);
assert.equal(retained.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
for (const token of original) {
const choice = finishNexusSkillsModulesBattle(retained, { stop: 'tech' });
assert.ok(choice.decision?.kind === 'techToken');
assert.deepEqual(choice.decision.choices, original);
reject(choice, fixture.opponent, { type: 'decision', token });
reject(choice, fixture.owner, { type: 'decision', decline: true });
const selected = applyAction(choice, fixture.owner, { type: 'decision', token });
assert.equal(selected.techTokens![token].owner, fixture.owner);
assert.equal(selected.techTokens![original.find(id => id !== token)!].owner, fixture.opponent);
const inspection = finishNexusSkillsModulesBattle(selected, { stop: 'inspection' });
assert.ok(inspection.decision?.kind === 'nexusChoamInspection');
assert.equal(viewGame(inspection, fixture.owner).nexusChoamInspection!.canInspect, false,
'Spent Emperor Cunning is not a held CHOAM card and cannot inspect a fresh card');
const decline: Action = { type: 'decision', event: inspection.decision.event, inspect: false };
reject(inspection, fixture.opponent, decline);
reject(inspection, fixture.owner, { ...decline, event: `${inspection.decision.event}-old` });
const declined = applyAction(inspection, fixture.owner, decline);
reject(declined, fixture.owner, decline);
custody(finishNexusSkillsModulesBattle(declined));
}
});
void test('physical Karama cancellation leaves held Arrakeen support legal and does not fabricate temporary roles or spend Tech', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, counter: true, ownerDial: 4, support: 4 });
let game = beginNexusSkillsModulesCunning(fixture);
assert.equal(game.response?.kind, 'nexusSardaukar');
reject(game, fixture.owner, { type: 'card', card: fixture.counter!, mode: 'cancel' });
game = applyAction(game, fixture.opponent, { type: 'card', card: fixture.counter!, mode: 'cancel' });
game = advanceNexusSkillsModulesBattle(game, state => !state.response && !state.phaseOpening && !state.decision);
assert.equal(game.nexusSardaukarLast!.stage, 'canceled');
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(game.discard.filter(card => card.id === fixture.counter).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
const revealed = revealNexusSkillsModulesBattle(fixture, { state: game });
const payment = quoteNexusSkillsModulesBattle(revealed).payments.find(payment => payment.player === fixture.owner)!;
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 2);
moneyAtCleanup(fixture, revealed, finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }));
});
void test('Basic Tech keeps the original blocked Cunning offer, no Advanced support fee, and mandatory physical winner reward', () => {
const fixture = createNexusSkillsModulesBattleFixture({ advanced: false, tech: true });
const prepared = prepareNexusSkillsModulesBattle(fixture);
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
assert.equal(maxCombatDial(viewGame(prepared, fixture.owner).battle!.ownForces!), 7);
const revealed = revealNexusSkillsModulesBattle(fixture);
const quote = quoteNexusSkillsModulesBattle(revealed);
assert.deepEqual(quote.payments, []); assert.equal(quote.casualties, null); assert.equal(quote.basicWinnerLosses, 6);
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 6);
assert.equal(cleanup.nexusCards!.cards!.hands[fixture.owner], 'emperor');
moneyAtCleanup(fixture, revealed, cleanup);
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
const done = finishNexusSkillsModulesBattle(cleanup);
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
custody(done);
});
void test('a sealed plan and later battle reject stale Cunning; the physical spent group expires through actual END Mentat and next Storm', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, secondBattle: true, ownerDial: 6, support: 3 });
let sealed = prepareNexusSkillsModulesBattle(fixture);
const event = viewGame(sealed, fixture.owner).nexusSardaukar!.offer!.event;
sealed = applyAction(sealed, fixture.owner, { ...fixture.planActions[0].action, dial: 4, support: 4 });
reject(sealed, fixture.owner, { type: 'nexusSardaukar', event });
assert.equal(sealed.nexusCards!.cards!.hands[fixture.owner], 'emperor');
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const oldEvent = revealed.nexusSardaukarLast!.event;
let game = finishNexusSkillsModulesBattle(revealed);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'carthag', target: actor === fixture.owner ? fixture.opponent : fixture.owner });
game = prepareNexusSkillsModulesBattle(fixture, game);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, 1);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
const candidates = (id: string) => player(game, id).leaders.filter(disc => !disc.dead && !disc.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === disc.id));
for (const id of [fixture.owner, fixture.opponent]) {
game = applyAction(game, id, { type: 'battlePlan', leader: candidates(id)[0].id, dial: 0, support: 0, weapon: null, defense: null });
while (game.phaseOpening || game.response || game.decision?.kind === 'fullPlanOffer') game = stepNexusSkillsModulesBattle(game);
}
game = finishNexusSkillsModulesBattle(game);
game = advanceNexusSkillsModulesBattle(game, state => state.turn === 3 && state.phase === 1 && !state.response && !state.phaseOpening && !state.decision);
assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false);
assert.equal(game.nexusSardaukarHistory![0].receipt.turn, 2);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
custody(game);
});
void test('four original legal policies rescue three physical ordinary counters and retain their native support budget before cleanup and Tech transfer', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', support: 6,
ownerDial: 10, ownerDefense: 'shield' });
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' });
assert.ok(rescue.decision?.kind === 'sukRescue');
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(rescue, fixture.owner);
view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'decision');
assert.ok(action && typeof action.choice === 'number');
const option: (typeof rescue.decision.options)[number] = rescue.decision.options[action.choice];
assert.equal(option.normal, 3); assert.equal(option.elite, 0); assert.equal(option.kept?.key, fixture.key);
const cleanup = applyAction(rescue, fixture.owner, action);
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 2);
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice - 4);
moneyAtCleanup(fixture, revealed, cleanup);
custody(finishNexusSkillsModulesBattle(cleanup));
let prepared = prepareNexusSkillsModulesBattle(fixture);
const openingView = viewGame(prepared, fixture.owner);
openingView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
const cunning = botActions(openingView).find(action => action.type === 'nexusSardaukar'); assert.ok(cunning);
prepared = applyAction(prepared, fixture.owner, cunning);
prepared = advanceNexusSkillsModulesBattle(prepared, state => !state.response && !state.phaseOpening && !state.decision);
const planView = viewGame(prepared, fixture.owner);
planView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
const plan = botActions(planView).find(action => action.type === 'battlePlan'); assert.ok(plan);
assert.equal(typeof plan.support, 'number');
assert.ok(Number(plan.support) <= player(prepared, fixture.owner).spice + 2);
const committed = applyAction(prepared, fixture.owner, plan);
assert.ok(committed.battle!.plans[fixture.owner]);
assert.equal(player(committed, fixture.owner).spice, player(prepared, fixture.owner).spice,
'The legal bot seals its budget; no debit is taken before actual battle resolution');
}
});
void test('Advanced Strongholds without Tech retain the same original Skills/Nexus Arrakeen rescue and physical inventory', () => {
const fixture = createNexusSkillsModulesBattleFixture({ tech: false, strongholds: true, skill: 'suk-graduate',
support: 6, ownerDial: 10, ownerDefense: 'shield' });
const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true });
const quote = quoteNexusSkillsModulesBattle(revealed);
assert.equal(quote.payments.find(payment => payment.player === fixture.owner)!.ownPayment, 4);
const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
assert.equal(cleanup.pendingTech ?? null, null);
moneyAtCleanup(fixture, revealed, cleanup);
custody(finishNexusSkillsModulesBattle(cleanup));
});