199 lines
14 KiB
TypeScript
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);
|
|
});
|