Compose classic Nexus and Leader Skills with physical Suk rescue

This commit is contained in:
Quintine committed 2026-10-04 20:41:39 +11:00
1 parent c8482a2d96
commit 3ae5e1f994
18 files changed
+1587 -18

No files matched your search

@@ -0,0 +1,319 @@
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);
assert.equal(player(game, fixture.owner).battleLosses, original.battleLosses + physicalLoss - saved);
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));
});
@@ -0,0 +1,267 @@
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 { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import {
advanceClassicNexusSkillsPayments, advanceClassicNexusSkillsPaymentsStep,
advanceClassicNexusSkillsPaymentsToPhase, classicNexusSkillsPaymentsInventory,
classicNexusSkillsPaymentsPlayer, createClassicNexusSkillsPaymentsFixture,
finishClassicNexusSkillsPaymentsFixture, initializeClassicNexusSkillsPaymentsSetup,
payClassicNexusSkillsPaymentsFixture,
} from './fixture-classic-nexus-skills-payments';
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid actions cannot mutate physical custody or any original accounting');
}
const wallets = (game: Game) => game.players.map(({ id, spice }) => ({ id, spice }));
function sameSkillCustody(before: Game, after: Game): void {
assert.deepEqual(after.leaderSkills, before.leaderSkills, 'Nexus use cannot redraw, detach or transfer an ordinary physical skill');
for (const player of before.players)
assert.deepEqual(viewGame(after, player.id).leaderSkills!.assignments,
viewGame(before, player.id).leaderSkills!.assignments,
'Consumer-visible assignment, controller and face-up custody must remain unchanged');
}
for (const advanced of [false, true]) {
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} all14/${kind} returns exactly three with original quota and wallet accounting`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind });
assert.equal(f.initial.setupStage, 'leaderSkills');
assert.equal(f.initial.leaderSkills!.assignments.length, 0);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, f.afterSetup.players.length);
assert.ok(f.afterSetup.players.every(player => player.traitorChoices.length === 0));
assert.equal(f.beforeFirstMentat.turn, 1);
assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.actor], null);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], kind === 'emperor-revival' ? 'emperor' : 'fremen');
assert.equal(f.game.phase, 4);
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
let paid = payClassicNexusSkillsPaymentsFixture(f);
let actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.game));
assert.equal(actor.revived, kind === 'fremen-revival' ? 3 : before.revived);
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
sameSkillCustody(f.game, paid);
rejected(paid, f.actor, f.paymentAction);
if (kind === 'fremen-revival') {
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
} else {
assert.equal(forceRevivalRemaining(paid, actor), 3);
const quote = forceRevivalQuote(paid, actor, 3);
assert.equal(quote.cost, 2, 'Atreides retains its original two-free plus one-paid invoice');
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
assert.equal(actor.spice, before.spice - quote.cost);
assert.equal(actor.revived, 3); assert.equal(actor.freeForcesRevived, 2);
sameSkillCustody(f.game, paid);
}
classicNexusSkillsPaymentsInventory(paid);
const atMentat = advanceClassicNexusSkillsPaymentsToPhase(paid, 8);
assert.equal(atMentat.turn, 2);
assert.equal(classicNexusSkillsPaymentsPlayer(atMentat, f.actor).revived, 3,
'Movement/Battle/Collection cannot prematurely reset the original Revival ledger');
sameSkillCustody(f.game, atMentat);
const nextTurn = advanceClassicNexusSkillsPayments(atMentat, game => game.turn === 3);
assert.equal(classicNexusSkillsPaymentsPlayer(nextTurn, f.actor).revived, 3,
'The real END Mentat does not reset force-revival accounting early');
const nextRevival = advanceClassicNexusSkillsPaymentsToPhase(nextTurn, 4);
assert.equal(classicNexusSkillsPaymentsPlayer(nextRevival, f.actor).revived, 0);
assert.equal(classicNexusSkillsPaymentsPlayer(nextRevival, f.actor).freeForcesRevived ?? 0, 0);
assert.deepEqual(nextTurn.nexusEmperorSecretHistory, paid.nexusEmperorSecretHistory);
assert.deepEqual(nextTurn.nexusFremenRevivalHistory, paid.nexusFremenRevivalHistory);
sameSkillCustody(f.game, nextTurn);
classicNexusSkillsPaymentsInventory(nextTurn);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen free-three remains early-only and both grants require an exact three`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'fremen-revival' });
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
rejected(late, f.actor, f.paymentAction);
sameSkillCustody(f.game, late);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(classicNexusSkillsPaymentsPlayer(short.game, short.actor).tanks, 2);
classicNexusSkillsPaymentsInventory(short.game);
}
const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' });
let ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
const pools = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
const wallet = pools.spice;
ordinary = applyAction(ordinary, extra.actor, extra.paymentAction);
const after = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
assert.equal(after.reserves, pools.reserves + 3); assert.equal(after.tanks, pools.tanks - 3);
assert.equal(after.spice, wallet, 'Emperor remains additional even after the ordinary quota is spent');
sameSkillCustody(extra.game, ordinary);
classicNexusSkillsPaymentsInventory(ordinary);
});
void test(`${advanced ? 'Advanced' : 'Basic'} normal bank auction Emperor Secret Ally retains the earned bid and delivers one original lot`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-purchase' });
const auction = f.game.auction!;
const lot = auction.cards[auction.index];
assert.equal(f.game.decision!.kind, 'auctionPayment');
assert.equal(f.game.decision!.player, f.actor);
assert.equal(auction.bidder, f.actor); assert.equal(auction.bid, 2);
assert.equal(auction.allyPayment ?? 0, 0);
const buyer = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
assert.ok(buyer.spice >= auction.bid);
assert.ok(!buyer.hand.some(card => card.id === lot.id));
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
const botView = viewGame(f.game, f.actor);
botView.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(botView).find(action => action.type === 'nexusEmperorPurchase');
assert.deepEqual(bot, f.paymentAction);
const paid = applyAction(f.game, f.actor, bot!);
assert.deepEqual(wallets(paid), wallets(f.game));
assert.equal(classicNexusSkillsPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1);
const receipt = paid.nexusEmperorPurchaseHistory![0];
assert.equal(receipt.card, lot.id); assert.equal(receipt.price, 2);
assert.equal(receipt.beforeSpice, buyer.spice); assert.equal(receipt.afterSpice, buyer.spice);
assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
sameSkillCustody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
classicNexusSkillsPaymentsInventory(paid);
const continued = advanceClassicNexusSkillsPaymentsToPhase(paid, 4);
assert.equal(classicNexusSkillsPaymentsPlayer(continued, f.actor).hand.filter(card => card.id === lot.id).length, 1);
assert.deepEqual(continued.nexusEmperorPurchaseHistory, paid.nexusEmperorPurchaseHistory);
sameSkillCustody(f.game, continued); classicNexusSkillsPaymentsInventory(continued);
});
void test(`${advanced ? 'Advanced' : 'Basic'} original Harkonnen Cunning draws and returns physical traitors without changing trained-disc custody`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'harkonnen-cunning' });
const owner = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
const top = f.game.traitorReserve![0];
const reserve = [...f.game.traitorReserve!];
assert.equal(owner.traitors.length, 4);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
const view = viewGame(f.game, f.actor);
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === 'nexusTraitorDraw');
assert.deepEqual(bot, f.paymentAction);
const drawn = applyAction(f.game, f.actor, bot!);
assert.deepEqual(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors, [...owner.traitors, top]);
assert.deepEqual(drawn.traitorReserve, reserve.slice(1));
assert.equal(drawn.nexusCards!.cards!.hands[f.actor], null);
const pending = viewGame(drawn, f.actor).nexusTraitors!.pending!;
assert.equal(pending.count, 1);
assert.equal(pending.choices.filter(choice => choice.drawn).length, 1);
sameSkillCustody(f.game, drawn);
for (const actor of drawn.players.map(player => player.id)) rejected(drawn, actor, { type: 'ready' });
const giveBack: Action = { type: 'nexusTraitorReturn', event: pending.event, cards: [owner.traitors[0]] };
rejected(drawn, f.opponent, giveBack);
rejected(drawn, f.actor, { ...giveBack, event: 'stale' });
rejected(drawn, f.actor, { ...giveBack, cards: ['foreign-identity'] });
const returned = finishClassicNexusSkillsPaymentsFixture(f, drawn, [owner.traitors[0]]);
assert.deepEqual(classicNexusSkillsPaymentsPlayer(returned, f.actor).traitors, [...owner.traitors.slice(1), top]);
assert.equal(returned.traitorReserve!.length, reserve.length);
assert.ok(returned.traitorReserve!.includes(owner.traitors[0]));
assert.equal(returned.nexusTraitorExchanges![0].stage, 'complete');
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
assert.deepEqual(wallets(returned), wallets(f.game));
rejected(returned, f.actor, giveBack); rejected(returned, f.actor, f.paymentAction);
sameSkillCustody(f.game, returned); classicNexusSkillsPaymentsInventory(returned);
});
for (const declaredTraitor of ['call', 'decline'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Cunning preserves an earlier ${declaredTraitor} after its physical matching identity changes custody`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'harkonnen-cunning', declaredTraitor });
const identity = f.declaredIdentity!;
assert.equal(f.game.battle!.traitorCalls[f.actor], declaredTraitor === 'call');
const declaration = structuredClone(f.game.battle!.traitorDeclarations);
const drawn = payClassicNexusSkillsPaymentsFixture(f);
assert.ok(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors.includes(identity));
const pending = viewGame(drawn, f.actor).nexusTraitors!.pending!;
const returnedIdentity = declaredTraitor === 'call' ? identity : pending.choices.find(choice => choice.id !== identity)!.id;
const returned = finishClassicNexusSkillsPaymentsFixture(f, drawn, [returnedIdentity]);
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).traitors.includes(identity), declaredTraitor === 'decline');
assert.equal(returned.battle!.traitorCalls[f.actor], declaredTraitor === 'call');
assert.deepEqual(returned.battle!.traitorDeclarations, declaration);
if (declaredTraitor === 'call') assert.equal(returned.battle!.traitorDeclarations![f.actor].identity, identity);
else rejected(returned, f.actor, { type: 'traitorCall', call: true });
sameSkillCustody(f.game, returned);
const resolved = applyAction(returned, f.opponent, { type: 'traitorCall', call: false });
if (declaredTraitor === 'call') assert.equal(classicNexusSkillsPaymentsPlayer(resolved, f.opponent).tanks, 5);
else assert.equal(resolved.lastBattleContext!.result, 'normal',
'An identity drawn after declining cannot become a retroactive traitor call');
sameSkillCustody(f.game, resolved); classicNexusSkillsPaymentsInventory(resolved);
});
}
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} genuinely preserves Advanced native elite counters and the original one-per-turn cap`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ kind, advanced: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The newly supported original profile must offer a real legal bot action');
const paid = applyAction(f.game, f.actor, bot);
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
const actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
assert.equal(actor.elites!.tanks, before.elites!.tanks - 1);
assert.equal(actor.elites!.revived, 1);
assert.equal(actor.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.game));
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
sameSkillCustody(f.game, paid); classicNexusSkillsPaymentsInventory(paid);
});
}
void test('actual undealt initialized setup retains original actor IDs and first offers without reinitializing or redealing', () => {
const setup = initializeClassicNexusSkillsPaymentsSetup({ advanced: true, kind: 'emperor-revival',
seatIds: ['authenticated-owner', 'authenticated-second', 'authenticated-third'] });
const before = structuredClone(setup);
const accepted = initializeClassicNexusSkillsPaymentsSetup({ initial: setup, kind: 'emperor-revival' });
assert.deepEqual(accepted, before);
const next = advanceClassicNexusSkillsPaymentsStep(accepted);
assert.equal(next.leaderSkills!.assignments.length, 1);
assert.equal(Object.keys(next.leaderSkills!.offers).length, Object.keys(setup.leaderSkills!.offers).length - 1);
assert.deepEqual(next.players.map(player => player.hand), before.players.map(player => player.hand),
'The actual already-dealt original starting hands are not replaced by skill selection');
let selecting = accepted;
while (selecting.setupStage === 'leaderSkills') {
assert.ok(selecting.players.every(player => !player.traitors.length && !player.traitorChoices.length),
'Every original skill choice precedes the original physical traitor deal');
classicNexusSkillsPaymentsInventory(selecting);
selecting = advanceClassicNexusSkillsPaymentsStep(selecting);
}
assert.equal(selecting.leaderSkills!.assignments.length, selecting.players.length);
assert.equal(selecting.setupStage, 'traitors');
const f = createClassicNexusSkillsPaymentsFixture({ initial: setup, kind: 'emperor-revival' });
assert.deepEqual(setup, before, 'The supplied actual CLI/setup snapshot is an immutable source');
assert.deepEqual(f.initial, before);
assert.equal(f.actor, 'authenticated-owner');
assert.deepEqual(f.game.players.map(player => player.id), before.players.map(player => player.id));
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], 'emperor');
const paid = payClassicNexusSkillsPaymentsFixture(f);
sameSkillCustody(f.game, paid); classicNexusSkillsPaymentsInventory(paid);
});
@@ -0,0 +1,402 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type ClassicNexusSkillsBattleStep = { actor: string; action: Action };
export type ClassicNexusSkillsBattleOptions = {
/** Original undealt authenticated CLI setup, or fresh classic lobby. Never redealt. */
initial?: Game;
advanced?: boolean;
seatIds?: string[];
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
ownerDial?: number;
support?: number;
opponentDial?: number;
opponentSupport?: number;
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
traitors?: 'owner' | 'opponent' | 'both';
counter?: boolean;
secondBattle?: boolean;
};
export type ClassicNexusSkillsBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: ClassicNexusSkillsBattleStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: ClassicNexusSkillsBattleStep[];
beforeDraw: Game;
drawAction: ClassicNexusSkillsBattleStep;
afterDraw: Game;
beforeBattle: Game;
battleAction: ClassicNexusSkillsBattleStep;
game: Game;
owner: string;
opponent: string;
observer: string;
partner: string;
trainer: string;
skill: LeaderSkillId;
band: 'normal' | 'skilled';
key: string;
secondKey?: string;
counter?: string;
planActions: ClassicNexusSkillsBattleStep[];
actions: ClassicNexusSkillsBattleStep[];
staging: string[];
};
export function classicNexusSkillsBattlePlayer(game: Game, actor: string): Player {
const player = game.players.find(seat => seat.id === actor);
assert.ok(player, `Missing original classic seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Scoped original shuffle/action entropy; generic non-word requests retain native forwarding. */
function entropy<T>(operation: () => T, rolls: number[] = []): 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) {
for (let index = 0; index < array.length; index++) array[index] = rolls[cursor++] ?? 0xffffffff;
} else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function offerRolls(selected: LeaderSkillId[]): number[] {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(skill => !selected.includes(skill));
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
return rolls;
}
export function nextClassicNexusSkillsBattleStep(game: Game): ClassicNexusSkillsBattleStep | null {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'wormRide')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'sukRescue') {
const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept }))
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function stepClassicNexusSkillsBattle(game: Game, next = nextClassicNexusSkillsBattleStep(game)): Game {
assert.ok(next, 'Original private plan requires a human action');
return entropy(() => applyAction(game, next.actor, next.action));
}
export function advanceClassicNexusSkillsBattle(game: Game, until: (state: Game) => boolean,
actions: ClassicNexusSkillsBattleStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next, 'Human plan boundary before requested state');
actions.push(structuredClone(next));
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original Skills/Nexus continuation did not reach its boundary');
}
/** Reorder undealt singleton Spice Cards only. Discards keep their original history. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const remainder = [...game.spiceDeck];
const front = kinds.map(kind => {
const index = remainder.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0, `Original undealt ${kind} must still exist`);
return remainder.splice(index, 1)[0];
});
game.spiceDeck = [...front, ...remainder];
staging.push(`Controlled conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
}
function reserveBoard(game: Game): void {
for (const seat of game.players) {
seat.reserves += Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0);
seat.elites.forces = {};
}
if (seat.advisors) seat.advisors = {};
}
}
function place(game: Game, actor: string, key: string, count: number): void {
const seat = classicNexusSkillsBattlePlayer(game, actor);
assert.ok(seat.reserves - (seat.elites?.reserves ?? 0) >= count);
seat.reserves -= count;
seat.forces[key] = (seat.forces[key] ?? 0) + count;
}
function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[]): string {
const seat = classicNexusSkillsBattlePlayer(game, actor);
let card = seat.hand.find(card => matches(card) && !used.includes(card.id));
if (!card) {
const from = game.deck.findIndex(card => matches(card) && !used.includes(card.id));
const donor = from < 0 ? game.players.find(seat => seat.hand.some(card => matches(card) && !used.includes(card.id))) : undefined;
assert.ok(from >= 0 || donor, 'Required original card must remain in deck/hand custody');
assert.ok(seat.hand.length < handLimit(seat));
card = from >= 0 ? game.deck.splice(from, 1)[0]
: donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0];
seat.hand.push(card);
staging.push(`Conserved physical ${card.id}: ${donor?.id ?? 'original deck'} → ${actor} hand`);
}
used.push(card.id);
return card.id;
}
function traitor(game: Game, actor: string, identity: string, staging: string[]): void {
const seat = classicNexusSkillsBattlePlayer(game, actor);
if (seat.traitors.includes(identity)) return;
const returned = seat.traitors[0];
const from = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(seat => seat.traitors.includes(identity));
assert.ok(returned && (from >= 0 || donor));
if (from >= 0) game.traitorReserve!.splice(from, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
seat.traitors[0] = identity;
staging.push(`Conserved original traitor ${identity}: ${donor?.id ?? 'reserve'} exchanged for ${actor}'s ${returned}`);
}
/** Genuine original all14/base33/all12 setup → Storm → END Mentat → worm →
* reciprocal alliance → closing Emperor draw → movement → human battle plans.
* Only labeled conserved physical sources are relocated; no wallets, phases,
* turn, skill effects, module owners, earned receipts or outcomes are assigned. */
export function createClassicNexusSkillsBattleFixture(options: ClassicNexusSkillsBattleOptions = {}): ClassicNexusSkillsBattleFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const band = options.band ?? 'skilled';
const actions: ClassicNexusSkillsBattleStep[] = [], staging: string[] = [];
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides', 'fremen'];
assert.ok(ids.length >= 3 && ids.length <= 6 && new Set(ids).size === ids.length);
const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit'];
game = createGame('CLASSICNEXUSSKILLSBATTLE', newPlayer(ids[0], factions[0], factions[0]), advanced, []);
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
}
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, []);
assert.ok(!game.techTokens && !game.strongholdCards && !game.homeworlds);
assert.ok(game.players.length >= 3 && game.players.length <= 6);
const owner = game.players.find(seat => seat.faction === 'emperor')?.id;
const opponent = game.players.find(seat => seat.faction === 'guild')?.id;
const observer = game.players.find(seat => seat.faction === 'atreides')?.id;
assert.ok(owner && opponent && observer);
const partner = game.players.find(seat => ![owner, opponent, observer].includes(seat.id))?.id ?? opponent;
const desiredSkill = options.skill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins']
.filter(skill => skill !== desiredSkill) as LeaderSkillId[];
const selected = game.players.map(seat => seat.id === owner ? desiredSkill : passive.shift()!);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills, 'Never replace existing original skill custody');
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const seat of game.players) if (!seat.ready) game = stepClassicNexusSkillsBattle(game, { actor: seat.id, action: { type: 'ready' } });
game = entropy(() => initializeLeaderSkillsGameForAudit(game), offerRolls(selected));
}
assert.equal(game.status, 'setup', 'Only original fresh undealt setup/lobby is accepted');
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.leaderSkills && game.nexusCards?.cards);
assert.equal(game.leaderSkills.assignments.length, 0);
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 240; count++) {
if (game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const offer: NonNullable<Game['leaderSkills']>['offers'][string] = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const requested: LeaderSkillId = selected[game.players.findIndex(seat => seat.id === actor)];
const legal: LeaderSkillId[] = offer.cards.filter(skill => !view.unavailableSkills?.[skill]);
const skill: LeaderSkillId | undefined = legal.includes(requested) ? requested : legal[0];
assert.ok(skill, 'Existing saved offer has no legal original skill; it cannot be redealt');
if (actor === owner && options.skill) assert.equal(skill, options.skill, 'Requested skill must be in the original saved offer');
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = classicNexusSkillsBattlePlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
const next: ClassicNexusSkillsBattleStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
} else {
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
}
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const assignment = game.leaderSkills!.assignments.find(skill => skill.owner === owner)!;
const skill = assignment.skill, trainer = assignment.leader;
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['land', 'land'], staging);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
staging.push('After actual setup, conserve all board counters through their own reserves before first movement completion');
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 8 && clean(state), actions);
let beforeFirstMentat!: Game, firstMentatStep!: ClassicNexusSkillsBattleStep;
while (game.turn === 1) {
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
const before = structuredClone(game);
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
const afterFirstMentat = structuredClone(game);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advanceClassicNexusSkillsBattle(game, state => state.nexus === true && !state.spiceWindow && clean(state), actions);
const beforeAlliance = structuredClone(game);
const allianceActions: ClassicNexusSkillsBattleStep[] = [
{ actor: observer, action: { type: 'alliance', target: partner } },
{ actor: partner, action: { type: 'alliance', target: observer } },
];
for (const next of allianceActions) { actions.push(next); game = stepClassicNexusSkillsBattle(game, next); }
game = advanceClassicNexusSkillsBattle(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!;
const from = cards.deck.indexOf('emperor'); assert.ok(from >= 0);
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
staging.push('Conserved undealt Emperor Nexus singleton ordered first after genuine alliance; only actual closing draw changes hand custody');
const beforeDraw = structuredClone(game);
const drawAction: ClassicNexusSkillsBattleStep = { actor: owner, action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0,
} };
actions.push(drawAction); game = stepClassicNexusSkillsBattle(game, drawAction);
const afterDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
const key = `arrakeen:${territory('arrakeen').sectors[0]}`;
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
for (const actor of [owner, opponent]) place(game, actor, key, 7);
const secondKey = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined;
if (secondKey) {
assert.ok(!territory('carthag').sectors.includes(game.storm));
for (const actor of [owner, opponent]) place(game, actor, secondKey, 1);
}
staging.push('Before actual turn-two movement completion, conserved seven ordinary counters per combatant at Arrakeen; native elite pools unchanged');
if (secondKey) staging.push('Conserved one further ordinary counter per combatant at Carthag for genuine same-turn expiry battle');
const used: string[] = [];
const card = (actor: string, kind: Card['kind'] | undefined) => kind ? hold(game, actor, card => card.kind === kind, staging, used) : null;
const ownerWeapon = card(owner, options.ownerWeapon ?? (skill === 'warmaster' ? 'worthless' : undefined));
const ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon);
const opponentDefense = card(opponent, options.opponentDefense);
const counter = options.counter ? hold(game, opponent, card => card.effect === 'karama', staging, used) : undefined;
const untrained = (actor: string) => {
const leaders = classicNexusSkillsBattlePlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
const leader = leaders.find(leader => leader.strength === 3) ?? leaders[0]; assert.ok(leader);
return leader.id;
};
const ownerLeader = band === 'skilled' ? trainer : untrained(owner);
const opponentLeader = untrained(opponent);
if (options.traitors === 'owner' || options.traitors === 'both') traitor(game, owner, opponentLeader, staging);
if (options.traitors === 'opponent' || options.traitors === 'both') traitor(game, opponent, ownerLeader, staging);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game);
const actor = game.active!; assert.ok([owner, opponent].includes(actor));
const battleAction: ClassicNexusSkillsBattleStep = { actor, action: { type: 'chooseBattle', territory: 'arrakeen', target: actor === owner ? opponent : owner } };
actions.push(battleAction); game = stepClassicNexusSkillsBattle(game, battleAction);
const planActions: ClassicNexusSkillsBattleStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownerLeader, dial: options.ownerDial ?? 6,
support: advanced ? options.support ?? 3 : 0, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: options.opponentDial ?? 0,
support: advanced ? options.opponentSupport ?? 0 : 0, weapon: opponentWeapon, defense: opponentDefense } },
];
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeAlliance, allianceActions,
beforeDraw, drawAction, afterDraw, beforeBattle, battleAction, game, owner, opponent, observer, partner,
trainer, skill, band, key, secondKey, counter, planActions, actions, staging };
}
export function prepareClassicNexusSkillsBattle(fixture: ClassicNexusSkillsBattleFixture, state = fixture.game): Game {
let game = structuredClone(state);
for (let count = 0; count < 160; count++) {
const next = nextClassicNexusSkillsBattleStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled';
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original skill posture/private plan boundary did not open');
}
export function beginClassicNexusSkillsCunning(fixture: ClassicNexusSkillsBattleFixture, state = prepareClassicNexusSkillsBattle(fixture)): Game {
const offer = viewGame(state, fixture.owner).nexusSardaukar?.offer;
assert.ok(offer); assert.equal(offer.blocked, null);
return stepClassicNexusSkillsBattle(state, { actor: fixture.owner, action: { type: 'nexusSardaukar', event: offer.event } });
}
export function revealClassicNexusSkillsBattle(fixture: ClassicNexusSkillsBattleFixture,
options: { state?: Game; cunning?: boolean } = {}): Game {
let game = prepareClassicNexusSkillsBattle(fixture, options.state ?? fixture.game);
if (options.cunning) game = advanceClassicNexusSkillsBattle(beginClassicNexusSkillsCunning(fixture, game), clean);
for (const plan of fixture.planActions) {
game = stepClassicNexusSkillsBattle(game, plan);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepClassicNexusSkillsBattle(game);
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishClassicNexusSkillsBattle(state: Game,
options: { stop?: 'losses' | 'rescue' | 'cleanup'; callers?: string[] } = {}): Game {
let game = structuredClone(state);
for (let count = 0; count < 240; count++) {
if (options.stop === 'losses' && game.decision?.kind === 'battleLosses') return game;
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game;
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false;
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original Skills/Nexus battle aftermath did not finish');
}
/** Quote the actual native source, including physical temporary roles and skill posture. */
export function quoteClassicNexusSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const seat = classicNexusSkillsBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: seat.faction, ally: seat.ally, spice: seat.spice, hand: seat.hand,
plan: battle.plans[actor], leader: seat.leaders.find(leader => leader.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor)
.map(assignment => ({ skill: assignment.skill, leader: assignment.leader,
faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) };
};
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(actor => ({ id: actor,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : classicNexusSkillsBattlePlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: classicNexusSkillsBattlePlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
@@ -0,0 +1,363 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { traitorDeck } from '../game/traitors';
import { nexusInventory } from './fixture-nexus-cards';
import { nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
export type ClassicNexusSkillsPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'harkonnen-cunning';
export type ClassicNexusSkillsPaymentStep = { actor: string; action: Action };
export type ClassicNexusSkillsPaymentsOptions = {
/** Original authenticated lobby OR actual undealt leader-skills CLI setup. */
initial?: Game;
advanced?: boolean;
seatIds?: string[];
ownerFaction?: FactionId;
kind?: ClassicNexusSkillsPaymentKind;
skill?: LeaderSkillId;
tanks?: number;
eliteTanks?: number;
/** Reach a real revealed battle and commit the original call before Cunning. */
declaredTraitor?: 'call' | 'decline';
};
export type ClassicNexusSkillsPaymentsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: ClassicNexusSkillsPaymentStep;
afterFirstMentat: Game;
beforeClosingDraw: Game;
closingDraw: ClassicNexusSkillsPaymentStep;
afterClosingDraw: Game;
beforePayment: Game;
game: Game;
actor: string;
opponent: string;
kind: ClassicNexusSkillsPaymentKind;
paymentAction: Action;
actions: ClassicNexusSkillsPaymentStep[];
planActions: ClassicNexusSkillsPaymentStep[];
staging: string[];
declaredIdentity?: string;
};
export const classicNexusSkillsPaymentsSnapshot = (game: Game): Game => structuredClone(game);
export function classicNexusSkillsPaymentsPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** The first thirteen rolls control only the original all14 shuffle. Other
* scalar rolls are fixed; generic getRandomValues calls retain native behavior. */
function originalRandom<T>(run: () => T, selected?: LeaderSkillId[]): T {
const rolls: number[] = [];
if (selected) {
assert.equal(new Set(selected).size, selected.length);
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(card => !selected.includes(card));
const desired = selected.flatMap(card => [card, remainder.shift()!]);
desired.push(...remainder);
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
}
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] = rolls[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');
}
}
export function nextClassicNexusSkillsPaymentsNativeStep(game: Game): ClassicNexusSkillsPaymentStep | null {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
assert.ok(actor);
const view = viewGame(game, actor).leaderSkills!;
const skill = view.offer!.cards.find(card => !view.unavailableSkills?.[card]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = classicNexusSkillsPaymentsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader, 'An original offer must have a legal skill/disc choice');
return { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
}
if (game.nexusCards?.phase?.stage === 'drawing' && clean(game)) {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextClassicSkillsStrongholdStep(game);
}
export function advanceClassicNexusSkillsPaymentsStep(state: Game, actions?: ClassicNexusSkillsPaymentStep[]): Game {
const next = nextClassicNexusSkillsPaymentsNativeStep(state);
assert.ok(next, 'The original continuation needs a human battle plan');
actions?.push(structuredClone(next));
return applyAction(state, next.actor, next.action);
}
export function advanceClassicNexusSkillsPayments(state: Game, predicate: (game: Game) => boolean,
actions?: ClassicNexusSkillsPaymentStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
throw Error('Original skill/Nexus continuation did not reach its boundary');
}
/** Checks the boundary BEFORE every action, including auto-skipped Battle. */
export function advanceClassicNexusSkillsPaymentsToPhase(state: Game, phase: number,
actions?: ClassicNexusSkillsPaymentStep[]): Game {
const turn = state.turn;
const game = advanceClassicNexusSkillsPayments(state, current => {
assert.equal(current.turn, turn);
return current.phase >= phase && clean(current);
}, actions);
assert.equal(game.turn, turn);
return game;
}
/** No replacement of a supplied setup's saved first offers or original IDs. */
export function initializeClassicNexusSkillsPaymentsSetup(options: ClassicNexusSkillsPaymentsOptions = {}): Game {
const kind = options.kind ?? 'emperor-revival';
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
let game = options.initial ? structuredClone(options.initial)
: createGame('CLASSICNEXUSSKILLSPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', faction, faction), options.advanced ?? false);
if (!options.initial) {
const forbidden = kind === 'fremen-revival' ? 'fremen' : kind.startsWith('emperor-') ? 'emperor' : undefined;
const count = options.seatIds?.length ?? 3;
assert.ok(count >= 3 && count <= 6);
for (const candidate of ['harkonnen', 'guild', 'beneGesserit', 'atreides', 'fremen', 'emperor'] as const)
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
assert.equal(game.players.length, count, 'The requested absent-faction power needs a compatible classic roster');
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === faction));
if (game.status === 'setup') {
assert.ok(classicNexusLeaderSkillsProfile(game));
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length));
assert.equal(game.leaderSkills!.assignments.length, 0);
classicNexusSkillsPaymentsInventory(game);
return game;
}
assert.equal(game.status, 'lobby');
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.ok(classicNexusLeaderSkillsProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const skill = options.skill ?? 'sandmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz']
.filter(candidate => candidate !== skill) as LeaderSkillId[];
const selected = game.players.map(p => p.faction === faction ? skill : passive.shift()!);
return originalRandom(() => initializeLeaderSkillsGameForAudit(game), selected);
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved original on-board counters into their own reserves; no wallet, phase, turn or module owner assigned.');
}
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const front = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0, 'Only original still-undealt physical Spice Cards may be ordered');
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; not a natural shuffle history.`);
}
function holdTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
if (owner.traitors.includes(identity)) return;
const returned = owner.traitors[0];
const index = game.traitorReserve!.indexOf(identity);
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(returned && (index >= 0 || donor));
if (index >= 0) game.traitorReserve![index] = returned;
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
owner.traitors[0] = identity;
staging.push(`Conserved original traitor ${identity}: exchanged ${returned} with ${donor?.id ?? 'reserve'} custody.`);
}
export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSkillsPaymentsOptions = {}): ClassicNexusSkillsPaymentsFixture {
const initial = initializeClassicNexusSkillsPaymentsSetup(options);
return originalRandom(() => {
const kind = options.kind ?? 'emperor-revival';
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
const actions: ClassicNexusSkillsPaymentStep[] = [], staging: string[] = [], planActions: ClassicNexusSkillsPaymentStep[] = [];
let game = advanceClassicNexusSkillsPayments(initial, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const actor = game.players.find(p => p.faction === faction)!.id;
assert.ok(game.players.length >= 3, 'A real alliance leaves this original seat unallied');
const opponent = game.players.find(p => p.id !== actor)!.id;
reserveBoard(game, staging);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
let beforeFirstMentat: Game | undefined, firstMentatStep: ClassicNexusSkillsPaymentStep | undefined;
while (game.turn === 1) {
const before = structuredClone(game);
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
reserveBoard(game, staging);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(p => p.id !== actor);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action }); game = applyAction(game, from, action);
}
allied = true;
}
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const card: FactionId = kind === 'harkonnen-cunning' ? 'harkonnen' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(card); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Ordered original undealt ${card} Nexus singleton before the real closing draw; no hand grant.`);
const beforeClosingDraw = structuredClone(game);
const closingDraw: ClassicNexusSkillsPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
assert.equal(game.nexusCards!.cards!.hands[actor], card);
const afterClosingDraw = structuredClone(game);
game = advanceClassicNexusSkillsPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
let paymentAction: Action, declaredIdentity: string | undefined;
if (kind === 'emperor-purchase') {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 3, actions);
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
if (game.auction?.active === actor && !game.auction.bid) {
const action: Action = { type: 'bid', amount: 2 };
actions.push({ actor, action }); game = applyAction(game, actor, action);
} else game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor);
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
} else if (kind === 'harkonnen-cunning') {
assert.equal(faction, 'harkonnen');
if (options.declaredTraitor) {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 5, actions);
reserveBoard(game, staging);
for (const id of [actor, opponent]) {
const player = classicNexusSkillsPaymentsPlayer(game, id);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 5);
player.reserves -= 5; player.forces['pasty_mesa:5'] = 5;
}
assert.notEqual(game.storm, 5, 'The actual retained storm must leave Pasty Mesa available');
staging.push('Conserved five ordinary reserve counters per combatant to Pasty Mesa sector five before genuine Movement completion.');
const untrained = (id: string) => classicNexusSkillsPaymentsPlayer(game, id).leaders
.filter(leader => !leader.dead && !game.leaderSkills!.assignments.some(a => a.leader === leader.id));
const ownLeader = untrained(actor).sort((a, b) => b.strength - a.strength)[0];
const targetLeader = untrained(opponent).sort((a, b) => a.strength - b.strength)[0];
assert.ok(ownLeader && targetLeader); declaredIdentity = targetLeader.id;
holdTraitor(game, actor, declaredIdentity, staging);
if (options.declaredTraitor === 'decline') {
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
const replacement = game.traitorReserve![0];
owner.traitors[owner.traitors.indexOf(declaredIdentity)] = replacement;
game.traitorReserve![0] = declaredIdentity;
staging.push(`Conserved ${declaredIdentity}: exchanged back to the actual next traitor draw for ${replacement}.`);
}
game = advanceClassicNexusSkillsPaymentsToPhase(game, 6, actions);
assert.equal(game.phase, 6);
const battleActor = game.active!;
const battleAction: Action = { type: 'chooseBattle', territory: 'pasty_mesa', target: battleActor === actor ? opponent : actor };
actions.push({ actor: battleActor, action: battleAction }); game = applyAction(game, battleActor, battleAction);
game = advanceClassicNexusSkillsPayments(game, state => !nextClassicNexusSkillsPaymentsNativeStep(state), actions);
for (const [id, leader] of [[actor, ownLeader.id], [opponent, targetLeader.id]]) {
const plan: ClassicNexusSkillsPaymentStep = { actor: id, action: { type: 'battlePlan', dial: 0, support: 0, leader, weapon: null, defense: null } };
planActions.push(plan); actions.push(plan); game = applyAction(game, id, plan.action);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.ok(game.battle?.revealed);
const call: Action = { type: 'traitorCall', call: options.declaredTraitor === 'call' };
actions.push({ actor, action: call }); game = applyAction(game, actor, call);
} else game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
const offer = viewGame(game, actor).nexusTraitors!.offer!;
assert.equal(offer.mode, 'cunning'); assert.equal(offer.blocked, null);
paymentAction = { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
} else {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 4, actions);
const player = classicNexusSkillsPaymentsPlayer(game, actor);
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && elite >= 0 && elite <= tanks);
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} reserve counters to Tanks, including ${elite} original elites; no earned-revival receipt staged.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event,
elite: elite ? 1 : 0 };
}
classicNexusSkillsPaymentsInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw,
closingDraw, afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind,
paymentAction, actions, planActions, staging, declaredIdentity };
});
}
/** Stops at the real private Cunning return instead of silently choosing it. */
export function payClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture, state = fixture.game): Game {
return applyAction(state, fixture.actor, fixture.paymentAction);
}
export function finishClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture,
state: Game, returned?: string[]): Game {
let game = state;
if (fixture.kind === 'harkonnen-cunning') {
const pending = viewGame(game, fixture.actor).nexusTraitors!.pending!;
assert.ok(pending);
const cards = returned ?? pending.choices.filter(choice => choice.drawn).map(choice => choice.id);
game = applyAction(game, fixture.actor, { type: 'nexusTraitorReturn', event: pending.event, cards });
}
return advanceClassicNexusSkillsPayments(game, current => clean(current) && !current.pendingRevival && !current.nexusTraitorPending);
}
export function classicNexusSkillsPaymentsInventory(game: Game): void {
nexusInventory(game);
validateLeaderSkills(game.leaderSkills!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
if (game.status !== 'setup')
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(player => player.traitors)].sort(),
traitorDeck(game.players).sort(), 'Every original traitor identity remains in reserve or actual hand custody');
for (const player of game.players) if (player.elites)
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
player.faction === 'emperor' ? 5 : 3, 'Original Advanced elite counters are conserved separately');
}