144 lines
9.0 KiB
TypeScript
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);
|
|
});
|