Files
Dune/tests/nexus-module-battles-runtime.test.ts

181 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { nexusInventory } from './fixture-nexus-cards';
import {
createNexusModuleBattleFixture, revealNexusModuleBattle, quoteNexusModuleBattle,
advanceNexusModuleBattle, nexusModuleBattlePlayer,
} from './fixture-nexus-module-battles';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
for (const modules of [
{ label: 'Basic Tech-only', advanced: false, tech: true, strongholds: false },
{ label: 'Advanced Tech + actual Arrakeen card', advanced: true, tech: true, strongholds: true },
]) void test(`${modules.label}: physical Moritani retention precedes winner Tech, then CHOAM inspects only the unused card`, () => {
const fixture = createNexusModuleBattleFixture({ ...modules, family: 'retention' });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.destroyedArmies, [fixture.opponent]);
const payment = quote.payments.find(p => p.player === fixture.owner);
if (modules.advanced) {
assert.ok(payment);
assert.equal(payment.cost, 4);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.allyPayment, 0);
} else assert.deepEqual(quote.payments, [], 'Basic force commitments consume no spice support.');
assert.equal(quote.retention?.source, 'nexus');
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.ok(token);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const guildTanks = nexusModuleBattlePlayer(revealed, fixture.opponent).tanks;
const emperorTanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - (payment?.ownPayment ?? 0));
assert.equal(nexusModuleBattlePlayer(game, fixture.opponent).tanks, guildTanks + 6);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, emperorTanks + 6);
assert.equal(game.techTokens![token].owner, fixture.opponent);
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'pending');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.stage, 'offer');
assert.equal(viewGame(game, fixture.opponent).nexusMoritaniRetention?.canKeep, true);
const event = game.lastBattleContext!.event;
const before = reload(game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, keep: fixture.played.id }));
assert.throws(() => applyAction(game, fixture.opponent, { type: 'decision', event, keep: fixture.unused.id }));
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, inspect: true }));
assert.deepEqual(game, before);
const keep = { type: 'decision', event, keep: fixture.played.id };
game = applyAction(game, fixture.opponent, keep);
// Single original token transfer occurs inside the retention continuation.
assert.equal(game.techTokens![token].owner, fixture.owner);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.kept, fixture.played.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.equal(game.discard.some(card => card.id === fixture.played.id), false);
assert.throws(() => applyAction(game, fixture.opponent, keep));
const inspect = { type: 'decision', event, inspect: true };
assert.throws(() => applyAction(game, fixture.owner, { ...inspect, card: fixture.played.id }));
game = applyAction(game, fixture.owner, inspect);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.unused.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.owner).hand.some(card => card.id === fixture.defense.id));
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'complete');
assert.equal(game.phase, 7);
assert.throws(() => applyAction(game, fixture.owner, inspect));
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Strongholds-only all-pass keeps Nexus cards, discards the original losing weapon once and preserves actual Arrakeen support', () => {
const fixture = createNexusModuleBattleFixture({ tech: false, strongholds: true });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.discard.filter(card => card.id === fixture.played.id).length, 1);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.opponent).hand.map(card => card.id), [fixture.unused.id]);
const collection = quoteSpiceCollection(game).receipts.find(p => p.player === fixture.owner)!;
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], 'choam');
assert.equal(game.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice,
balance - quote.payments.find(p => p.player === fixture.owner)!.ownPayment +
collection.strongholds + collection.collected);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 1);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(game.phase, 7);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Tech-only Emperor passing Cunning retains its Nexus and pays ordinary support before losing ordinary counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor', strongholds: false });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.ownPayment, 6);
assert.equal(payment.bankSupport, 0);
assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const elites = structuredClone(nexusModuleBattlePlayer(revealed, fixture.owner).elites);
const settled = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'nexusChoamInspection');
const collection = quoteSpiceCollection(settled).receipts.find(p => p.player === fixture.owner)!;
const done = advanceNexusModuleBattle(settled, state => state.phase === 7);
assert.equal(done.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(done.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).forces[fixture.location], 1);
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).spice,
balance - 6 + collection.strongholds + collection.collected);
assert.deepEqual(nexusModuleBattlePlayer(done, fixture.owner).elites, elites);
assert.equal(done.discard.filter(card => card.id === fixture.played.id).length, 1);
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.equal(done.techTokens![token].owner, fixture.owner);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
nexusInventory(done);
});
void test('Emperor Cunning counts ordinary forces temporarily, pays actual-holder Arrakeen support and loses no starred counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor' });
const originalElites = structuredClone(nexusModuleBattlePlayer(fixture.game, fixture.owner).elites);
const revealed = revealNexusModuleBattle(fixture, fixture.game, true);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.casualties!.forces, viewGame(revealed, fixture.owner).battle!.ownForces);
assert.equal(quote.casualties!.forces.temporaryElite, 5);
assert.equal(quote.casualties!.forces.normal, 7);
assert.equal(quote.casualties!.forces.elite, 0);
assert.deepEqual(quote.casualties!.options[0], { normal: 3, elite: 0, paidNormal: 3, paidElite: 0 });
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.cost, 1);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 1);
assert.equal(payment.allyPayment, 0);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const tanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, tanks + 3);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 4);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - 1);
assert.equal(game.nexusSardaukarHistory?.length, 1);
assert.equal(game.nexusSardaukarHistory![0].casualties?.outcome, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(viewGame(game, fixture.owner).nexusChoamInspection?.canInspect, false);
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.phase, 7);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: game.nexusSardaukarHistory![0].receipt.event }));
nexusInventory(game);
});
void test('minimal legal bot continuation retains the played card, completes single-owner Tech and inspects the unused card', () => {
const fixture = createNexusModuleBattleFixture();
let game = advanceNexusModuleBattle(revealNexusModuleBattle(fixture), state => state.decision?.kind === 'moritaniRetention');
for (const actor of [fixture.opponent, fixture.owner]) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
if (actor === fixture.opponent) assert.equal(action.keep, fixture.played.id);
else assert.equal(action.inspect, true);
game = applyAction(game, actor, action);
}
assert.equal(game.techTokens![ownedTech(fixture.game.techTokens, fixture.opponent)[0]].owner, fixture.owner);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.phase, 7);
nexusInventory(game);
});