Files
Dune/tests/nexus-richese-betrayal-engine.test.ts
T

264 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeRicheseBetrayalGameForAudit, joinGame,
newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { validateNexusCards } from '../game/nexus-cards';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const player = (g: Game,id: string) => g.players.find(p => p.id === id)!;
function reject(g: Game,id: string,action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g,id,action));
assert.equal(JSON.stringify(g),before);
}
function payOriginalSeller(g: Game): Game {
const frame = g.pendingNexusEmperorSellerPurchase;
return frame ? applyAction(g, frame.winner,
{ type: 'nexusEmperorSellerPurchase', event: frame.event, use: false }) : g;
}
function passes(g: Game): Game {
const receipt = g.pendingRicheseBetrayal!.receipt;
for (const id of receipt.required)
g = applyAction(g,id,{type:'richeseBetrayalPass',event:receipt.event});
return payOriginalSeller(g);
}
function census(g: Game): string[] {
return [...g.deck,...g.discard,...(g.richeseCache ?? []),...(g.richeseRemoved ?? []),
...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(c => c.id).sort();
}
void test('Basic and Advanced cache veto spends one Nexus, discards exact target and retires the original lot without any purchase debit', () => {
for (const advanced of [false,true]) {
const f = createRicheseBetrayalFixture('purchase',{advanced});
const before = reload(f.game), receipt = before.pendingRicheseBetrayal!.receipt;
assert.equal(player(before,f.target).hand.some(c => c.id === f.card),false);
const done = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event});
assert.deepEqual(done.players.map(p => p.spice),before.players.map(p => p.spice));
assert.equal(done.discard.filter(c => c.id === f.card).length,1);
assert.equal(done.richeseCache!.some(c => c.id === f.card),false);
assert.equal(player(done,f.target).hand.some(c => c.id === f.card),false);
assert.equal(done.nexusCards!.cards!.hands[f.holder],null);
assert.equal(done.nexusCards!.cards!.discard[receipt.nexusDiscardIndex ?? before.nexusCards!.cards!.discard.length],'richese');
assert.equal(done.richeseBetrayal!.completed[0].stage,'used');
assert.equal(done.richeseBetrayal!.current,null);
assert.equal(done.richeseAuction,null);
assert.equal(done.richeseBidding!.stage,'normal');
assert.deepEqual(census(done),census(before));
validateNexusCards(done.nexusCards!.cards!,done.players);
const normalized = normalizeAutomaticGame(reload(done));
assert.deepEqual(normalized,done);
reject(done,f.holder,{type:'richeseBetrayalUse',event:f.event});
}
});
void test('all acknowledgements resume the original cache purchase exactly once, keeping the Nexus card', () => {
const f = createRicheseBetrayalFixture('purchase');
const before = reload(f.game);
const done = passes(reload(before));
assert.equal(player(done,f.target).spice,player(before,f.target).spice - 6);
assert.equal(player(done,f.target).hand.filter(c => c.id === f.card).length,1);
assert.equal(done.discard.some(c => c.id === f.card),false);
assert.equal(done.nexusCards!.cards!.hands[f.holder],'richese');
assert.equal(done.richeseBetrayal!.completed[0].stage,'passed');
assert.deepEqual(census(done),census(before));
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
reject(done,f.holder,{type:'richeseBetrayalPass',event:f.event});
});
void test('cache and concealed Black Market diversion preserve original ally split, delivery and earned Harkonnen bonus without seller income', () => {
for (const source of ['cache','blackMarket'] as const) {
const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2});
const before = reload(f.game), bonus = before.deck[0].id;
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.ownPayment,4);
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.donor,f.ally);
const done = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4);
assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice);
assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2);
assert.equal(player(done,f.target).spice,player(before,f.target).spice);
assert.deepEqual(player(done,f.buyer).hand.map(c => c.id).sort(),[f.card,bonus].sort());
assert.equal(done.richeseAuction,null);
assert.equal(done.richeseBidding!.stage,source === 'cache' ? 'normal' : 'declaration');
assert.equal(done.richeseBidding!.blackMarketSold,source === 'blackMarket');
assert.deepEqual(census(done),census(before));
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
reject(done,f.holder,{type:'richeseBetrayalUse',event:f.event});
}
});
void test('all-pass Black Market sale pays original seller and draws one native bonus, never a second debit', () => {
const f = createRicheseBetrayalFixture('sale',{allyPayment:2});
const before = reload(f.game), bonus = before.deck[0].id;
const done = passes(reload(before));
assert.equal(player(done,f.target).spice,player(before,f.target).spice + 6);
assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4);
assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice);
assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2);
assert.deepEqual(player(done,f.buyer).hand.map(c => c.id).sort(),[f.card,bonus].sort());
assert.equal(done.nexusCards!.cards!.hands[f.holder],'richese');
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
});
void test('public neutral timing is independent of Nexus identity and concealed source remains unentitled', () => {
const f = createRicheseBetrayalFixture('sale');
const relevant = reload(f.game), irrelevant = reload(f.game);
const cards = irrelevant.nexusCards!.cards!, index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = 'richese';cards.hands[f.holder] = 'choam';
for (const p of relevant.players) {
const a = viewGame(relevant,p.id), b = viewGame(irrelevant,p.id);
const publicReaction = (v: typeof a) => {
const r = v.richeseBetrayalReaction!;
return {event:r.event,kind:r.kind,target:r.target,buyer:r.buyer,source:r.source,price:r.price,canPass:r.canPass,hasPassed:r.hasPassed};
};
assert.deepEqual(publicReaction(a),publicReaction(b));
assert.equal(a.decision,null);assert.equal(a.response,null);
assert.equal(Object.hasOwn(a,'pendingRicheseBetrayal'),false);
assert.equal(Object.hasOwn(a,'richeseBetrayal'),false);
if (p.id !== f.target) {
assert.equal(a.richeseAuction!.card,null);
assert.equal(a.richeseAuction!.cardId,null);
assert.equal(JSON.stringify(a).includes(`"${f.card}"`),false);
}
}
assert.equal(viewGame(relevant,f.holder).richeseBetrayalReaction!.canUse,true);
assert.equal(viewGame(irrelevant,f.holder).richeseBetrayalReaction!.canUse,false);
assert.equal(viewGame(relevant,f.other).richeseBetrayalReaction!.canPass,true);
assert.equal(viewGame(relevant,f.other).richeseBetrayalReaction!.canUse,false);
});
void test('wrong actor/event/payload and all underlying actions reject immutably; pass relinquishes this exact use opportunity', () => {
const f = createRicheseBetrayalFixture('purchase');
for (const [id,action] of [
[f.target,{type:'richeseBetrayalUse',event:f.event}],
[f.other,{type:'richeseBetrayalUse',event:f.event}],
[f.holder,{type:'richeseBetrayalUse',event:'stale'}],
[f.holder,{type:'richeseBetrayalUse',event:f.event,card:f.card}],
[f.holder,{type:'ready'}],
[f.holder,{type:'nexusChoamBetrayal'}],
] as [string,Action][]) reject(f.game,id,action);
const passed = applyAction(reload(f.game),f.holder,{type:'richeseBetrayalPass',event:f.event});
assert.equal(viewGame(passed,f.holder).richeseBetrayalReaction!.hasPassed,true);
assert.equal(viewGame(passed,f.holder).richeseBetrayalReaction!.canUse,false);
reject(passed,f.holder,{type:'richeseBetrayalUse',event:f.event});
assert.deepEqual(normalizeAutomaticGame(reload(passed)),passed);
});
void test('orphaned/corrupt source receipts fail before projection, normalization or payment', () => {
const f = createRicheseBetrayalFixture('sale');
const corruptions: ((g: Game) => void)[] = [
g => {delete g.pendingRicheseBetrayal;},
g => {delete g.richeseBetrayal;},
g => {g.richeseBetrayal!.current = null;},
g => {g.pendingRicheseBetrayal!.quote.amount++;},
g => {g.pendingRicheseBetrayal!.receipt.invoice.card.name = 'forged face';},
g => {player(g,f.buyer).spice--;},
g => {player(g,f.target).hand.splice(0,1);},
];
for (const mutate of corruptions) {
const bad = reload(f.game);mutate(bad);
const before = JSON.stringify(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad,p.id));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad,f.holder,{type:'richeseBetrayalUse',event:f.event});
assert.equal(JSON.stringify(bad),before);
}
});
void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition', () => {
function lobby() {
let g = createGame('RICHESEADMISSION',newPlayer('r','Richese','richese'),false,['choam']);
joinGame(g,newPlayer('c','CHOAM','choam'));
g.nexusCards = {cards:null,phase:null};
for (const p of g.players) g = applyAction(g,p.id,{type:'ready'});
return g;
}
for (const mutate of [
(g: Game) => {g.nexusCards = undefined;},
(g: Game) => {g.expansions.push('ix');},
(g: Game) => {g.sandtrout = true;},
(g: Game) => {g.kullPreview = true;},
(g: Game) => {g.semutaPreview = true;},
(g: Game) => {g.players[1].faction = 'ixians';},
]) {
const bad = lobby();mutate(bad);
const before = JSON.stringify(bad);
assert.throws(() => initializeRicheseBetrayalGameForAudit(bad));
assert.equal(JSON.stringify(bad),before);
}
const fresh = lobby(), initialized = initializeRicheseBetrayalGameForAudit(fresh);
assert.equal(initialized.richeseBetrayalPreview,true);
assert.deepEqual(initialized.richeseBetrayal,{sequence:0,current:null,completed:[]});
});
void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => {
const f = createRicheseBetrayalFixture('sale',{bonusCounter:true});
const before = reload(f.game);
const paid = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
assert.equal(paid.response?.kind,'harkonnenBonus');
assert.equal(paid.currentAuctionSale!.recipient,'bank');
assert.equal(player(paid,f.target).spice,player(before,f.target).spice);
assert.equal(player(paid,f.buyer).spice,player(before,f.buyer).spice - 6);
assert.deepEqual(player(paid,f.buyer).hand.map(c => c.id),[f.card]);
assert.equal(viewGame(paid,f.other).richeseBetrayalReaction,null);
assert.deepEqual(normalizeAutomaticGame(reload(paid)),paid);
for (const mutate of [
(g: Game) => {g.currentAuctionSale = null;},
(g: Game) => {delete g.currentAuctionSale!.recipient;},
(g: Game) => {g.response = null;},
]) {
const bad = reload(paid);mutate(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad,p.id));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad,f.other,{type:'card',card:f.counterCard!,mode:'cancel'});
}
const done = applyAction(reload(paid),f.other,{type:'card',card:f.counterCard!,mode:'cancel'});
assert.equal(done.richeseAuction,null);
assert.equal(done.richeseBidding!.stage,'declaration');
assert.equal(player(done,f.target).spice,player(before,f.target).spice);
assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 6);
assert.deepEqual(player(done,f.buyer).hand.map(c => c.id),[f.card]);
assert.equal(done.discard.filter(c => c.id === f.counterCard).length,1);
assert.equal(done.richeseBetrayal!.completed.length,1);
assert.deepEqual(census(done),census(before));
});
void test('Richese can fund its buyer from escrow with no unpledged spice, with or without diversion', () => {
for (const source of ['cache','blackMarket'] as const) for (const use of [false,true]) {
const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2,allyIsSeller:true,allySpice:2});
assert.equal(player(f.game,f.target).spice,0);
assert.equal(f.game.aid[f.target].amount,2);
const done = use ? payOriginalSeller(applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event})) : passes(reload(f.game));
assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4);
// The authorized Advanced source sends Richese's own contribution to the
// Emperor or, with no Emperor seated, to the Bank: the seller receives only
// the buyer's own payment. Basic keeps the full sale credit.
assert.equal(player(done,f.target).spice,use ? 0 : source === 'cache' ? 6 : 4);
assert.equal(done.aid[f.target].amount,0);
assert.deepEqual(census(done),census(f.game));
assert.deepEqual(normalizeAutomaticGame(reload(done)),done);
}
});
void test('an exhausted cache no longer blocks a Black Market closing bid or its betrayal receipt', () => {
let emptied = false;
const fixture = createRicheseBetrayalFixture('sale',{source:'blackMarket',beforeFinalBid(g) {
(g.richeseRemoved ??= []).push(...g.richeseCache!.splice(0));
emptied = true;
}});
assert.ok(emptied);
const g = fixture.game;
// User ruling 7 October 2026: an exhausted cache is legal, so the closing bid
// completes and reaches its betrayal receipt with the physical cache preserved.
assert.equal(g.richeseCache?.length ?? 0,0);
assert.equal(g.richeseRemoved?.length,10);
assert.ok(g.pendingRicheseBetrayal);
assert.equal(g.pendingRicheseBetrayal.receipt.invoice.kind,'sale');
assert.equal(g.richeseBidding!.cacheCanceled,false);
});