Files
Dune/tests/nexus-core-tech-runtime.test.ts
T

152 lines
9.5 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, type Action, type Game } from '../game/engine';
import { TECH_TOKENS, ownedTech } from '../game/tech-tokens';
import { forceRevivalQuote, normalRevivalAllowance } from '../game/revival';
import { reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
import { createNexusCoreTechHarass, createNexusCoreTechRevival,
createNexusCoreTechShipment, finishNexusCoreTechPhase } from './fixture-nexus-core-tech';
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected consumer action cannot move cards, counters, wallets or tokens');
}
const quietPhaseFour = (game: Game) => advanceClassicIndependentNexus(game,
state => state.phase === 4 && classicDiscoveryNexusClean(state));
function firstStormCustody(game: Game) {
assert.ok(game.techTokens);
for (const token of TECH_TOKENS) {
const owner = game.techTokens[token.id].owner;
assert.ok(owner, `The original first Storm must have assigned ${token.name}`);
assert.equal(game.techTokens[token.id].spice, 0, 'No token income exists before its phase');
}
}
void test('Basic independent36 Tech keeps first-Storm custody and pays the funded delivery Heighliners once', () => {
const f = createNexusCoreTechShipment();
firstStormCustody(f.game);
assert.equal(f.game.turn, 2);
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
const owner = f.token.owner, pending = f.token.count;
const before = reactionPlayer(f.beforeShipment, 'atreides');
const game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalUse',
event: f.game.pendingGuildBetrayal!.invoice.event });
// The delivered five counters, not the fee itself, are the real shipment.
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 5);
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.beforeShipment, 'emperor').spice + 1);
assert.equal(game.techTokens!.heighliners.owner, owner);
assert.equal(game.techTokens!.heighliners.spice, pending, 'Delivery accrues the owner-count income as pending spice');
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
const closed = finishNexusCoreTechPhase(game);
assert.equal(closed.after.techTokens!.heighliners.spice, 0, 'Phase end pays the pending pile exactly once');
assert.equal(closed.after.techTokens!.heighliners.triggeredTurn, game.turn);
const beforeClose = wallets(closed.before), afterClose = wallets(closed.after);
assert.equal(afterClose[owner], beforeClose[owner] + pending);
for (const [id, spice] of Object.entries(beforeClose))
if (id !== owner) assert.equal(afterClose[id], spice, 'No other seat collects another token owner\'s pile');
});
void test('Advanced all14 Tech fixed Tleilaxu group pays real bank spice and only a free return accrues Axlotl', () => {
const f = createNexusCoreTechRevival();
firstStormCustody(f.game);
const owner = f.token.owner, pending = f.token.count;
const start = reactionPlayer(f.game, 'atreides');
assert.equal(start.tanks, 8, 'Actual battle losses supply the physical Tanks');
assert.equal(start.leaders.find(disc => disc.id === f.leader)!.dead, true);
let game = reactionAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
const rate = normalRevivalAllowance(game, reactionPlayer(game, 'atreides'));
assert.equal(rate.freeRate, 4);
assert.equal(rate.limit, 7);
rejected(game, 'atreides', { ...f.action, elite: 1 });
rejected(game, 'atreides', { ...f.action, leader: '' });
rejected(game, 'atreides', { ...f.action, amount: 6 });
game = quietPhaseFour(reactionAction(game, 'atreides', f.action));
const fixed = reactionPlayer(game, 'atreides');
assert.equal(fixed.spice, start.spice - 5, 'Five exact counters cost one bank spice each');
assert.equal(fixed.tanks, 3);
assert.equal(fixed.reserves, start.reserves + 5);
assert.equal(fixed.revived, 5);
assert.equal(fixed.freeForcesRevived, 0, 'The paid card group never consumes the free quota');
assert.equal(fixed.leaderRevived, true);
assert.equal(fixed.leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
assert.equal(game.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
assert.equal(game.techTokens!.axlotl.owner, owner);
assert.equal(game.techTokens!.axlotl.spice, 0, 'A paid fixed group is not a free revival and accrues nothing');
assert.equal(game.techTokens!.axlotl.triggeredTurn, undefined);
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 1 }));
const freeSeat = reactionPlayer(game, 'atreides');
assert.equal(freeSeat.spice, fixed.spice, 'The ordinary free return itself costs nothing');
assert.equal(freeSeat.freeForcesRevived, 1);
assert.equal(freeSeat.tanks, 2);
assert.equal(game.techTokens!.axlotl.spice, pending, 'One genuine free return accrues the owner-count pile');
assert.equal(game.techTokens!.axlotl.triggeredTurn, game.turn);
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 1 }));
assert.equal(reactionPlayer(game, 'atreides').spice, fixed.spice);
assert.equal(reactionPlayer(game, 'atreides').reserves, start.reserves + 7);
assert.equal(reactionPlayer(game, 'atreides').tanks, 1);
assert.equal(game.techTokens!.axlotl.spice, pending,
'A second genuine free return in this Revival does not mint another pile');
const closed = finishNexusCoreTechPhase(game);
assert.equal(closed.after.techTokens!.axlotl.spice, 0, 'Revival phase end pays the Axlotl pile once');
const beforeClose = wallets(closed.before), afterClose = wallets(closed.after);
assert.equal(afterClose[owner], beforeClose[owner] + pending);
});
void test('pending Axlotl spice cannot fund an unaffordable further paid revival', () => {
const f = createNexusCoreTechRevival({ drain: true });
const owner = f.token.owner, pending = f.token.count;
let game = reactionAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
const rate = normalRevivalAllowance(game, reactionPlayer(game, 'atreides'));
assert.ok(reactionPlayer(game, 'atreides').spice < 6,
'The genuine turn-one shipment leaves a thin wallet for this boundary');
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: rate.freeRate }));
const seat = reactionPlayer(game, 'atreides');
assert.equal(game.techTokens!.axlotl.spice, pending, 'The owner\'s own free return created the pending pile');
const quote = forceRevivalQuote(game, seat, 3);
assert.ok(quote.cost > seat.spice && quote.cost - seat.spice <= pending,
'Choose the smallest legal overage the pending pile would cover if it were spendable');
rejected(game, 'atreides', { type: 'revive', amount: 3 });
assert.equal(game.techTokens!.axlotl.spice, pending, 'The refusal cannot mint or clear token income');
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 2 }));
const paid = reactionPlayer(game, 'atreides');
assert.equal(paid.spice, seat.spice - 4, 'Two genuinely affordable paid counters cost their printed four bank spice');
assert.equal(paid.tanks, 2);
assert.equal(game.techTokens!.axlotl.spice, pending, 'Axlotl accrues once per turn, not once per return');
const closed = finishNexusCoreTechPhase(game);
assert.equal(closed.after.techTokens!.axlotl.spice, 0);
assert.equal(wallets(closed.after)[owner], wallets(closed.before)[owner] + pending);
});
void test('Basic independent36 Harass return and native winner transfer mint no token income', () => {
const f = createNexusCoreTechHarass();
const before = f.beforeCall;
const game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalUse',
event: f.game.pendingNexusHarkonnenBetrayal!.source.event });
const lost = ownedTech(before.techTokens, 'atreides');
assert.ok(lost.length, 'The defeated seat genuinely held tokens');
const moved = TECH_TOKENS.filter(token =>
before.techTokens![token.id].owner !== game.techTokens![token.id].owner);
assert.equal(moved.length, 1, 'The battle winner takes exactly one token from the loser');
const token = moved[0];
assert.equal(before.techTokens![token.id].owner, 'atreides');
assert.equal(game.techTokens![token.id].owner, 'harkonnen');
assert.ok(lost.includes(token.id), 'The native choice belongs to the loser\'s actual holdings');
assert.equal(game.pendingTech ?? null, null);
for (const rule of TECH_TOKENS) {
assert.equal(game.techTokens![rule.id].spice, 0, `${rule.name} mints no income from a traitor battle`);
}
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn,
'The earlier genuine shipment accrual was already paid at its own phase end');
assert.equal(reactionPlayer(game, 'atreides').reserves,
reactionPlayer(f.beforeShipment, 'atreides').reserves, 'The Harass return is not a Revival grant');
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(disc => disc.id === f.identity)?.dead, false);
});