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