181 lines
12 KiB
TypeScript
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);
|
|
});
|