Files
Dune/tests/semuta-nexus-ixian-replacement-public.test.ts
T
Quintine a1b09c5b02
Verify and publish checkpoint container / container (push) Successful in 10m42s
Connect three-deck Nexus Kull and Ixian Semuta
2026-10-30 18:50:24 +11:00

199 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; };
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
!game.pendingGuildBetrayal && !game.pendingTreacheryDiscard;
function inventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
...(game.ixAuction?.cards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length, 'Each physical Treachery card has one live location');
return cards;
}
/** Public Basic setup and a genuine qualifying Nexus draw. Original unopened
* Spice/Nexus order, board counters and card custody are staged conservatively;
* the alliance, bidding, payment and purchased-card response are real actions. */
function fixture(exhaustDeck = false, cacheSale = false) {
let game = createGame('SEMUTANEXUSIX', newPlayer('a','Atreides','atreides'), false, ['choam']);
for (const [id, faction] of [['r','richese'], ['e','emperor'], ['g','guild']] as const)
joinGame(game, newPlayer(id, faction, faction));
game = act(game, 'a', {type:'moduleOption', module:'nexusCards', enabled:true});
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'a', {type:'start'});
assert.equal(game.semutaPreview, true); assert.equal(game.nexusIxianReplacementPreview, true);
game = advanceSemutaNexusTrade(game, state => state.status === 'playing');
const original = inventory(game);
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {};
if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); player.elites.forces = {}; }
}
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, n) => n >= position && 'territory' in card); assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceSemutaNexusTrade(game, state => state.turn === 2 && state.phase === 1 && clean(state));
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed); assert.ok(worm >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
game = advanceSemutaNexusTrade(game, state => !!state.nexus && !state.spiceWindow && clean(state));
game = act(game,'e',{type:'alliance', target:'g'}); game = act(game,'g',{type:'alliance', target:'e'});
game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state));
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
game = act(game,'a',{type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0});
swapSemutaNexusTradeIdentity(game,'a','ixians');
// Move the physical Semuta to Richese, exchanging one existing card if held.
const richese = seat(game,'r'), zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)];
const source = zones.find(cards => cards.some(card => card.id === SEMUTA_DRUG_ID)); assert.ok(source);
if (source !== richese.hand) {
const index = source.findIndex(card => card.id === SEMUTA_DRUG_ID), [semuta] = source.splice(index,1);
const displaced = richese.hand.pop(); if (displaced) source.push(displaced);
richese.hand.push(semuta);
}
// A real held Karama keeps the native paid-or-free auction decision open.
const buyer = seat(game,'a');
if (!buyer.hand.some(card => card.effect === 'karama')) {
const holders = [game.deck, game.discard, game.richeseCache!, ...game.players.filter(player => player.id !== 'a').map(player => player.hand)];
const cards = holders.find(zone => zone.some(card => card.effect === 'karama')); assert.ok(cards);
const index = cards.findIndex(card => card.effect === 'karama'), [karama] = cards.splice(index,1);
const displaced = buyer.hand.pop(); if (displaced) cards.push(displaced);
buyer.hand.push(karama);
}
assert.deepEqual(inventory(game), original);
if (cacheSale) {
game = advanceSemutaNexusTrade(game,state => state.decision?.kind === 'richeseCache');
const lot = game.richeseCache!.find(card => card.id !== SEMUTA_DRUG_ID); assert.ok(lot);
const bought = structuredClone(lot), beforeSpice = seat(game,'a').spice;
game = act(game,'r',{type:'decision', event:game.richeseBidding!.event,
card:bought.id, method:'onceAround', direction:'clockwise'});
let bid = false;
for (let n = 0; !game.pendingNexusIxianReplacement && n < 150; n++) {
if (game.richeseAuction && !game.richeseAuction.outcome && !game.decision && !game.response && game.richeseAuction.active) {
const actor = game.richeseAuction.active;
const ownBid = actor === 'a' && !bid;
game = act(game,actor,{type:'richeseBid',event:game.richeseAuction.event,
amount:ownBid ? 2 : null, ...(ownBid ? {allyPayment:0} : {})});
if (ownBid) bid = true;
} else {
const next = nextSemutaNexusTradeNativeStep(game);
game = act(game,next.actor,next.action);
}
}
assert.ok(bid); assert.ok(game.pendingNexusIxianReplacement);
assert.equal(game.currentAuctionSale?.origin,'cache');
assert.equal(game.pendingNexusIxianReplacement!.card.id,bought.id);
assert.equal(seat(game,'a').spice,beforeSpice - 2);
assert.ok(game.deck[0]);
return {game, original, purchased:bought, replacement:structuredClone(game.deck[0]), beforeSpice};
}
game = advanceSemutaNexusTrade(game, state => state.phase === 3 && !!state.auction && clean(state));
while (game.auction!.active !== 'a') game = act(game, game.auction!.active, {type:'passBid'});
game = act(game,'a',{type:'bid', amount:2, allyPayment:0});
game = advanceSemutaNexusTrade(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'a');
const purchased = structuredClone(game.auction!.cards[game.auction!.index]), beforeSpice = seat(game,'a').spice;
// An empty draw deck uses the real discard recycling path. Stage only
// original, unallocated cards before the purchase source is committed.
if (exhaustDeck) game.discard.push(...game.deck.splice(0));
game = act(game,'a',{type:'decision',karama:false});
game = advanceSemutaNexusTrade(game, state => !!state.pendingNexusIxianReplacement);
assert.equal(seat(game,'a').spice, beforeSpice - 2);
assert.equal(game.pendingNexusIxianReplacement!.card.id,purchased.id);
assert.equal(game.deck.length === 0,exhaustDeck);
return {game, original, purchased, replacement:game.deck[0] ? structuredClone(game.deck[0]) : null, beforeSpice};
}
for (const claim of [false,true]) void test(`Basic public Ixian Nexus purchased-card discard offers Semuta ${claim ? 'claim' : 'pass'} before replacement`, () => {
const f = fixture(), before = structuredClone(f.game), bought = f.purchased.id;
assert.ok(f.replacement);
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
assert.deepEqual(f.game,before, 'Original input remains unchanged');
assert.equal(pending.nexusCards!.cards!.hands.a,null);
assert.equal(pending.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length,1);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy,'a');
assert.deepEqual(pending.deck,before.deck, 'Replacement draw waits for the fresh discard response');
assert.deepEqual(viewGame(pending,'r').semutaReaction!.candidates,[]);
assert.deepEqual(viewGame(pending,'g').semutaReaction!.candidates,[]);
const event = pending.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending,'r'); view.players.find(player => player.id === 'r')!.bot = difficulty;
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action);
assert.doesNotThrow(() => act(pending,'r',action));
}
let game = pending;
if (claim) game = act(game,'r',{type:'semutaCommit',event});
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true);
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
assert.equal(game.discard.some(card => card.id === bought),!claim);
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID),claim);
assert.equal(game.nexusIxianReplacementHistory!.length,1);
assert.equal(game.nexusIxianReplacementHistory![0].source.card.id,bought);
assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id);
assert.deepEqual(game.deck.map(card => card.id),before.deck.slice(1).map(card => card.id));
assert.deepEqual(inventory(game),f.original);
});
for (const claim of [false,true]) void test(`Basic Richese cache sale offers its Ixian Nexus discard to Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture(false,true), before = structuredClone(f.game), bought = f.purchased.id;
assert.ok(f.replacement); assert.equal(before.currentAuctionSale?.origin,'cache');
const sellerSpice = seat(before,'r').spice;
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false);
assert.deepEqual(pending.deck,before.deck);
assert.equal(pending.nexusCards!.cards!.hands.a,null);
assert.equal(seat(pending,'r').spice,sellerSpice, 'The original seller receipt waits for the replacement');
const event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
if (claim) game = act(game,'r',{type:'semutaCommit',event});
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true);
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
assert.equal(seat(game,'r').spice,sellerSpice+2, 'The original seller receipt settles once');
assert.equal(game.nexusIxianReplacementHistory!.length,1);
assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id);
assert.deepEqual(inventory(game),f.original);
});
for (const claim of [false,true]) void test(`Basic Ixian Nexus replacement recycles an empty deck after Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture(true), before = structuredClone(f.game), bought = f.purchased.id;
assert.equal(before.deck.length,0); assert.ok(before.discard.length > 0);
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.deck.length,0, 'Recycling and replacement wait for Semuta');
assert.equal(pending.discard.filter(card => card.id === bought).length,1);
assert.equal(pending.nexusCards!.cards!.hands.a,null);
const event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
if (claim) game = act(game,'r',{type:'semutaCommit',event});
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(game.nexusIxianReplacementHistory!.length,1);
const replacement = game.nexusIxianReplacementHistory![0].replacement; assert.ok(replacement);
assert.equal(seat(game,'a').hand.filter(card => card.id === replacement.id).length,1);
assert.equal(seat(game,'a').hand.length,seat(before,'a').hand.length);
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
assert.equal(game.discard.length,0, 'The real discard pile was shuffled into the exhausted deck');
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
assert.deepEqual(inventory(game),f.original);
});