Files
Dune/tests/classic-nexus-skills-battles-runtime.test.ts
T
Quintine 7e3bec2721 Implement the five 7 October pending rule rulings
The user answered five recorded questions; each ruling is indexed in
RULE_DECISIONS.md and implemented with a focused regression.

- CHOAM opening income counts as a qualifying collection, so low-population
  Tupile adds its bank increment under the Inflation multiplier. The engine
  guard is removed and the projection/notice expose the added bonus.
- An exhausted Richese cache is legal and restores one ordinary lot; the
  canceled/prevented compulsory cache auction branch restores the reduction
  before the ordinary pool runs and the cache offer is skipped when no cache
  lot can run. The pure continuation quote mirrors the count.
- Suk-rescued casualties count toward Advanced Atreides' seven Kwisatz
  Haderach losses: the battle-loss counter gains the full dialed allocation in
  both rescue paths, and the earlier Suk availability restriction is removed.
- The Guild Nexus Secret Ally reserve return overrides the Arrakis-to-native-
  Homeworld restriction for the holder's own reserves and deposits through the
  shared native-reserve pipeline (generalized from the revival deposit).
- Interior passengers count as entering the territories a relocated Ixian HMS
  points into, so Sandmaster collects there before the native collection.

Full offline suite 7804/7804, types and lint pass. Historical evidence and
superseded test expectations are updated to the ruled behaviour; preserved
combined Homeworld/Nexus, occupation and Richese captures are unblocked but
not re-run here. No existing game, seat or saved state was changed.
2026-10-07 21:56:17 +11:00

322 lines
22 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 { maxCombatDial } from '../game/combat';
import { nexusInventory } from './fixture-nexus-cards';
import {
advanceClassicNexusSkillsBattle, beginClassicNexusSkillsCunning,
classicNexusSkillsBattlePlayer as player, createClassicNexusSkillsBattleFixture,
finishClassicNexusSkillsBattle, prepareClassicNexusSkillsBattle,
quoteClassicNexusSkillsBattle, revealClassicNexusSkillsBattle, stepClassicNexusSkillsBattle,
type ClassicNexusSkillsBattleFixture,
} from './fixture-classic-nexus-skills-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());
for (const seat of game.players) 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);
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(revealed: Game, cleanup: Game): void {
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards');
assert.equal(cleanup.lastBattleContext!.winner, quote.winner);
for (const seat of revealed.players) {
const payment = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0);
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
assert.equal(player(cleanup, seat.id).spice, seat.spice - payment + bounty,
'Original battle payment and printed-disc bounty settle once before Collection');
}
assert.deepEqual(quote.strongholdIncome, []);
custody(cleanup);
}
function lossWindow(fixture: ClassicNexusSkillsBattleFixture): { revealed: Game; game: Game } {
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const game = finishClassicNexusSkillsBattle(revealed, { stop: 'losses' });
assert.equal(game.decision?.kind, 'battleLosses');
return { revealed, game };
}
for (const advanced of [false, true]) for (const count of [3, 4, 5, 6]) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${count}-seat original setup, Mentat, reciprocal alliance and closing draw preserve physical sources and actor IDs`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ advanced,
seatIds: Array.from({ length: count }, (_, index) => `original-${advanced}-${count}-${index}`) });
for (const game of [fixture.initial, fixture.afterSetup, fixture.afterFirstMentat, fixture.beforeDraw, fixture.afterDraw, fixture.beforeBattle]) custody(game);
const mentat = stepClassicNexusSkillsBattle(fixture.beforeFirstMentat, fixture.firstMentatStep);
assert.equal(mentat.turn, 2);
assert.deepEqual(mentat, fixture.afterFirstMentat);
let alliance = fixture.beforeAlliance;
for (const action of fixture.allianceActions) alliance = stepClassicNexusSkillsBattle(alliance, action);
assert.equal(player(alliance, fixture.observer).ally, fixture.partner);
assert.equal(player(alliance, fixture.partner).ally, fixture.observer);
assert.equal(player(fixture.beforeDraw, fixture.owner).ally, null);
assert.ok(fixture.beforeDraw.nexusCards!.phase!.eligible.includes(fixture.owner));
assert.equal(fixture.beforeDraw.nexusCards!.cards!.hands[fixture.owner], null);
const drawn = stepClassicNexusSkillsBattle(fixture.beforeDraw, fixture.drawAction);
assert.deepEqual(drawn, fixture.afterDraw);
assert.equal(drawn.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(drawn.nexusCards!.cards!.deck.includes('emperor'), false);
assert.deepEqual(fixture.beforeBattle.players.map(seat => seat.id), fixture.initial.players.map(seat => seat.id));
assert.deepEqual([...fixture.afterSetup.spiceDeck, ...fixture.afterSetup.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(),
[...fixture.beforeBattle.spiceDeck, ...fixture.beforeBattle.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort());
assert.deepEqual([...fixture.afterSetup.traitorReserve!, ...fixture.afterSetup.players.flatMap(seat => seat.traitors)].sort(),
[...fixture.beforeBattle.traitorReserve!, ...fixture.beforeBattle.players.flatMap(seat => seat.traitors)].sort());
});
}
void test('original undealt authenticated CLI setup is consumed without replacing saved offers or actor identities', () => {
const original = createClassicNexusSkillsBattleFixture({ seatIds: ['human-emperor', 'human-guild', 'human-atreides', 'human-fremen'] }).initial;
const before = structuredClone(original);
const fixture = createClassicNexusSkillsBattleFixture({ initial: original, skill: 'warmaster', ownerDial: 4, support: 4 });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, original);
assert.equal(fixture.owner, 'human-emperor'); assert.equal(fixture.opponent, 'human-guild');
const revealed = revealClassicNexusSkillsBattle(fixture);
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.equal(revealed.nexusCards!.cards!.hands[fixture.owner], 'emperor');
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
for (const band of ['normal', 'skilled'] as const) {
void test(`native Advanced Cunning plus ${band} Warmaster counts five ordinary counters temporarily, never creates elite Tanks or discounted support`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', band, ownerWeapon: 'projectile', ownerDefense: 'worthless' });
const original = structuredClone(player(fixture.game, fixture.owner));
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
const ownForces = viewGame(revealed, fixture.owner).battle!.ownForces!;
assert.equal(ownForces.normal, 7); assert.equal(ownForces.elite, 0); assert.equal(ownForces.temporaryElite, 5);
assert.equal(maxCombatDial(ownForces), 12);
assert.equal(quote.winner, fixture.owner);
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.deepEqual(bonus, { bonus: band === 'skilled' ? 3 : 1,
applied: [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }] });
const ownScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.attacker : quote.scores!.defender;
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.owner].leader)!;
assert.equal(ownScore, 6 + disc.strength + bonus.bonus);
const opposingDisc = player(revealed, fixture.opponent).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.opponent].leader)!;
assert.equal(quote.bounty!.amount, opposingDisc.strength, 'Leader Skill strength is not part of printed-disc bounty');
assert.equal(quote.bounty!.player, fixture.owner);
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
assert.equal(payment.ownPayment, 3); assert.equal(payment.bankSupport, 0); 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]]);
let game = finishClassicNexusSkillsBattle(revealed, { stop: 'losses' });
game = applyAction(game, fixture.owner, { type: 'decision', choice: 2 });
assert.equal(player(game, fixture.owner).forces[fixture.key], 2);
assert.equal(player(game, fixture.owner).tanks, original.tanks + 5);
assert.deepEqual(player(game, fixture.owner).elites, original.elites);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
moneyAtCleanup(revealed, game);
const finished = finishClassicNexusSkillsBattle(game);
assert.deepEqual(player(finished, fixture.owner).elites, original.elites);
assert.equal(viewGame(finished, fixture.owner).nexusSardaukar!.active, false);
custody(finished);
});
}
for (const band of ['normal', 'skilled'] as const) {
void test(`the original ${band} Warmaster modifier decides a native Cunning battle rather than merely decorating a larger dial`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', band, ownerDial: 0, support: 0,
opponentDial: band === 'normal' ? 0.5 : 5, opponentSupport: band === 'normal' ? 0 : 5 });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
const ownScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.attacker : quote.scores!.defender;
const opposingScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.defender : quote.scores!.attacker;
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.owner].leader)!;
assert.ok(disc.strength < opposingScore);
assert.ok(ownScore > opposingScore);
assert.equal(quote.winner, fixture.owner);
assert.equal(quote.payments.find(payment => payment.player === fixture.owner)!.ownPayment, 0);
assert.equal(quote.casualties!.forces.temporaryElite, 5);
assert.equal(quote.casualties!.options[0].normal, 0);
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
}
void test('a killed skilled Warmaster loses the actual physical skill and pays only printed-disc bounty', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', opponentWeapon: 'projectile' });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(quote.winner, fixture.owner, 'Actual dial six exceeds the opposing surviving printed disc even when the skilled leader dies');
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.equal(bonus.bonus, 0);
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === fixture.trainer)!;
assert.equal(quote.bounty!.amount, disc.strength);
assert.equal(quote.bounty!.player, fixture.owner);
const cleanup = finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.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 === 'warmaster').length, 1);
moneyAtCleanup(revealed, cleanup);
});
for (const band of ['normal', 'skilled'] as const) {
void test(`actual ${band} native Emperor Suk rescues Cunning ordinary casualties before winner cleanup, paying original support once`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', band, ownerDefense: 'shield' });
const original = structuredClone(player(fixture.game, fixture.owner));
const { revealed, game: losses } = lossWindow(fixture);
const choice = band === 'normal' ? 1 : 2;
const physicalLoss = band === 'normal' ? 4 : 5;
const saved = band === 'normal' ? 1 : 3;
assert.ok(losses.decision?.kind === 'battleLosses');
assert.equal(losses.decision.options[choice].normal, physicalLoss);
assert.equal(losses.decision.options[choice].elite, 0);
reject(losses, fixture.opponent, { type: 'decision', choice });
let game = applyAction(losses, fixture.owner, { type: 'decision', choice });
if (band === 'skilled') {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
assert.equal(game.pendingSukRescue!.commitment.forces.temporaryElite, 5);
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: fixture.key, normal: 7, elite: 0 }]);
assert.deepEqual(game.pendingSukRescue!.losses, losses.decision.options[choice]);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'pending');
assert.equal(player(game, fixture.owner).tanks, original.tanks);
const rescue = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key && option.kept.kind === 'normal');
assert.ok(rescue >= 0);
const action: Action = { type: 'decision', event: decision.event, choice: rescue };
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);
}
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).reserves, original.reserves + saved - (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).tanks, original.tanks + physicalLoss - saved);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(player(game, fixture.owner).battleLosses, original.battleLosses + physicalLoss);
assert.deepEqual(player(game, fixture.owner).elites, original.elites);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
assert.equal(player(game, fixture.owner).spice, player(revealed, fixture.owner).spice - 3);
moneyAtCleanup(revealed, game);
custody(finishClassicNexusSkillsBattle(game));
});
}
void test('a killed skilled native Suk disc creates no rescue entitlement and returns its singleton skill to the original deck', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', opponentWeapon: 'projectile', ownerWeapon: 'worthless' });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
assert.equal(quoteClassicNexusSkillsBattle(revealed).sukGraduate, undefined);
const cleanup = finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' });
assert.equal(cleanup.pendingSukRescue ?? null, null);
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.trainer)!.dead, true);
assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1);
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
moneyAtCleanup(revealed, cleanup);
});
void test('original physical Karama cancels Cunning, spends each singleton once, and leaves a legal ordinary Warmaster plan', () => {
const fixture = createClassicNexusSkillsBattleFixture({ counter: true, ownerDial: 4, support: 4 });
const prepared = prepareClassicNexusSkillsBattle(fixture);
const elites = structuredClone(player(prepared, fixture.owner).elites);
let game = beginClassicNexusSkillsCunning(fixture, prepared);
assert.equal(game.response?.kind, 'nexusSardaukar');
game = applyAction(game, fixture.opponent, { type: 'card', card: fixture.counter!, mode: 'cancel' });
game = advanceClassicNexusSkillsBattle(game, state => !state.response && !state.decision && !state.phaseOpening);
assert.equal(game.nexusSardaukarLast!.stage, 'canceled');
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.deepEqual(player(game, fixture.owner).elites, elites);
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 = revealClassicNexusSkillsBattle(fixture, { state: game });
assert.equal(quoteClassicNexusSkillsBattle(revealed).payments.find(payment => payment.player === fixture.owner)!.ownPayment, 4);
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
void test('sealing the original Advanced plan blocks late Cunning without spending its held Emperor card', () => {
const fixture = createClassicNexusSkillsBattleFixture({ ownerDial: 4, support: 4 });
let game = prepareClassicNexusSkillsBattle(fixture);
const event = viewGame(game, fixture.owner).nexusSardaukar!.offer!.event;
game = applyAction(game, fixture.owner, fixture.planActions[0].action);
assert.match(viewGame(game, fixture.owner).nexusSardaukar!.offer!.blocked!, /committed/);
reject(game, fixture.owner, { type: 'nexusSardaukar', event });
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], 'emperor');
game = applyAction(game, fixture.opponent, fixture.planActions[1].action);
moneyAtCleanup(game, finishClassicNexusSkillsBattle(game, { stop: 'cleanup' }));
});
void test('Basic retains the explicit blocked Sardaukar offer while the original skilled Warmaster plan legally wins without doubled forces or spice support', () => {
const fixture = createClassicNexusSkillsBattleFixture({ advanced: false });
const prepared = prepareClassicNexusSkillsBattle(fixture);
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
assert.match(offer.blocked!, /Advanced advantage/);
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
assert.equal(maxCombatDial(viewGame(prepared, fixture.owner).battle!.ownForces!), 7);
const revealed = revealClassicNexusSkillsBattle(fixture);
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.deepEqual(quote.payments, []);
assert.equal(quote.winner, fixture.owner);
assert.equal(viewGame(revealed, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(quote.casualties, null);
assert.equal(quote.basicWinnerLosses, 6);
const cleanup = finishClassicNexusSkillsBattle(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(revealed, cleanup);
});
void test('completed Cunning expires for the actual second battle in the same turn and cannot reuse its spent source', () => {
const fixture = createClassicNexusSkillsBattleFixture({ secondBattle: true });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const oldEvent = revealed.nexusSardaukarLast!.event;
let game = finishClassicNexusSkillsBattle(revealed);
assert.equal(game.turn, 2); assert.equal(game.phase, 6);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'carthag', target: actor === fixture.owner ? fixture.opponent : fixture.owner });
game = prepareClassicNexusSkillsBattle(fixture, game);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, 1);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(viewGame(game, fixture.owner).battle!.ownForces!), 1);
assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false);
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
custody(game);
});
void test('real END Mentat and next Storm do not reactivate historical temporary roles', () => {
const fixture = createClassicNexusSkillsBattleFixture();
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const oldEvent = revealed.nexusSardaukarLast!.event;
let game = finishClassicNexusSkillsBattle(revealed);
game = advanceClassicNexusSkillsBattle(game, state => state.turn === 3 && state.phase === 1 && !state.phaseOpening && !state.response && !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('minimal legal Easy bot path resolves the real typed Suk rescue before human winner-card cleanup', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', ownerDefense: 'shield' });
const { revealed, game: losses } = lossWindow(fixture);
let game = applyAction(losses, fixture.owner, { type: 'decision', choice: 2 });
assert.equal(game.decision?.kind, 'sukRescue');
const view = viewGame(game, fixture.owner);
view.players.find(seat => seat.id === fixture.owner)!.bot = 'Easy';
const action = botActions(view).find(action => action.type === 'decision');
assert.ok(action && typeof action.choice === 'number');
assert.ok(game.decision?.kind === 'sukRescue');
assert.equal(game.decision.options[action.choice].normal, 3);
assert.equal(game.decision.options[action.choice].elite, 0);
game = applyAction(game, fixture.owner, action);
assert.equal(player(game, fixture.owner).forces[fixture.key], 3);
assert.equal(player(game, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 2);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
moneyAtCleanup(revealed, game);
custody(finishClassicNexusSkillsBattle(game));
});