Compose classic Nexus and Leader Skills with physical Suk rescue
This commit is contained in:
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');
|
||||
}
|
||||
Reference in new issue
Block a user