273 lines
17 KiB
TypeScript
273 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
advanceNexusEmperorSeller, createNexusEmperorSellerFixture, nexusEmperorSellerCards,
|
|
nexusEmperorSellerInventory, nexusEmperorSellerPlayer, nextNexusEmperorSellerStep,
|
|
stepNexusEmperorSeller, type NexusEmperorSellerFixture,
|
|
} from './fixture-nexus-emperor-seller';
|
|
|
|
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
|
const original = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, original, 'Refusal must precede spending or native lot mutation');
|
|
}
|
|
function finishOriginalLot(f: NexusEmperorSellerFixture, game: Game): { game: Game; bonuses: number } {
|
|
let bonuses = 0;
|
|
let inBonus = false;
|
|
for (let n = 0; n < 100; n++) {
|
|
if (!game.richeseAuction && !game.currentAuctionSale) return { game, bonuses };
|
|
const bonus = game.response?.kind === 'harkonnenBonus';
|
|
if (bonus && !inBonus) bonuses++;
|
|
inBonus = bonus;
|
|
assert.ok(!game.decision, 'The original paid/free acquisition cannot reopen buyer confirmation');
|
|
game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game));
|
|
}
|
|
throw Error(`Original ${f.source} acquisition did not reach its source successor`);
|
|
}
|
|
const scenarios = [
|
|
{ advanced: false, source: 'cache' as const },
|
|
{ advanced: true, source: 'cache' as const },
|
|
{ advanced: true, source: 'blackMarket' as const },
|
|
];
|
|
|
|
for (const scenario of scenarios) for (const use of [false, true]) void test(
|
|
`${scenario.advanced ? 'Advanced' : 'Basic'} original ${scenario.source}: ${use ? 'retain spice without seller income' : 'ordinary paid seller income'} and native Harkonnen bonus once`, () => {
|
|
const f = createNexusEmperorSellerFixture(scenario), original = structuredClone(f.game);
|
|
const offer = viewGame(f.game, f.winner).nexusEmperorSellerPurchase!;
|
|
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
|
|
for (const rival of f.game.players.filter(player => player.id !== f.winner))
|
|
assert.equal(viewGame(f.game, rival.id).nexusEmperorSellerPurchase, null);
|
|
const action = { type: 'nexusEmperorSellerPurchase', event: f.event, use };
|
|
let game = stepNexusEmperorSeller(f.game, { actor: f.winner, action });
|
|
assert.deepEqual(f.game, original);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (use ? 0 : f.price));
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
|
assert.equal(game.response?.kind, 'harkonnenBonus', 'The existing native bonus cancellation boundary must remain');
|
|
assert.equal(game.currentAuctionSale!.origin, f.source);
|
|
assert.equal(game.currentAuctionSale!.free, use);
|
|
if (use) {
|
|
assert.equal(game.currentAuctionSale!.richeseContribution, undefined);
|
|
assert.equal(game.currentAuctionSale!.spiceBankerIncomePayment, undefined);
|
|
assert.equal(game.currentAuctionSale!.bureaucratPayment, undefined);
|
|
assert.equal(game.currentAuctionSale!.bureaucratPaymentEvent, undefined);
|
|
}
|
|
assert.equal(game.currentAuctionSale!.seller, f.seller);
|
|
assert.equal(game.richeseAuction!.event, f.lot);
|
|
assert.equal(game.richeseAuction!.cardId, f.card.id);
|
|
assert.deepEqual(game.richeseAuction!.outcome, original.richeseAuction!.outcome);
|
|
assert.equal(game.pendingNexusEmperorSellerPurchase, null);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.winner], use ? null : 'emperor');
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, use ? 1 : 0);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (use ? 0 : f.price));
|
|
for (const rival of game.players.filter(player => player.id !== f.winner && player.id !== f.seller))
|
|
assert.equal(rival.spice, f.wallets[rival.id], 'There is no bank-funded substitute income');
|
|
const bonusCard = game.deck[0];
|
|
assert.ok(bonusCard);
|
|
const settled = finishOriginalLot(f, game);
|
|
game = settled.game;
|
|
assert.equal(settled.bonuses, 1);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length,
|
|
nexusEmperorSellerPlayer(original, f.winner).hand.length + 2);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === bonusCard.id).length, 1);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
|
if (f.source === 'cache') {
|
|
assert.equal(game.richeseCache!.length, original.richeseCache!.length - 1);
|
|
assert.ok(!game.richeseCache!.some(card => card.id === f.card.id));
|
|
assert.equal(game.richeseBidding!.stage, 'normal');
|
|
assert.ok(game.auction, 'The original reduced ordinary pool follows the first cache lot');
|
|
} else {
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.seller).hand.length,
|
|
nexusEmperorSellerPlayer(original, f.seller).hand.length - 1);
|
|
assert.deepEqual(game.richeseCache, original.richeseCache);
|
|
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
|
assert.equal(game.decision!.kind, 'richeseDeclaration');
|
|
}
|
|
assert.deepEqual(nexusEmperorSellerCards(game), f.originalCards);
|
|
nexusEmperorSellerInventory(game);
|
|
if (use) {
|
|
const receipt = game.nexusEmperorPurchaseHistory!.at(-1)!;
|
|
assert.ok('version' in receipt && receipt.version === 2);
|
|
assert.equal(receipt.afterSpice, receipt.beforeSpice);
|
|
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
|
|
} else {
|
|
assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], original.nexusEmperorPurchaseHistory ?? []);
|
|
assert.deepEqual(game.nexusEmperorPurchaseEvents ?? [], original.nexusEmperorPurchaseEvents ?? []);
|
|
}
|
|
rejectsUnchanged(game, f.winner, action);
|
|
},
|
|
);
|
|
|
|
for (const advanced of [false, true]) void test(
|
|
`${advanced ? 'Advanced' : 'Basic'} genuine Richese self-cache purchase retains the original bank destination without bank money`, () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced, winnerFaction: 'richese' });
|
|
const beforeHand = nexusEmperorSellerPlayer(f.game, f.winner).hand.length;
|
|
const original = structuredClone(f.game);
|
|
const game = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
|
|
assert.deepEqual(f.game, original);
|
|
for (const player of game.players) assert.equal(player.spice, f.wallets[player.id]);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length, beforeHand + 1);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
|
assert.equal(game.richeseCache!.length, f.game.richeseCache!.length - 1);
|
|
assert.equal(game.richeseBidding!.stage, 'normal');
|
|
assert.equal(game.response?.kind === 'emperorIncome', false);
|
|
assert.equal(game.response?.kind === 'harkonnenBonus', false);
|
|
nexusEmperorSellerInventory(game);
|
|
},
|
|
);
|
|
|
|
void test('original buyer rejects stale, foreign, malformed and dual-cost actions without spending or source movement', () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket' });
|
|
for (const rival of f.game.players.filter(player => player.id !== f.winner))
|
|
rejectsUnchanged(f.game, rival.id, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
|
for (const action of [
|
|
{ type: 'nexusEmperorSellerPurchase', event: 'stale', use: true },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, price: 0 },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, allyPayment: 1 },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, card: f.card.id },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, cost: f.card.id },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: 'true' },
|
|
{ type: 'nexusEmperorSellerPurchase', event: f.event },
|
|
{ type: 'decision', event: f.event, use: true },
|
|
{ type: 'nexusEmperorPurchase', event: f.event },
|
|
]) rejectsUnchanged(f.game, f.winner, action);
|
|
assert.equal(f.game.nexusCards!.cards!.hands[f.winner], 'emperor');
|
|
nexusEmperorSellerInventory(f.game);
|
|
});
|
|
|
|
void test('a real Emperor holder cannot commit a personally unaffordable native seller bid', () => {
|
|
for (const scenario of scenarios) {
|
|
const f = createNexusEmperorSellerFixture(scenario);
|
|
const before = f.chronology.beforeWinnerBid;
|
|
const ownSpice = nexusEmperorSellerPlayer(before, f.winner).spice;
|
|
assert.equal(before.nexusCards!.cards!.hands[f.winner], 'emperor');
|
|
rejectsUnchanged(before, f.winner, { type: 'richeseBid', event: f.lot, amount: ownSpice + 1 });
|
|
assert.equal(nexusEmperorSellerPlayer(before, f.winner).spice, ownSpice);
|
|
assert.equal(nexusEmperorSellerPlayer(before, f.winner).hand.some(card => card.id === f.card.id), false);
|
|
nexusEmperorSellerInventory(before);
|
|
}
|
|
});
|
|
|
|
void test('real irrelevant closing draw has the same public buyer Pay boundary, never an Emperor spend entitlement', () => {
|
|
const f = createNexusEmperorSellerFixture({ nexusCard: 'guild' });
|
|
assert.equal(f.game.decision!.kind, 'nexusEmperorSellerPurchase');
|
|
assert.equal(f.game.decision!.player, f.winner);
|
|
assert.equal(!!viewGame(f.game, f.winner).nexusEmperorSellerPurchase?.canUse, false);
|
|
rejectsUnchanged(f.game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
|
const settled = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } })).game;
|
|
assert.equal(nexusEmperorSellerPlayer(settled, f.winner).spice, f.wallets[f.winner] - f.price);
|
|
assert.equal(nexusEmperorSellerPlayer(settled, f.seller).spice, f.wallets[f.seller] + f.price);
|
|
assert.equal(settled.nexusCards!.cards!.hands[f.winner], 'guild');
|
|
nexusEmperorSellerInventory(settled);
|
|
});
|
|
|
|
void test('naturally setup-dealt Truthtrance suspends the original seller purchase and restores its exact event before Use', () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', truthtrance: true });
|
|
const card = nexusEmperorSellerPlayer(f.game, 'choam').hand.find(held => held.effect === 'truthtrance');
|
|
assert.ok(card);
|
|
assert.ok(nexusEmperorSellerPlayer(f.chronology.afterSetup, 'choam').hand.some(held => held.id === card.id));
|
|
let game = stepNexusEmperorSeller(f.game, { actor: 'choam', action: { type: 'card', card: card.id } });
|
|
assert.equal(game.truthtrance!.stage, 'priority');
|
|
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
|
|
rejectsUnchanged(game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
|
|
game = stepNexusEmperorSeller(game, { actor, action: { type: 'truthPass' } });
|
|
}
|
|
const name = nexusEmperorSellerPlayer(game, f.winner).hand[0].name;
|
|
game = stepNexusEmperorSeller(game, { actor: 'choam', action: { type: 'truthAsk',
|
|
question: { kind: 'fact', target: f.winner, fact: { kind: 'hand', name } } } });
|
|
assert.equal(viewGame(game, f.winner).truthAnswer, 'yes');
|
|
game = stepNexusEmperorSeller(game, { actor: f.winner, action: { type: 'truthAnswer', answer: 'yes' } });
|
|
assert.equal(game.truthtrance, null);
|
|
assert.equal(game.decision!.kind, 'nexusEmperorSellerPurchase');
|
|
assert.equal(game.decision!.event, f.event);
|
|
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
|
|
assert.equal(game.discard.filter(held => held.id === card.id).length, 1);
|
|
game = finishOriginalLot(f, stepNexusEmperorSeller(game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller]);
|
|
nexusEmperorSellerInventory(game);
|
|
});
|
|
|
|
void test('minimal original Easy bot can legally resolve both genuine buyer entitlements', () => {
|
|
for (const nexusCard of ['emperor', 'guild'] as const) {
|
|
const f = createNexusEmperorSellerFixture({ nexusCard });
|
|
const view = viewGame(f.game, f.winner);
|
|
view.players.find(player => player.id === f.winner)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
assert.ok(action, 'The actual paused buyer needs a legal bot continuation');
|
|
const game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner, action })).game;
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (nexusCard === 'emperor' ? 0 : f.price));
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (nexusCard === 'emperor' ? 0 : f.price));
|
|
nexusEmperorSellerInventory(game);
|
|
}
|
|
});
|
|
|
|
void test('a retained original receipt remains historical through the next genuine turn and closing phases', () => {
|
|
const f = createNexusEmperorSellerFixture();
|
|
let game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
|
|
const receipt = structuredClone(game.nexusEmperorPurchaseHistory!.at(-1));
|
|
game = advanceNexusEmperorSeller(game, state => state.turn === 3 && state.phase === 2 &&
|
|
!state.phaseOpening && !state.response && !state.decision);
|
|
assert.deepEqual(game.nexusEmperorPurchaseHistory!.at(-1), receipt);
|
|
for (const player of game.players) assert.equal(viewGame(game, player.id).turn, 3);
|
|
nexusEmperorSellerInventory(game);
|
|
});
|
|
|
|
for (const betrayal of ['pass', 'bank'] as const) {
|
|
void test(`original Richese reaction ${betrayal} precedes price retention without inventing a later payment`, () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced: true, betrayal });
|
|
const before = structuredClone(f.game.players);
|
|
assert.equal(f.game.richeseBetrayal?.completed.at(-1)?.stage, betrayal === 'bank' ? 'used' : 'passed');
|
|
const free = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
|
|
for (const p of free.players) assert.equal(p.spice, before.find(old => old.id === p.id)!.spice);
|
|
const settled = finishOriginalLot(f, free);
|
|
assert.equal(settled.bonuses, 1);
|
|
assert.equal(nexusEmperorSellerPlayer(settled.game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
|
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
|
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length,
|
|
betrayal === 'bank' ? 1 : 0);
|
|
nexusEmperorSellerInventory(settled.game);
|
|
});
|
|
}
|
|
|
|
void test('Pay after original Richese bank diversion debits the buyer but pays no seller', () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', betrayal: 'bank' });
|
|
const paid = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
|
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } });
|
|
assert.equal(nexusEmperorSellerPlayer(paid, f.winner).spice, f.wallets[f.winner] - f.price);
|
|
assert.equal(nexusEmperorSellerPlayer(paid, f.seller).spice, f.wallets[f.seller]);
|
|
assert.equal(paid.nexusCards!.cards!.hands[f.winner], 'emperor');
|
|
const settled = finishOriginalLot(f, paid);
|
|
assert.equal(settled.bonuses, 1);
|
|
nexusEmperorSellerInventory(settled.game);
|
|
});
|
|
|
|
void test('original Richese self-purchase discard occurs before any Emperor Nexus disposal or acquisition', () => {
|
|
const f = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese', betrayal: 'before' });
|
|
let game = f.game;
|
|
while (game.pendingRicheseBetrayal) {
|
|
const receipt = game.pendingRicheseBetrayal.receipt;
|
|
const actor = receipt.required.find(id => !receipt.passed.includes(id))!;
|
|
game = stepNexusEmperorSeller(game, { actor, action: {
|
|
type: actor === 'guild' ? 'richeseBetrayalUse' : 'richeseBetrayalPass', event: receipt.event,
|
|
} });
|
|
}
|
|
assert.equal(game.pendingNexusEmperorSellerPurchase ?? null, null);
|
|
assert.equal(game.nexusCards!.cards!.hands[f.winner], 'emperor');
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
|
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.some(card => card.id === f.card.id), false);
|
|
assert.equal(game.discard.filter(card => card.id === f.card.id).length, 1);
|
|
for (const p of game.players) assert.equal(p.spice, f.wallets[p.id]);
|
|
nexusEmperorSellerInventory(game);
|
|
});
|