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

319 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createTechTokens } from '../game/tech-tokens';
import { nexusInventory } from './fixture-nexus-cards';
import { holdNexusRicheseCard } from './fixture-nexus-richese';
import {
advanceNexusModulePaymentsToPhase, createNexusModulePaymentsFixture,
nextNexusModulePaymentsNativeStep, payNexusModulePaymentsFixture,
type NexusModulePaymentsFixture,
} from './fixture-nexus-module-payments';
function wallets(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
function rejected(game: Game, actor: string, action: Parameters<typeof applyAction>[2]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected declarations spend neither physical card, counter nor spice.');
}
/** Empty Battle can auto-skip into Collection. Its independently quoted native
* stronghold income is not technology and cannot be mistaken for a second pile. */
function collectOnce(fixture: NexusModulePaymentsFixture, paid: Game, amount: number): Game {
const expected = wallets(paid);
let ended = advanceNexusModulePaymentsToPhase(paid, paid.phase + 1);
if (ended.phase === 7) {
for (const receipt of quoteSpiceCollection(paid).receipts) expected[receipt.player] = receipt.balance;
}
if (fixture.tokenOwner) expected[fixture.tokenOwner] += amount;
assert.deepEqual(wallets(ended), expected);
assert.equal(ended.techTokens![fixture.token].spice, 0);
const collection = advanceNexusModulePaymentsToPhase(ended, 7);
const beforeMentat = wallets(collection);
ended = advanceNexusModulePaymentsToPhase(collection, 8);
assert.deepEqual(wallets(ended), beforeMentat, 'Mentat must not pay a collected Tech pile again.');
assert.equal(ended.techTokens![fixture.token].spice, 0);
const next = nextNexusModulePaymentsNativeStep(ended);
assert.ok(next);
const continued = applyAction(ended, next.actor, next.action);
assert.deepEqual(wallets(continued), beforeMentat);
nexusInventory(continued);
return continued;
}
// E1 printed p5: real non-Guild off-planet shipment, once for each token controlled,
// held on Heighliners until phase end. Borrowed tariff is not faction identity.
// Publisher-authored source: http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf
for (const kind of ['guild-shipment', 'richese-shipment'] as const) {
for (const advanced of [false, true]) {
void test(`${kind} in ${advanced ? 'Advanced with claimed Strongholds' : 'Basic'} pays the original invoice, arrives physically and collects Heighliners only at phase end`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced, strongholds: advanced });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(f.cost, kind === 'guild-shipment' ? 3 : 1);
const paid = payNexusModulePaymentsFixture(f);
const after = paid.players.find(p => p.id === f.actor)!;
assert.equal(after.faction, 'atreides');
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.forces[f.destination!], 5);
assert.equal(after.shipped, true);
assert.equal(after.moved, before.moved);
const expected = wallets(f.beforePayment);
expected[f.actor] -= f.cost;
assert.deepEqual(wallets(paid), expected, 'The earned token pile cannot fund this invoice or a later shipment yet.');
assert.equal(paid.techTokens!.heighliners.spice, f.tokenCount);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'guild-shipment' ? 'guild' : 'richese')).length, 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
void test('a stopped Richese borrowed shipment spends its singleton but no invoice, force or Heighliners income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', advanced: true, nativeGuild: true });
const guild = f.game.players.find(p => p.faction === 'guild')!;
const karama = holdNexusRicheseCard(f.game, guild.id, 'karama');
const before = wallets(f.game);
const reserves = f.game.players.find(p => p.id === f.actor)!.reserves;
let game = applyAction(f.game, f.actor, f.paymentAction);
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.pendingShipment!.amount, 5);
assert.equal(game.pendingShipment!.cost, 1);
assert.deepEqual(wallets(game), before, 'Interception precedes actual payment and arrival.');
assert.equal(game.techTokens!.heighliners.spice, 0);
game = applyAction(game, guild.id, { type: 'card', mode: 'special', card: karama.id });
for (let n = 0; (game.response || game.decision) && n < 100; n++) {
const next = nextNexusModulePaymentsNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision);
assert.deepEqual(wallets(game), before);
assert.equal(game.players.find(p => p.id === f.actor)!.reserves, reserves);
assert.equal(game.players.find(p => p.id === f.actor)!.forces[f.destination!], undefined);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
assert.ok(game.discard.some(c => c.id === karama.id));
rejected(game, f.actor, f.paymentAction);
nexusInventory(game);
collectOnce(f, game, 0);
});
void test('native Guild using Richese pricing stays native Guild and cannot activate Heighliners alone', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', ownerFaction: 'guild' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'guild');
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 1);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0);
collectOnce(f, paid, 0);
});
void test('Fremen paid borrowed Guild tariff does not relocate southern reserves or change Heighliners source eligibility', () => {
const f = createNexusModulePaymentsFixture({ kind: 'guild-shipment', ownerFaction: 'fremen' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'fremen');
assert.equal(f.cost, 3);
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 3);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0, 'A tariff alone does not make the native on-planet reserve source off-planet.');
collectOnce(f, paid, 0);
});
// Emperor returns an additional free group directly; Fremen spends its card
// before a separate ordinary return that uses the fixed free quota of three.
// Both actual free groups accrue Axlotl through their original return source,
// without granting native faction identity.
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
for (const strongholds of [false, true]) {
void test(`${kind} consumes the physical card and exact original ledgers${strongholds ? ' without a Stronghold revival bonus' : ''}, then collects Axlotl once`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: strongholds, strongholds });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
let paid = payNexusModulePaymentsFixture(f);
let actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, before.faction);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment), 'No grant, Tech pile or Stronghold card manufactures spendable spice during Revival.');
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.techTokens!.axlotl.spice, f.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
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 keeps its ordinary two-free/one-paid invoice after the additional grant.');
const wallet = actor.spice;
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
paid = advanceNexusModulePaymentsToPhase(paid, 4);
actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
assert.equal(actor.spice, wallet - quote.cost);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 2);
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount, 'Another genuine free batch cannot accrue the same token twice.');
}
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap through bot activation and native selection`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The actual bot must participate in the newly admitted module option.');
let paid = applyAction(f.game, f.actor, bot);
if (f.revivalAction) {
rejected(paid, f.actor, { type: 'revive', amount: 3, elite: 2 });
paid = applyAction(paid, f.actor, f.revivalAction);
}
for (let n = 0; (paid.response || paid.decision || paid.pendingRevival) && n < 200; n++) {
const next = nextNexusModulePaymentsNativeStep(paid);
assert.ok(next);
paid = applyAction(paid, next.actor, next.action);
}
const actor = paid.players.find(p => p.id === f.actor)!;
const before = f.beforePayment.players.find(p => p.id === 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.elites!.reserves + actor.elites!.tanks
+ Object.values(actor.elites!.forces).reduce((sum, count) => sum + count, 0),
before.elites!.reserves + before.elites!.tanks
+ Object.values(before.elites!.forces).reduce((sum, count) => sum + count, 0));
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
nexusInventory(paid);
});
}
void test('Fremen prior free returns count toward the quota, exhausted improvement is unavailable, and real Truthtrance blocks both grants', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival' });
const late = advanceNexusModulePaymentsToPhase(applyAction(f.game, f.actor, { type: 'revive', amount: 1 }), 4);
const granted = applyAction(late, f.actor, f.paymentAction);
assert.equal(granted.players.find(p => p.id === f.actor)!.tanks, late.players.find(p => p.id === f.actor)!.tanks);
const returned = advanceNexusModulePaymentsToPhase(applyAction(granted, f.actor, { type: 'revive', amount: 2 }), 4);
assert.equal(returned.players.find(p => p.id === f.actor)!.freeForcesRevived, 3);
assert.equal(returned.players.find(p => p.id === f.actor)!.revived, 3);
for (const kind of ['fremen-revival', 'emperor-revival'] as const) {
const short = createNexusModulePaymentsFixture({ kind, tanks: 2 });
if (kind === 'emperor-revival') rejected(short.game, short.actor, short.paymentAction);
else {
const enabled = applyAction(short.game, short.actor, short.paymentAction);
rejected(enabled, short.actor, { type: 'revive', amount: 3 });
const partial = advanceNexusModulePaymentsToPhase(applyAction(enabled, short.actor, { type: 'revive', amount: 2 }), 4);
assert.equal(partial.players.find(p => p.id === short.actor)!.tanks, 0);
assert.equal(partial.players.find(p => p.id === short.actor)!.freeForcesRevived, 2);
}
const quiet = createNexusModulePaymentsFixture({ kind });
const questioner = quiet.game.players.find(p => p.id !== quiet.actor)!;
const card = holdNexusRicheseCard(quiet.game, questioner.id, 'truthtrance');
const pending = applyAction(quiet.game, questioner.id, { type: 'card', card: card.id });
assert.ok(pending.truthtrance);
rejected(pending, quiet.actor, quiet.paymentAction);
}
});
void test('Strongholds alone grants no revival bonus or token income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true, tech: false });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment));
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
});
void test('new module admission rejects too few Tech seats, Basic Strongholds, unrelated modules and already-owned components', () => {
for (const boundary of ['two-tech-seats', 'basic-strongholds', 'skills', 'owned-tech', 'owned-stronghold'] as const) {
let lobby = createGame('NEXUSMODULEBOUNDARY', newPlayer('a', 'Atreides', 'atreides'), boundary !== 'basic-strongholds');
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
if (boundary !== 'two-tech-seats') joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
if (boundary === 'owned-stronghold') lobby.strongholdCards = createStrongholdCards();
else if (boundary !== 'basic-strongholds') lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
if (boundary === 'basic-strongholds') lobby.strongholdCards = createStrongholdCards();
if (boundary === 'skills') lobby.leaderSkills = createLeaderSkills(() => 0.5);
if (boundary === 'owned-tech') lobby.techTokens!.axlotl.owner = 'a';
if (boundary === 'owned-stronghold') lobby.strongholdCards!.owners.arrakeen = 'a';
const before = structuredClone(lobby);
assert.throws(() => initializeNexusGameForAudit(lobby));
assert.deepEqual(lobby, before);
}
// Selected native-expansion cards/factions are not another path to admission.
let lobby = createGame('NEXUSMODULEDECK', newPlayer('a', 'Atreides', 'atreides'), false, ['ix']);
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
lobby.techTokens = createTechTokens();
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
assert.throws(() => initializeNexusGameForAudit(lobby));
});
for (const strongholds of [false, true]) {
void test(`original Emperor bank-auction purchase${strongholds ? ' with genuinely claimed Advanced Strongholds' : ' with Tech'} keeps the winning wallet, delivers the lot once and activates no technology`, () => {
const f = createNexusModulePaymentsFixture({ kind: 'emperor-purchase',
advanced: strongholds, strongholds });
const before = f.beforePayment;
const auction = before.auction!;
const lot = auction.cards[auction.index];
const buyer = before.players.find(p => p.id === f.actor)!;
assert.equal(auction.bid, 2);
assert.ok(buyer.spice >= auction.bid, 'The real buyer must possess the original invoice amount.');
assert.equal(buyer.hand.some(card => card.id === lot.id), false);
const paid = applyAction(f.game, f.actor, f.paymentAction);
const after = paid.players.find(p => p.id === f.actor)!;
assert.deepEqual(wallets(paid), wallets(before), 'Emperor Secret Ally retains the actual personal winning payment.');
assert.equal(after.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(after.faction, buyer.faction);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(paid.nexusEmperorPurchaseHistory!.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.deepEqual(paid.techTokens, before.techTokens,
'A bank purchase is neither a shipment, a free revival nor CHOAM Charity.');
assert.deepEqual(paid.strongholdCards, before.strongholdCards);
assert.ok(!paid.decision || paid.decision.kind !== 'auctionPayment',
'The original winner payment cannot remain open after card delivery.');
if (paid.auction) assert.equal(paid.auction.index, auction.index + 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
const continued = advanceNexusModulePaymentsToPhase(paid, 4);
assert.equal(continued.players.find(p => p.id === f.actor)!.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(continued.nexusEmperorPurchaseHistory!.length, 1);
assert.deepEqual(continued.techTokens, before.techTokens,
'Completing the real Bidding round cannot mint a delayed technology pile for that purchase.');
nexusInventory(continued);
});
}