Files
Dune/tests/semuta-nexus-emperor-bank.test.ts
T
Quintine 95ebd7f976
Verify and publish checkpoint container / container (push) Successful in 11m12s
Enable Basic Nexus Kull and native reactions
2026-10-30 18:26:05 +11:00

144 lines
9.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { emperorNexusBankPurchaseModeSupported } from '../game/nexus-emperor-purchase';
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, semutaNexusTradePlayer, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
import { nextNexusEmperorBankStep, stepNexusEmperorBank } from './fixture-nexus-emperor-bank';
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
function frontSpice(game: Game, kinds: ('land' | 'worm')[]) {
for (let i = 0; i < kinds.length; i++) {
const index = game.spiceDeck.findIndex((card, n) => n >= i && (kinds[i] === 'land' ? 'territory' in card : 'worm' in card));
assert.ok(index >= i);
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]);
}
}
/** Real initial setup, original Nexus and auction. Only unopened Spice order,
* already-drawn Nexus identity and conserved board positions are staged. */
function fixture() {
let game = createGame('SEMUTANEXUSBANK', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
for (const [id, faction] of [['g','guild'],['a','atreides'],['h','harkonnen']] as const)
joinGame(game, newPlayer(id, faction, faction));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
assert.equal(game.semutaPreview, true);
game = advanceSemutaNexusTrade(game, g => g.status === 'playing');
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
game = advanceSemutaNexusTrade(game, g => g.phase === 1 && clean(g));
frontSpice(game, ['land','land']);
game = advanceSemutaNexusTrade(game, g => g.phase === 5 && clean(g));
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((total, count) => total + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((total, count) => total + count, 0);
player.elites.forces = {};
}
}
game = advanceSemutaNexusTrade(game, g => g.turn === 2 && g.phase === 1 && clean(g));
frontSpice(game, ['worm','land','land']);
game = advanceSemutaNexusTrade(game, g => g.nexus && !g.spiceWindow && clean(g));
game = applyAction(game, 'g', {type:'alliance', target:'a'});
game = applyAction(game, 'a', {type:'alliance', target:'g'});
game = advanceSemutaNexusTrade(game, g => g.nexusCards?.phase?.stage === 'drawing');
game = applyAction(game, 'r', {type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0});
swapSemutaNexusTradeIdentity(game, 'r', 'emperor');
game = advanceSemutaNexusTrade(game, g => g.phase === 3 && !!g.auction && !g.richeseAuction && clean(g));
while (game.auction!.active !== 'r') game = applyAction(game, game.auction!.active, {type:'passBid'});
game = applyAction(game, 'r', {type:'bid', amount:2, allyPayment:0});
game = advanceSemutaNexusTrade(game, g => g.decision?.kind === 'auctionPayment');
assert.equal(game.decision!.player, 'r');
assert.equal(game.auction!.bid, 2);
return game;
}
for (const use of [false, true]) void test(`Basic Semuta Nexus bank auction ${use ? 'uses Emperor Secret Ally' : 'pays original price'} once`, () => {
let game = fixture();
const inventory = semutaNexusTradeInventory(game), before = structuredClone(game);
const buyer = semutaNexusTradePlayer(game, 'r'), card = game.auction!.cards[game.auction!.index];
const offer = viewGame(game, 'r').nexusEmperorSecretAlly;
assert.ok(offer?.purchase && !offer.purchase.blocked);
assert.ok(buyer.spice >= 2);
const price = buyer.spice;
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase', event:offer.event} : {type:'decision', karama:false});
assert.deepEqual(before.auction!.cards[before.auction!.index], card);
assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2));
assert.ok(semutaNexusTradePlayer(game, 'r').hand.some(held => held.id === card.id));
assert.equal(game.nexusCards!.cards!.hands.r, use ? null : 'emperor');
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, Number(use));
for (let n = 0; n < 100 && game.auction?.index === before.auction!.index; n++) {
assert.notEqual(game.decision?.kind, 'auctionPayment', 'Original lot must not charge twice');
game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game));
}
assert.notEqual(game.auction?.index, before.auction!.index);
assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2));
assert.deepEqual(semutaNexusTradeInventory(game), inventory);
});
void test('Basic Semuta bank purchase uses the supported native roster, not unrelated overlay flags', () => {
const game = createGame('BANKPROFILE', newPlayer('r','Richese','richese'), false, ['choam']);
joinGame(game, newPlayer('g','Guild','guild'));
game.nexusCards = {cards:null,phase:null}; game.semutaPreview = true;
assert.equal(emperorNexusBankPurchaseModeSupported(game), true);
const unusedIx = structuredClone(game); unusedIx.expansions.push('ix');
assert.equal(emperorNexusBankPurchaseModeSupported(unusedIx), true);
for (const change of [
(g:Game) => { g.advanced = true; },
(g:Game) => { g.homeworlds = {custody:null}; },
(g:Game) => { g.discoveryEnabled = true; },
(g:Game) => { g.expansions.push('ix', 'ix'); },
(g:Game) => { g.nexusCards = undefined; },
(g:Game) => { joinGame(g,newPlayer('e','Emperor','emperor')); },
]) {
const unsupported = structuredClone(game); change(unsupported);
assert.equal(emperorNexusBankPurchaseModeSupported(unsupported), false);
}
});
for (const claim of [false, true]) for (const use of [false, true]) void test(
`Semuta ${claim ? 'claim' : 'pass'} after Truthtrance retains the original Emperor Nexus bank offer and ${use ? 'Use' : 'Pay'}`, () => {
let game = fixture();
// Move only existing physical cards into holders before using the already
// opened native window; exchange a held card to retain each hand size.
const place = (owner: string, matches: (card: Game['deck'][number]) => boolean) => {
const seat = semutaNexusTradePlayer(game, owner);
const existing = seat.hand.find(matches); if (existing) return existing.id;
const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!,
...game.players.filter(player => player.id !== owner).map(player => player.hand)];
const source = zones.find(cards => cards.some(matches)); assert.ok(source);
const index = source.findIndex(matches), displaced = seat.hand.pop(); assert.ok(displaced);
const [card] = source.splice(index, 1, displaced); seat.hand.push(card); return card.id;
};
const truth = place('h', card => card.effect === 'truthtrance');
const semuta = place('a', card => card.effect === 'semutaDrug');
const original = semutaNexusTradeInventory(game), spice = semutaNexusTradePlayer(game,'r').spice;
const originalOffer = viewGame(game,'r').nexusEmperorSecretAlly!;
game = applyAction(game, 'h', {type:'card', card:truth});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
game = applyAction(game, actor, {type:'truthPass'});
}
const name = semutaNexusTradePlayer(game,'r').hand[0].name;
game = applyAction(game, 'h', {type:'truthAsk',question:{kind:'fact',target:'r',fact:{kind:'hand',name}}});
game = applyAction(game, 'r', {type:'truthAnswer',answer:'yes'});
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = game.pendingTreacheryDiscard!.batch.event;
if (claim) game = applyAction(game, 'a', {type:'semutaCommit',event});
else for (const seat of game.players) game = applyAction(game, seat.id, {type:'semutaPass',event});
assert.equal(semutaNexusTradePlayer(game,'a').hand.some(card => card.id === truth), claim);
assert.equal(game.discard.some(card => card.id === semuta), claim);
assert.equal(game.discard.some(card => card.id === truth), !claim);
assert.equal(viewGame(game,'r').nexusEmperorSecretAlly?.event, originalOffer.event);
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase',event:originalOffer.event} : {type:'decision',karama:false});
assert.equal(semutaNexusTradePlayer(game,'r').spice,spice - (use ? 0 : 2));
assert.equal(game.nexusCards!.cards!.hands.r,use ? null : 'emperor');
// Paying retains the held Emperor Nexus, so the newly initialized neutral
// Ixian Secret Ally purchase window must be passed before the lot closes.
if (!use) {
assert.ok(game.pendingNexusIxianReplacement);
game = applyAction(game, 'r', {type:'nexusIxianReplacementPass',event:game.pendingNexusIxianReplacement.event});
}
assert.deepEqual(semutaNexusTradeInventory(game),original);
});