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)); });