Files
Dune/tests/paired-ix-nexus-modules-runtime.test.ts

270 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import { ownedTech } from '../game/tech-tokens';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
advancePairedIxNexusModules, choosePairedIxMobileBattle, createPairedIxNexusModulesFixture,
finishPairedIxNexusModulesBattle, finishPairedIxNexusModulesTurn, pairedIxNexusModulesPlayer as player,
preparePairedIxExpiryBattle, revealPairedIxNexusModulesBattle, type PairedIxNexusModulesFixture,
type PairedIxNexusModulesStep,
} from './fixture-paired-ix-nexus-modules';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'An illegal consumer action cannot spend the original singleton, forces or spice');
}
function inventory(game: Game) {
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game, original: Game) {
assert.deepEqual(inventory(game), inventory(original));
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
if (p.elites) {
const old = player(original, p.id).elites!;
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0));
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
const originalTraitors = [...(original.traitorReserve ?? []), ...original.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
assert.deepEqual(traitors, originalTraitors);
assert.equal(new Set(traitors).size, traitors.length);
}
function reward(f: PairedIxNexusModulesFixture, game: Game) {
assert.equal(game.lastBattleContext!.winner, f.ixians);
if (f.game.techTokens) {
const token = ownedTech(f.game.techTokens, f.opponent);
assert.equal(token.length, 1);
assert.equal(game.techTokens![token[0]].owner, f.ixians);
assert.equal(game.techTokens![token[0]].spice, f.game.techTokens![token[0]].spice);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
}
}
function money(revealed: Game, settled: Game) {
const quote = quoteStrongholdFactionsBattle(revealed);
const collection = settled.phase === 7 && !settled.phaseOpening ? quoteSpiceCollection(settled).receipts : [];
for (const p of revealed.players) {
const payment = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const income = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0);
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
const city = collection.filter(r => r.player === p.id).reduce((sum, r) => sum + r.collected + r.strongholds, 0);
assert.equal(player(settled, p.id).spice, p.spice - payment + income + bounty + city, p.faction);
}
return quote;
}
function useSuboids(f: PairedIxNexusModulesFixture): Game {
const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!;
assert.equal(offer.blocked, null);
reject(f.game, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
const game = applyAction(f.game, f.ixians, { type: 'nexusSuboids', event: offer.event });
assert.equal(game.nexusCards!.cards!.hands[f.ixians], null);
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length, 1);
assert.equal(player(game, f.ixians).spice, player(f.game, f.ixians).spice);
assert.deepEqual(player(game, f.ixians).forces, player(f.game, f.ixians).forces);
assert.deepEqual(player(game, f.ixians).elites, player(f.game, f.ixians).elites);
reject(game, f.ixians, { type: 'nexusSuboids', event: offer.event });
return game;
}
for (const mode of [
{ advanced: false, tech: true, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: true },
{ advanced: true, tech: true, strongholds: true },
]) void test(`paired E1 ${JSON.stringify(mode)}: original Nx Cunning, exact cyborg losses, equal substitution and held-site support`, () => {
const f = createPairedIxNexusModulesFixture(mode);
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians');
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu');
assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]);
assert.equal(player(f.afterNexusDraw, f.ixians).ally, null);
if (mode.strongholds) {
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.ixians);
assert.equal(f.afterFirstMentat.strongholdCards!.owners[MOBILE_STRONGHOLD], f.ixians);
}
const game = useSuboids(f);
const revealed = revealPairedIxNexusModulesBattle(game, f.planActions);
const quote = quoteStrongholdFactionsBattle(revealed);
assert.equal(quote.winner, f.ixians);
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
const payment = quote.payments.find(r => r.player === f.ixians)!;
if (mode.advanced) {
assert.equal(payment.bankSupport, mode.strongholds ? 1 : 0);
assert.equal(payment.ownPayment, mode.strongholds ? 0 : 1);
} else {
assert.deepEqual(quote.payments, [], 'Basic battles create no spice-support payment receipts');
}
let losses = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution');
if (losses.decision?.kind === 'battleLosses') {
const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3);
assert.ok(choice >= 0, 'The actual committed dial can lose exactly the three original cyborgs');
losses = applyAction(losses, f.ixians, { type: 'decision', choice });
}
const pending = advancePairedIxNexusModules(losses, g => g.decision?.kind === 'ixSubstitution');
assert.equal(player(pending, f.ixians).forces[f.location], 3);
assert.equal(player(pending, f.ixians).elites!.forces[f.location] ?? 0, 0);
assert.equal(player(pending, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
assert.equal(player(pending, f.ixians).elites!.tanks, 3);
assert.equal(player(pending, f.ixians).spice, player(revealed, f.ixians).spice - (mode.advanced ? payment.ownPayment : 0));
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Native substitution precedes the actual loser Tech award');
reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 4 } });
const exchanged = applyAction(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 3 } });
const settled = finishPairedIxNexusModulesBattle(exchanged);
assert.equal(player(settled, f.ixians).forces[f.location], 3);
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
assert.equal(player(settled, f.ixians).elites!.tanks, 0);
assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup);
// A second real battle in the original turn uses the spent card's effect.
const mobile = choosePairedIxMobileBattle(settled, f.ixians, f.opponent);
assert.equal(viewGame(mobile, f.ixians).nexusSuboids!.active, true);
if (mode.strongholds) assert.equal(viewGame(mobile, f.ixians).battle!.strongholdEffects[f.ixians], 'arrakeen');
const leader = player(mobile, f.ixians).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(mobile, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
const plans: PairedIxNexusModulesStep[] = [
{ actor: f.ixians, action: { type: 'battlePlan', dial: 3, support: mode.advanced ? 1 : 0, leader: leader.id, weapon: null, defense: null } },
{ actor: f.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
];
const second = revealPairedIxNexusModulesBattle(mobile, plans), secondQuote = quoteStrongholdFactionsBattle(second);
assert.equal(secondQuote.casualties!.forces.normalFixedHalf, false);
assert.equal(secondQuote.casualties!.forces.normalFreeSupport, true);
if (mode.advanced) {
const own = secondQuote.payments.find(r => r.player === f.ixians)!;
assert.equal(own.bankSupport, mode.strongholds ? 1 : 0);
assert.equal(own.ownPayment, mode.strongholds ? 0 : 1);
} else assert.deepEqual(secondQuote.payments, []);
let secondLosses = advancePairedIxNexusModules(second, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution');
if (secondLosses.decision?.kind === 'battleLosses') {
const choice = secondLosses.decision.options.findIndex(o => o.normal === 1 && o.elite === 1);
assert.ok(choice >= 0);
secondLosses = applyAction(secondLosses, f.ixians, { type: 'decision', choice });
}
const secondSettled = finishPairedIxNexusModulesBattle(secondLosses);
money(second, secondSettled); custody(secondSettled, f.afterSetup);
const nextTurn = finishPairedIxNexusModulesTurn(secondSettled);
assert.equal(viewGame(nextTurn, f.ixians).nexusSuboids!.active, false);
const expired = preparePairedIxExpiryBattle(nextTurn, f.ixians, f.tleilaxu);
const expiredLeader = player(expired, f.ixians).leaders.find(l => !l.dead)!;
reject(expired, f.ixians, { type: 'battlePlan', dial: 3, support: 0, leader: expiredLeader.id, weapon: null, defense: null });
const sealed = applyAction(expired, f.ixians, { type: 'battlePlan', dial: 1.5, support: 0, leader: expiredLeader.id, weapon: null, defense: null });
assert.equal(sealed.battle!.plans[f.ixians].dial, 1.5);
custody(sealed, f.afterSetup);
});
void test('native paired Cunning requires an own unsealed plan and grants full unsupported Suboid casualties, not force manufacture', () => {
const f = createPairedIxNexusModulesFixture({ advanced: true, strongholds: false });
const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!;
const sealed = applyAction(f.game, f.ixians, f.planActions[0].action);
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
const plans = structuredClone(f.planActions);
plans[0].action.dial = 3; plans[0].action.support = 0;
const empowered = revealPairedIxNexusModulesBattle(useSuboids(f), plans);
const ordinary = revealPairedIxNexusModulesBattle(f.game, plans);
const full = quoteStrongholdFactionsBattle(empowered), half = quoteStrongholdFactionsBattle(ordinary);
assert.ok(full.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
assert.ok(!half.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
assert.equal(full.payments.find(r => r.player === f.ixians)!.ownPayment, 0);
assert.deepEqual(player(empowered, f.ixians).forces, player(ordinary, f.ixians).forces);
assert.deepEqual(player(empowered, f.ixians).elites, player(ordinary, f.ixians).elites);
const losses = advancePairedIxNexusModules(empowered, g => g.decision?.kind === 'battleLosses');
assert.equal(losses.decision!.kind, 'battleLosses');
if (losses.decision?.kind !== 'battleLosses') throw Error('Missing native Suboid casualty decision');
const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0);
assert.ok(choice >= 0);
const paid = applyAction(losses, f.ixians, { type: 'decision', choice });
const settled = finishPairedIxNexusModulesBattle(paid);
assert.equal(player(settled, f.ixians).forces[f.location], 3);
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
assert.equal(player(settled, f.ixians).tanks, player(empowered, f.ixians).tanks + 3);
assert.equal(player(settled, f.ixians).elites!.tanks, player(empowered, f.ixians).elites!.tanks);
money(empowered, settled); reward(f, settled); custody(settled, f.afterSetup);
});
void test('paired native Face Dance preserves original winner rewards, cards and Tech, then claims Stronghold at actual END Mentat; partial-stock Cunning draws before retiring originals', () => {
const f = createPairedIxNexusModulesFixture({ program: 'faceDance', advanced: true, tech: true, strongholds: true });
const revealed = revealPairedIxNexusModulesBattle(f.game, f.planActions);
const pending = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'faceDance');
const quote = money(revealed, pending);
assert.deepEqual(quote.strongholdIncome, [{ player: f.ixians, amount: 1 }]);
assert.equal(quote.bounty!.player, f.ixians);
reward(f, pending);
const winner = player(pending, f.ixians), survivorCount = winner.forces[f.location];
const elite = winner.elites!.forces[f.location] ?? 0;
for (const id of [f.planActions[0].action.weapon, f.planActions[0].action.defense]) assert.ok(winner.hand.some(c => c.id === id));
const dancerBefore = structuredClone(player(pending, f.tleilaxu).faceDancers!);
assert.ok(dancerBefore.every(c => !c.revealed));
reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: survivorCount + 1 }, sector: territory(f.kind).sectors[0] });
const after = applyAction(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { [f.replacementSource]: 2, reserves: survivorCount - 2 }, sector: territory(f.kind).sectors[0] });
const returned = player(after, f.ixians), replacement = player(after, f.tleilaxu);
assert.equal(returned.forces[f.location] ?? 0, 0);
assert.equal(returned.elites!.forces[f.location] ?? 0, 0);
assert.equal(returned.reserves, winner.reserves + survivorCount);
assert.equal(returned.elites!.reserves, winner.elites!.reserves + elite);
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.elites!.tanks, winner.elites!.tanks);
assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
assert.deepEqual(after.techTokens, pending.techTokens);
assert.equal(replacement.forces[f.location], survivorCount);
assert.equal(replacement.forces[f.replacementSource], 1);
assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - survivorCount + 2);
assert.equal(strongholdControllers(after.players, true)[f.kind], f.tleilaxu);
assert.equal(strongholdBenefit(after.strongholdCards, f.ixians, f.kind), f.kind);
assert.equal(strongholdBenefit(after.strongholdCards, f.tleilaxu, f.kind), null);
assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
const revealedStock = replacement.faceDancers!.filter(c => c.revealed);
assert.equal(revealedStock.length, 1, 'Partial stock comes only from the actual successful Face Dance');
const untouched = replacement.faceDancers!.filter(c => !c.revealed);
assert.deepEqual(untouched, dancerBefore.filter(c => c.leader !== revealedStock[0].leader));
const beforeDraw = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn;
const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!;
const action = nexusFaceDancersAction(view, offer.event); assert.ok(action);
reject(after, f.tleilaxu, { ...action, leaders: [revealedStock[0].leader] });
const replaced = applyAction(after, f.tleilaxu, action);
const finalStock = player(replaced, f.tleilaxu).faceDancers!;
assert.deepEqual(finalStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(finalStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: beforeDraw[0], revealed: false }]);
assert.ok(!revealedStock.some(c => c.leader === beforeDraw[0]), 'Set-aside originals cannot be their own immediate draw');
assert.deepEqual([...replaced.traitorReserve!].sort(), [...beforeDraw.slice(1), revealedStock[0].leader].sort());
assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance);
assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null);
assert.equal(replaced.nexusCards!.cards!.discard.filter(face => face === 'tleilaxu').length, 1);
assert.deepEqual(replaced.techTokens, after.techTokens);
reject(replaced, f.tleilaxu, action);
custody(replaced, f.afterSetup);
const mentat = advancePairedIxNexusModules(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
const ordinary = applyAction(mentat, f.tleilaxu, { type: 'replaceFaceDancer', leader: untouched[0].leader });
const settled = advancePairedIxNexusModules(ordinary, g => !g.response && !g.decision);
assert.equal(player(settled, f.tleilaxu).faceDancerReplacedTurn, settled.turn);
reject(settled, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(settled, f.tleilaxu).faceDancers![0].leader });
const claimed = finishPairedIxNexusModulesTurn(settled);
assert.equal(claimed.strongholdCards!.owners[f.kind], f.tleilaxu);
assert.deepEqual(claimed.techTokens, after.techTokens);
custody(claimed, f.afterSetup);
});
void test('representative native bot Face Dance consumes only actual paired reserve/board sources', () => {
const f = createPairedIxNexusModulesFixture({ program: 'faceDance' });
const pending = advancePairedIxNexusModules(revealPairedIxNexusModulesBattle(f.game, f.planActions), g => g.decision?.kind === 'faceDance');
const view = viewGame(pending, f.tleilaxu);
view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy';
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action);
assert.equal(action.reveal, true);
const after = applyAction(pending, f.tleilaxu, action);
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]);
assert.deepEqual(after.techTokens, pending.techTokens);
custody(after, f.afterSetup);
});