Implement bounded Richese Nexus Betrayal auctions

This commit is contained in:
Quintine committed 2026-09-30 23:18:32 +10:00
1 parent a921c0da9b
commit 9c0000e469
25 files changed
+2208 -39

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
function saved(game: Game): Game {
return JSON.parse(JSON.stringify(game));
}
void test('all four profiles commit the saved purchase veto and sale diversion from their private projection', () => {
for (const profile of DIFFICULTIES) {
for (const kind of ['purchase', 'sale'] as const) {
const fixture = createRicheseBetrayalFixture(kind);
fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile;
const game = saved(fixture.game);
const own = viewGame(game, fixture.holder);
// No rival hand or resources may be needed to choose the response.
for (const rival of own.players) {
if (rival.id !== own.me) {
delete rival.hand;
delete rival.spice;
}
}
const reaction = own.richeseBetrayalReaction!;
assert.equal(reaction.canUse, true);
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'richeseBetrayalUse', event: reaction.event }]);
const balances = game.players.map(player => player.spice);
const buyerCount = game.players.find(player => player.id === fixture.buyer)!.hand.length;
const result = saved(applyAction(game, fixture.holder, actions[0]));
assert.equal(viewGame(result, fixture.holder).richeseBetrayalReaction, null);
assert.ok(result.nexusCards?.cards?.discard.includes('richese'));
if (kind === 'purchase') {
assert.deepEqual(result.players.map(player => player.spice), balances);
assert.equal(result.players.find(player => player.id === fixture.buyer)!.hand.length, buyerCount);
assert.ok(result.discard.some(card => card.id === fixture.card));
} else {
assert.equal(result.players.find(player => player.id === fixture.target)!.spice,
game.players.find(player => player.id === fixture.target)!.spice);
assert.equal(result.players.find(player => player.id === fixture.buyer)!.spice,
game.players.find(player => player.id === fixture.buyer)!.spice - reaction.price);
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card));
}
}
}
});
void test('all profiles pass publicly possible nonholder boundaries through saved original settlement', () => {
for (const profile of DIFFICULTIES) {
for (const kind of ['purchase', 'sale'] as const) {
const fixture = createRicheseBetrayalFixture(kind, { holderNexus: 'choam' });
for (const player of fixture.game.players) player.bot = profile;
let game = saved(fixture.game);
const original = viewGame(game, fixture.holder).richeseBetrayalReaction!;
assert.equal(original.canUse, false);
for (let acknowledgements = 0; acknowledgements < game.players.length; acknowledgements++) {
const responder = game.players.find(player => {
const reaction = viewGame(game, player.id).richeseBetrayalReaction;
return reaction?.canPass && !reaction.hasPassed;
});
if (!responder) break;
const view = viewGame(game, responder.id);
const actions = botActions(view);
assert.deepEqual(actions, [{ type: 'richeseBetrayalPass', event: original.event }]);
game = saved(applyAction(game, responder.id, actions[0]));
if (viewGame(game, responder.id).richeseBetrayalReaction) {
assert.deepEqual(botActions(viewGame(game, responder.id)), []);
}
}
assert.equal(viewGame(game, fixture.holder).richeseBetrayalReaction, null);
assert.ok(game.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card));
assert.equal(game.players.find(player => player.id === fixture.buyer)!.spice,
fixture.game.players.find(player => player.id === fixture.buyer)!.spice - original.price);
assert.equal(game.nexusCards?.cards?.hands[fixture.holder], 'choam');
}
}
});
void test('projected restrictions defeat physical Richese Nexus hints without exposing underlying bot actions', () => {
for (const profile of DIFFICULTIES) {
const fixture = createRicheseBetrayalFixture('purchase');
fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile;
const view = viewGame(saved(fixture.game), fixture.holder);
assert.equal(view.nexusCards?.card, 'richese');
const reaction = view.richeseBetrayalReaction!;
reaction.canUse = false;
reaction.blocked = 'The committed promise prevents spending this Nexus card.';
assert.deepEqual(botActions(view), [{ type: 'richeseBetrayalPass', event: reaction.event }]);
reaction.hasPassed = true;
assert.deepEqual(botActions(view), []);
reaction.hasPassed = false;
reaction.canPass = false;
assert.deepEqual(botActions(view), []);
reaction.canPass = true;
view.status = 'finished';
assert.deepEqual(botActions(view), []);
}
});
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeRicheseBetrayalGameForAudit, joinGame,
newPlayer, viewGame, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard, type NexusState } from '../game/nexus-cards';
import { orderNexusSpice } from './fixture-nexus-cards';
export const RICHESE_BETRAYAL_FIXTURE_FACTIONS = ['richese','choam','harkonnen','fremen'] as const;
export type RicheseBetrayalFixtureOptions = {
seatIds?: string[];
source?: 'cache' | 'blackMarket';
advanced?: boolean;
holderNexus?: 'richese' | 'choam';
allyPayment?: number;
allyIsSeller?: boolean;
allySpice?: number;
beforeFinalBid?: (g: Game) => void;
bonusCounter?: boolean;
};
export type RicheseBetrayalFixture = {
game: Game;
holder: string;
other: string;
target: string;
buyer: string;
card: string;
event: string;
ally: string | null;
counterCard: string | null;
};
function step(g: Game): Game {
for (const p of g.players) {
const v = viewGame(g,p.id);
v.players.find(seat => seat.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) return applyAction(g,p.id,action);
}
throw new Error(`Richese Betrayal setup stalled at ${g.status}/${g.phase}/${g.decision?.kind}`);
}
function holdNexus(g: Game, owner: string, card: 'richese' | 'choam' | 'guild') {
const cards = g.nexusCards!.cards!;
assert.ok(cards.deck.includes(card));
const ordered: NexusState = {...cards,deck:[card,...cards.deck.filter(c => c !== card)]};
g.nexusCards!.cards = drawNexusCard(ordered,owner,g.players,() => 0);
}
/** Genuine setup and completed Spice phase, conserved physical Nexus draws and
* real declaration/bid actions. Arrange resources only before the auction, never
* rewrite a paused receipt. Seat IDs are fixed before setup for authenticated SQL. */
export function createRicheseBetrayalFixture(
kind: 'purchase' | 'sale', options: RicheseBetrayalFixtureOptions = {},
): RicheseBetrayalFixture {
const ids = options.seatIds ?? ['richese','holder','buyer','other'];
assert.equal(ids.length,4);
const [target,holder,harkonnen,other] = ids;
const source = options.source ?? (kind === 'purchase' ? 'cache' : 'blackMarket');
assert.ok(kind !== 'purchase' || source === 'cache');
const advanced = options.advanced ?? source === 'blackMarket';
assert.ok(source !== 'blackMarket' || advanced);
let g = createGame('RICHESEBETRAYAL',newPlayer(target,'Richese','richese'),advanced,['choam']);
for (let i = 1; i < ids.length; i++)
joinGame(g,newPlayer(ids[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i]));
g.nexusCards = {cards:null,phase:null};
for (const p of g.players) g = applyAction(g,p.id,{type:'ready'});
g = initializeRicheseBetrayalGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 100; i++) g = step(g);
assert.equal(g.status,'playing');
for (const p of g.players) {g.deck.push(...p.hand.splice(0));p.spice = 20;}
for (let i = 0; g.phase === 0 && i < 100; i++) g = step(g);
assert.equal(g.phase,1);
orderNexusSpice(g,['land','land']);
for (let i = 0; g.phase === 1 && i < 100; i++) g = step(g);
assert.equal(g.phase,2);
assert.equal(g.nexusCards!.phase!.stage,'complete');
holdNexus(g,holder,options.holderNexus ?? 'richese');
const contribution = options.allyPayment ?? 0;
const buyer = kind === 'purchase' ? target : harkonnen;
let ally: string | null = null;
if (contribution) {
assert.equal(kind,'sale');
assert.ok(contribution > 0 && contribution <= 6);
ally = options.allyIsSeller ? target : other;
g.players.find(p => p.id === harkonnen)!.ally = ally;
g.players.find(p => p.id === ally)!.ally = harkonnen;
} else holdNexus(g,other,'guild');
const card = source === 'cache' ? g.richeseCache![0].id : (() => {
const index = g.deck.findIndex(c => c.kind === 'shield');
assert.ok(index >= 0);
const selected = g.deck.splice(index,1)[0];
g.players.find(p => p.id === target)!.hand.push(selected);
return selected.id;
})();
for (const id of g.order) g = applyAction(g,id,{type:'ready'});
while (g.decision?.kind === 'choamMarket')
g = applyAction(g,g.decision.player,{type:'decision',done:true});
assert.equal(g.phase,3);
if (source === 'cache') {
if (g.decision?.kind === 'richeseBlackMarket')
g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,decline:true});
g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,position:'first'});
}
assert.equal(g.decision?.kind,source === 'cache' ? 'richeseCache' : 'richeseBlackMarket');
g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,card,method:'onceAround',direction:'clockwise'});
assert.equal(g.response,null);
let counterCard: string | null = null;
if (options.bonusCounter) {
const index = g.deck.findIndex(c => c.effect === 'karama');
assert.ok(index >= 0);
const counter = g.deck.splice(index,1)[0];
g.players.find(p => p.id === other)!.hand.push(counter);
counterCard = counter.id;
}
if (ally) {
if (options.allySpice !== undefined) g.players.find(p => p.id === ally)!.spice = options.allySpice;
g = applyAction(g,ally,{type:'pledgeAid',amount:contribution});
}
for (let i = 0; !g.pendingRicheseBetrayal && i < 10; i++) {
const lot = g.richeseAuction!;
assert.ok(lot && !lot.outcome && lot.active);
if (lot.acted.length === lot.order.length - 1) options.beforeFinalBid?.(g);
g = applyAction(g,lot.active,{type:'richeseBid',event:lot.event,
amount:lot.active === buyer ? 6 : kind === 'purchase' && !lot.bidder ? 1 : null,
...(lot.active === buyer ? {allyPayment:contribution} : {})});
}
assert.ok(g.pendingRicheseBetrayal);
assert.equal(g.pendingRicheseBetrayal.receipt.invoice.kind,kind);
return {game:g,holder,other,target,buyer,card,event:g.pendingRicheseBetrayal.receipt.event,ally,counterCard};
}
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement } from 'react';
import type { ReactNode, ComponentProps } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game, GameView } from '../game/engine';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// Static imports load Next and CSS before these Node-only SSR alias hooks.
const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
const { GameTable } = await import('../components/game-table');
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, act() {}, busy }));
}
function click(node: ReactNode, prefix: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, prefix)) return true;
}
return false;
}
void test('only the canonical private holder can use while public possible nonholders acknowledge the same auction', () => {
const holderFixture = createRicheseBetrayalFixture('purchase');
const nonholderFixture = createRicheseBetrayalFixture('purchase', { holderNexus: 'choam' });
const holder = viewGame(JSON.parse(JSON.stringify(holderFixture.game)), holderFixture.holder);
const nonholder = viewGame(JSON.parse(JSON.stringify(nonholderFixture.game)), nonholderFixture.holder);
assert.match(markup(holder), /<button[^>]*>Use Richese Nexus/);
assert.match(markup(holder), /Inspect Richese Nexus card/);
assert.match(markup(nonholder), /<button[^>]*>Pass/);
assert.doesNotMatch(markup(nonholder), /<button[^>]*>Use Richese Nexus|Inspect Richese Nexus card/);
const outsider = viewGame(holderFixture.game, holderFixture.target);
assert.doesNotMatch(markup(outsider), /<button[^>]*>(?:Pass|Use Richese Nexus)/);
assert.match(markup(outsider), /Waiting for the required acknowledgements/);
});
void test('concealed Black Market receipt never exposes another hand, spice, or physical sale face', () => {
const fixture = createRicheseBetrayalFixture('sale');
const view = viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.holder);
const face = fixture.game.players.find(player => player.id === fixture.target)!.hand.find(card => card.id === fixture.card)!;
for (const rival of view.players) {
if (rival.id !== view.me) {
delete rival.hand;
delete rival.spice;
}
}
const html = markup(view);
assert.match(html, /concealed Black Market lot/);
assert.match(html, /same payment to the Spice Bank/);
assert.doesNotMatch(html, new RegExp(face.id));
assert.doesNotMatch(html, new RegExp(face.name));
});
void test('busy and blocked reactions cannot spend a cost, but blocked responders may pass', () => {
const fixture = createRicheseBetrayalFixture('purchase');
const view = viewGame(fixture.game, fixture.holder);
const busy = markup(view, true);
assert.match(busy, /disabled=""[^>]*>Pass/);
assert.match(busy, /disabled=""[^>]*>Use Richese Nexus/);
view.richeseBetrayalReaction!.blocked = 'An existing promise reserves this card.';
assert.doesNotMatch(markup(view), /<button[^>]*>Use Richese Nexus/);
assert.match(markup(view), /<button[^>]*>Pass/);
});
void test('human event-bound Use and Pass handlers settle real saved purchases and sales, not an underlying bid', () => {
for (const kind of ['purchase', 'sale'] as const) {
for (const use of [true, false]) {
const fixture = createRicheseBetrayalFixture(kind);
let game: Game = JSON.parse(JSON.stringify(fixture.game));
const own = viewGame(game, fixture.holder);
let selected: Action | undefined;
const tree = NexusRicheseBetrayal({ game: own, act(action) { selected = action; }, busy: false });
assert.equal(click(tree, use ? 'Use Richese Nexus' : 'Pass'), true);
assert.ok(selected);
game = applyAction(game, fixture.holder, selected);
if (!use) {
for (const player of game.players) {
const view = viewGame(game, player.id);
const offer = view.richeseBetrayalReaction;
if (offer?.canPass && !offer.hasPassed) {
game = applyAction(game, player.id, { type: 'richeseBetrayalPass', event: offer.event });
}
}
}
assert.equal(viewGame(game, fixture.holder).richeseBetrayalReaction, null);
const buyer = game.players.find(player => player.id === fixture.buyer)!;
const vetoed = use && kind === 'purchase';
assert.equal(buyer.hand.some(card => card.id === fixture.card), !vetoed);
assert.equal(buyer.spice, fixture.game.players.find(player => player.id === fixture.buyer)!.spice - (vetoed ? 0 : own.richeseBetrayalReaction!.price));
if (kind === 'sale') {
assert.equal(game.players.find(player => player.id === fixture.target)!.spice,
fixture.game.players.find(player => player.id === fixture.target)!.spice + (use ? 0 : own.richeseBetrayalReaction!.price));
}
}
}
});
void test('the actual table owns auction and voluntary controls through reservation, pause, transport and autopilot', () => {
const fixture = createRicheseBetrayalFixture('purchase');
const own = viewGame(fixture.game, fixture.holder);
const table = (game: ComponentProps<typeof GameTable>['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, {
game, send: async () => {}, busy, onExit() {},
}));
const html = table(own);
assert.match(html, /aria-label="Richese Nexus Betrayal response"/);
assert.match(html, /Richese Nexus Betrayal development preview/);
assert.match(html, /href="\/rules\?topic=nexus-richese-betrayal#nexus-richese-betrayal"/);
assert.doesNotMatch(html, /aria-label="Richese auction"|<button[^>]*>[^<]*(?:Submit bid|Bid |Cash in|Play Karama|Make a bribe)/i);
assert.match(table(own, true), /disabled=""[^>]*>Use Richese Nexus/);
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
const restricted: ComponentProps<typeof GameTable>['game'] = structuredClone(own);
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
assert.match(table(restricted), /disabled=""[^>]*>Use Richese Nexus/);
}
own.richeseBetrayalReaction = null;
own.richeseBetrayalPreview = false;
const ordinary = table(own);
assert.match(ordinary, /aria-label="Richese auction"/);
assert.doesNotMatch(ordinary, /Richese Nexus Betrayal development preview|aria-label="Richese Nexus Betrayal response"/);
});
aliases.deregister();
+252
View File
@@ -0,0 +1,252 @@
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 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 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 = 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 and does not bypass public starts', () => {
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:[]});
reject(fresh,'r',{type:'start'});
});
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 = 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 ? 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);
assert.equal(player(done,f.target).spice,use ? 0 : 6);
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 rejects a Black Market closing bid before saving an unfinishable Harkonnen acknowledgement', () => {
let before: Game | null = null, serialized = '';
assert.throws(() => createRicheseBetrayalFixture('sale',{source:'blackMarket',beforeFinalBid(g) {
(g.richeseRemoved ??= []).push(...g.richeseCache!.splice(0));
before = g;serialized = JSON.stringify(g);
}}), /cache/i);
assert.ok(before);
assert.equal(JSON.stringify(before),serialized);
assert.equal((before as Game).pendingRicheseBetrayal,null);
assert.equal((before as Game).richeseBetrayal!.current,null);
assert.equal((before as Game).richeseAuction!.outcome,null);
});
@@ -0,0 +1,494 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { viewGame, type Action, type Game } from '../game/engine';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import {
createRicheseBetrayalFixture,
RICHESE_BETRAYAL_FIXTURE_FACTIONS,
type RicheseBetrayalFixture,
type RicheseBetrayalFixtureOptions,
} from './fixture-richese-betrayal';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
type Kind = 'purchase' | 'sale';
type Options = RicheseBetrayalFixtureOptions;
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
const player = (g: Game, id: string) => {
const found = g.players.find(p => p.id === id);
assert.ok(found);
return found;
};
function physical(g: Game) {
const ids = [
...g.deck, ...g.discard, ...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []), ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'physical Treachery custody is unique');
return ids;
}
function nexusInventory(g: Game) {
assert.ok(g.nexusCards?.cards);
const cards = g.nexusCards.cards;
validateNexusCards(cards, g.players);
assert.deepEqual([
...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter(card => card !== null),
].sort(), [...NEXUS_FACTIONS].sort());
}
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
async function fixture(t: test.TestContext, kind: Kind, options: Options = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const [host, ...rest] = RICHESE_BETRAYAL_FIXTURE_FACTIONS;
const made = await store.rooms.createRoom('Richese Betrayal SQL', host, options.advanced ?? true, ['choam']);
const code = made.view.code;
const tokens = [made.token];
for (const faction of rest)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const seatIds = auths.map(auth => auth.playerId);
const madeGame = createRicheseBetrayalFixture(kind, { ...options, seatIds });
const initial = madeGame.game;
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
save(initial);
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'atreides', false, []);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code);
const credentialRows = credentials(store.sqlite);
const stock = physical(initial);
nexusInventory(initial);
store.writes.length = 0;
return { ...store, ...madeGame, code, tokens, auths, initial, save, stock, credentialRows,
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow };
}
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends RicheseBetrayalFixture {
code: string;
tokens: string[];
auths: SeatAuth[];
initial: Game;
save(g: Game): void;
stock: string[];
credentialRows: { seats: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, g: Game) {
assert.deepEqual(physical(g), f.stock);
nexusInventory(g);
protectedRows(f);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
// Authorization is the credential's values, not its realm/prototype or brand.
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, id: string, action: Action) {
const rooms = f.restart();
const current = await rooms.readRoom(f.code);
await rooms.act(f.code, await authenticate(f, id), current.version, action, clock);
return f.restart().readRoom(f.code);
}
const pass = (event: string): Action => ({ type: 'richeseBetrayalPass', event });
const use = (event: string): Action => ({ type: 'richeseBetrayalUse', event });
function publicReaction(g: Game, id: string) {
const reaction = viewGame(g, id).richeseBetrayalReaction;
assert.ok(reaction);
const { event, kind, target, buyer, source, price } = reaction;
return { event, kind, target, buyer, source, price };
}
async function restored(f: Fixture, g: Game) {
const before = row(f);
const writeCount = f.writes.length;
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
assert.equal(auth.playerId, f.auths[index].playerId);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
assert.equal(view.richeseBetrayalPreview, true);
for (const key of ['pendingRicheseBetrayal', 'richeseBetrayal'])
assert.equal(Object.hasOwn(view, key), false, 'receipt/cursor stays private');
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
assert.equal(Object.hasOwn(rival, 'hand'), false);
assert.equal(Object.hasOwn(rival, 'traitors'), false);
assert.equal(Object.hasOwn(rival, 'spice'), false);
}
if (view.richeseBetrayalReaction) {
assert.deepEqual(publicReaction(g, auth.playerId), publicReaction(g, f.holder));
assert.deepEqual(Object.keys(view.richeseBetrayalReaction).sort(), [
'event', 'kind', 'target', 'buyer', 'source', 'price',
'canPass', 'hasPassed', 'canUse', 'blocked',
].sort());
assert.equal(view.richeseBetrayalReaction.canUse,
auth.playerId === f.holder && g.nexusCards!.cards!.hands[f.holder] === 'richese' &&
!view.richeseBetrayalReaction.hasPassed);
if (view.richeseBetrayalReaction.source === 'blackMarket' && auth.playerId !== f.target) {
assert.equal(view.richeseAuction?.card ?? null, null);
assert.equal(JSON.stringify(view).includes(f.card), false, 'concealed lot identity stays unentitled');
}
}
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock),
f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, 'refresh/recovery cannot acknowledge a human opportunity');
assert.equal(f.writes.length, writeCount);
inventory(f, g);
}
async function passOthers(f: Fixture, g: Game) {
for (const p of g.players) {
if (p.id === f.holder) continue;
const reaction = viewGame(g, p.id).richeseBetrayalReaction;
if (reaction?.canPass) g = await act(f, p.id, pass(reaction.event));
}
assert.equal(viewGame(g, f.holder).richeseBetrayalReaction?.canPass, true);
return g;
}
async function allPass(f: Fixture, g: Game) {
g = await passOthers(f, g);
await restored(f, g);
return act(f, f.holder, pass(f.event));
}
function spentOnce(f: Fixture, g: Game) {
assert.equal(g.nexusCards!.cards!.hands[f.holder], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(g.richeseBetrayal!.completed.length, 1);
assert.equal(g.richeseBetrayal!.completed[0].stage, 'used');
assert.equal(viewGame(g, f.holder).richeseBetrayalReaction, null);
}
function vetoed(f: Fixture, g: Game) {
spentOnce(f, g);
assert.deepEqual(g.players.map(p => [p.id, p.spice]), f.initial.players.map(p => [p.id, p.spice]));
for (const p of g.players) assert.deepEqual(p.hand, player(f.initial, p.id).hand);
assert.equal(g.discard.filter(card => card.id === f.card).length, 1);
assert.equal(g.richeseCache!.some(card => card.id === f.card), false);
assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event);
inventory(f, g);
}
function sold(f: Fixture, g: Game, diverted: boolean, allyPayment = 0) {
const invoice = publicReaction(f.initial, f.holder);
assert.equal(player(g, f.buyer).spice, player(f.initial, f.buyer).spice - invoice.price + allyPayment);
assert.equal(player(g, f.target).spice, player(f.initial, f.target).spice + (diverted ? 0 : invoice.price));
if (allyPayment) {
const ally = player(f.initial, f.buyer).ally!;
assert.equal(player(g, ally).spice, player(f.initial, ally).spice);
assert.equal(g.aid[ally].amount, f.initial.aid[ally].amount - allyPayment);
}
for (const p of g.players) {
if ([f.buyer, f.target, player(f.initial, f.buyer).ally].includes(p.id)) continue;
assert.equal(p.spice, player(f.initial, p.id).spice);
}
const total = (game: Game) => game.players.reduce((sum, p) => sum + p.spice, 0) +
Object.values(game.aid).reduce((sum, credit) => sum + credit.amount, 0);
assert.equal(total(g), total(f.initial) - (diverted ? invoice.price : 0),
'bank receives the original payment, not a second charge');
const buyer = player(g, f.buyer);
const bonus = f.initial.deck[0].id;
assert.equal(buyer.faction, 'harkonnen');
assert.deepEqual(buyer.hand.map(card => card.id).sort(),
[...player(f.initial, f.buyer).hand.map(card => card.id), f.card, bonus].sort());
assert.equal(g.deck.some(card => card.id === bonus), false);
assert.equal(player(g, f.target).hand.some(card => card.id === f.card), false);
assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event);
inventory(f, g);
}
for (const advanced of [false, true]) {
void test(`SQLite saved ${advanced ? 'Advanced' : 'Basic'} cache veto discards its exact lot without debit or acquisition`, async t => {
const f = await fixture(t, 'purchase', { advanced });
await restored(f, f.initial);
assert.equal(player(f.initial, f.target).hand.some(card => card.id === f.card), false);
assert.equal(f.initial.richeseCache!.some(card => card.id === f.card), true);
const done = await act(f, f.holder, use(f.event));
assert.equal(done.version, f.initial.version + 1);
vetoed(f, done);
await restored(f, done);
const before = row(f), writes = f.writes.length;
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, use(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const scenario of [
{ source: 'cache', advanced: false, allyPayment: 0 },
{ source: 'cache', advanced: true, allyPayment: 2 },
{ source: 'blackMarket', advanced: true, allyPayment: 2 },
] as const) {
void test(`SQLite saved ${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.source} sale diversion preserves original payers, card and one Harkonnen bonus`, async t => {
const f = await fixture(t, 'sale', scenario);
await restored(f, f.initial);
const done = await act(f, f.holder, use(f.event));
spentOnce(f, done);
sold(f, done, true, scenario.allyPayment);
await restored(f, done);
const before = row(f), writes = f.writes.length;
for (const action of [use(f.event), pass(f.event)])
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, action, clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite saved all-pass ${kind} performs the original quoted settlement once without consuming Nexus`, async t => {
const f = await fixture(t, kind);
const invoice = publicReaction(f.initial, f.holder);
const done = await allPass(f, f.initial);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.equal(done.nexusCards!.cards!.discard.includes('richese'), false);
assert.equal(viewGame(done, f.holder).richeseBetrayalReaction, null);
assert.equal(done.richeseBetrayal!.completed.length, 1);
assert.equal(done.richeseBetrayal!.completed[0].stage, 'passed');
if (kind === 'sale') sold(f, done, false);
else {
assert.equal(player(done, f.target).spice, player(f.initial, f.target).spice - invoice.price);
assert.deepEqual(player(done, f.target).hand.map(card => card.id).sort(),
[...player(f.initial, f.target).hand.map(card => card.id), f.card].sort());
assert.equal(done.discard.some(card => card.id === f.card), false);
assert.equal(done.richeseCache!.some(card => card.id === f.card), false);
for (const p of done.players.filter(p => p.id !== f.target))
assert.equal(p.spice, player(f.initial, p.id).spice);
inventory(f, done);
}
await restored(f, done);
const before = row(f), writes = f.writes.length;
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, pass(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite competing final pass/use ${kind} CAS commits one original settlement after restart`, async t => {
const f = await fixture(t, kind);
const pending = await passOthers(f, f.initial);
await restored(f, pending);
f.writes.length = 0;
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
let results: PromiseSettledResult<unknown>[];
try {
const auth = await authenticate(f, f.holder);
results = await Promise.allSettled([pass(f.event), use(f.event)].map(action =>
f.restart().act(f.code, auth, pending.version, action, clock).finally(release)));
} finally {
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
assert.deepEqual(f.writes.map(write => write.changes).sort((a,b) => a - b), [0, 1]);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
const used = results[1].status === 'fulfilled';
assert.equal(done.nexusCards!.cards!.hands[f.holder], used ? null : 'richese');
assert.equal(done.richeseBetrayal!.completed.length, 1);
assert.equal(done.richeseBetrayal!.completed[0].stage, used ? 'used' : 'passed');
if (used) spentOnce(f, done);
if (kind === 'sale') sold(f, done, used);
else if (used) vetoed(f, done);
else {
assert.equal(player(done, f.target).spice,
player(f.initial, f.target).spice - publicReaction(f.initial, f.holder).price);
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
}
await restored(f, done);
});
}
void test('SQLite stale, foreign-seat, forged invoice and wrong-event reactions reject before writes and preserve real credentials', async t => {
const f = await fixture(t, 'purchase');
const rooms = f.restart();
const before = row(f);
const holder = await authenticate(f, f.holder);
const foreign = await rooms.authenticate(f.unrelatedCode, f.unrelatedToken);
await assert.rejects(rooms.authenticate(f.code, f.unrelatedToken));
await assert.rejects(rooms.readSeatView(f.code, foreign));
await assert.rejects(rooms.readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
const requests: { auth: SeatAuth; version: number; action: Action }[] = [
{ auth: holder, version: f.initial.version - 1, action: use(f.event) },
{ auth: foreign, version: f.initial.version, action: use(f.event) },
{ auth: await authenticate(f, f.target), version: f.initial.version, action: use(f.event) },
{ auth: holder, version: f.initial.version, action: use(`${f.event}:stale`) },
{ auth: holder, version: f.initial.version, action: pass(`${f.event}:stale`) },
{ auth: holder, version: f.initial.version,
action: { ...use(f.event), card: f.card, price: 0, recipient: f.holder } as Action },
];
for (const request of requests) {
await assert.rejects(rooms.act(f.code, request.auth, request.version, request.action, clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
protectedRows(f);
}
const pending = await passOthers(f, f.initial);
const saved = row(f), writes = f.writes.length;
if (pending.version !== f.initial.version)
await assert.rejects(rooms.act(f.code, holder, f.initial.version, use(f.event), clock));
const otherReaction = viewGame(pending, f.other).richeseBetrayalReaction;
if (otherReaction?.hasPassed)
await assert.rejects(rooms.act(f.code, await authenticate(f, f.other), pending.version, pass(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, writes);
await restored(f, pending);
});
void test('SQLite neutral acknowledgement and all-seat public invoice survive hidden relevant/nonrelevant Nexus substitution', async t => {
const f = await fixture(t, 'sale');
const irrelevant = structuredClone(f.initial);
const cards = irrelevant.nexusCards!.cards!;
const replacement = cards.deck.indexOf('choam');
assert.ok(replacement >= 0);
assert.equal(cards.hands[f.holder], 'richese');
cards.hands[f.holder] = 'choam';
cards.deck[replacement] = 'richese';
f.save(irrelevant);
f.writes.length = 0;
await restored(f, irrelevant);
for (const auth of f.auths) {
const original = viewGame(f.initial, auth.playerId).richeseBetrayalReaction!;
const changed = (await f.restart().readSeatView(f.code,
await authenticate(f, auth.playerId))).richeseBetrayalReaction!;
assert.deepEqual(publicReaction(irrelevant, auth.playerId), publicReaction(f.initial, auth.playerId));
assert.equal(changed.canPass, original.canPass);
assert.equal(changed.hasPassed, original.hasPassed);
assert.equal(changed.canUse, false);
if (auth.playerId !== f.holder) assert.deepEqual(plain(changed), plain(original));
}
const saved = row(f);
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
irrelevant.version, use(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, 0);
const done = await allPass(f, irrelevant);
sold(f, done, false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'choam');
await restored(f, done);
});
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite corrupt/orphaned saved ${kind} receipts reject all-seat disclosure and settlement before writes`, async t => {
const f = await fixture(t, kind);
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'deleted frame with independent pending cursor', change: g => { delete g.pendingRicheseBetrayal; } },
{ name: 'deleted cursor with orphan frame', change: g => { delete g.richeseBetrayal; } },
{ name: 'detached cursor event', change: g => { g.richeseBetrayal!.current = null; } },
{ name: 'lost pending sequence', change: g => { g.richeseBetrayal!.sequence = 0; } },
{ name: 'changed public price', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.price++; } },
{ name: 'changed payer split', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.ownPayment++; } },
{ name: 'changed physical face', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.card.name += ' forged'; } },
{ name: 'foreign buyer', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.buyer = f.holder; } },
{ name: 'changed source quote', change: g => { g.pendingRicheseBetrayal!.quote.amount++; } },
{ name: 'changed original lot', change: g => { g.pendingRicheseBetrayal!.lot.cardId = g.deck[0].id; } },
{ name: 'foreign required acknowledgement', change: g => { g.pendingRicheseBetrayal!.receipt.required.push(f.target); } },
{ name: 'forged pass', change: g => { g.pendingRicheseBetrayal!.receipt.passed.push(f.holder); } },
{ name: 'lost exact source custody', change: g => {
if (kind === 'purchase') {
const index = g.richeseCache!.findIndex(card => card.id === f.card);
assert.ok(index >= 0);
player(g, f.other).hand.push(...g.richeseCache!.splice(index, 1));
} else {
const owner = player(g, f.target);
const index = owner.hand.findIndex(card => card.id === f.card);
assert.ok(index >= 0);
player(g, f.other).hand.push(...owner.hand.splice(index, 1));
}
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const saved = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code,
await authenticate(f, auth.playerId)), name);
for (const action of [pass(f.event), use(f.event)])
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
corrupt.version, action, clock), name);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), saved, name);
assert.equal(f.writes.length, 0, name);
protectedRows(f);
}
});
}
void test('SQLite completed veto history survives later physical card movement but not deleted or corrupted completion evidence', async t => {
const f = await fixture(t, 'purchase');
const done = await act(f, f.holder, use(f.event));
vetoed(f, done);
const later = structuredClone(done);
// Model later component recycling without recreating or copying a component.
const treacheryIndex = later.discard.findIndex(card => card.id === f.card);
assert.ok(treacheryIndex >= 0);
later.deck.push(...later.discard.splice(treacheryIndex, 1));
const cards = later.nexusCards!.cards!;
const nexusIndex = cards.discard.indexOf('richese');
assert.ok(nexusIndex >= 0);
cards.deck.push(...cards.discard.splice(nexusIndex, 1));
f.save(later);
f.writes.length = 0;
await restored(f, later);
assert.equal(later.richeseBetrayal!.completed[0].stage, 'used');
const saved = row(f);
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
later.version, use(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, 0);
for (const change of [
(g: Game) => { g.richeseBetrayal!.completed = []; },
(g: Game) => { g.richeseBetrayal!.completed[0].invoice.price++; },
(g: Game) => { delete g.richeseBetrayal; },
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
corrupt.version, use(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
protectedRows(f);
}
});
+237
View File
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Card } from '../game/cards';
import { NEXUS_FACTIONS, type NexusPlayer, type NexusState } from '../game/nexus-cards';
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
import { richeseCards } from '../game/richese-cards';
import { quoteRicheseSettlement } from '../game/richese-settlement';
import {
createRicheseBetrayalReceipt, quoteRicheseBetrayalInvoice,
requiredRicheseBetrayalResponders, richeseBetrayalEligibility,
richeseBetrayalEvent, validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt,
RicheseBetrayalError,
type RicheseBetrayalReceipt,
} from '../game/nexus-richese-betrayal';
const players: NexusPlayer[] = [
{ id: 'seller', faction: 'richese' },
{ id: 'buyer', faction: 'atreides' },
{ id: 'holder', faction: 'fremen' },
{ id: 'emperor', faction: 'emperor' },
];
const context = { turn: 2, players };
function soldInput(source: 'cache' | 'blackMarket' = 'cache', self = false, allyPayment = 0) {
const card: Card = source === 'cache' ? richeseCards()[0] : { id: 'base-weapon', name: 'Maula Pistol', kind: 'projectile' };
const winner = self ? 'seller' : 'buyer';
let lot = createRicheseAuction({
event: 'lot-2', cardId: card.id, owner: 'seller', source, method: 'silent',
eligible: ['buyer', 'seller'], order: ['buyer', 'seller'], tieOrder: ['buyer', 'seller'],
});
lot = submitRicheseBid(lot, { event: lot.event, actor: 'buyer', amount: self ? 0 : 5 }, 5);
lot = submitRicheseBid(lot, { event: lot.event, actor: 'seller', amount: self ? 5 : 0 }, 5);
const roster = allyPayment ? players.map(p => ({ ...p,
...(p.id === winner ? { ally: 'emperor' } : p.id === 'emperor' ? { ally: winner } : {}),
})) : players;
const quote = quoteRicheseSettlement({
lot, owner: { id: 'seller', handCount: 0, handLimit: 4 },
winner: { id: winner, handCount: 0, handLimit: 4, spice: 5 - allyPayment },
card, funding: { amount: 5, allyPayment, donor: allyPayment ? 'emperor' : null },
allyCredit: { amount: allyPayment },
});
assert.equal(quote.kind, 'sold');
if (quote.kind !== 'sold') throw new Error('Fixture must produce a funded sold invoice.');
return { players: roster, lot, quote, donor: allyPayment ? 'emperor' : null,
originalRecipient: (self ? 'emperor' : 'seller') as string | null, sourceCards: [card], physicalCards: [card] };
}
function receipt(stage: 'pending' | 'passed' | 'used' = 'used'): RicheseBetrayalReceipt {
return createRicheseBetrayalReceipt(context, {
event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1,
round: 'round-2', lot: 'lot-2', invoice: quoteRicheseBetrayalInvoice(soldInput()),
required: ['holder', 'buyer'], passed: stage === 'passed' ? ['holder', 'buyer'] : ['buyer'],
stage, actor: stage === 'used' ? 'holder' : null, nexusDiscardIndex: stage === 'used' ? 0 : null,
});
}
function cardsFor(holderCard: typeof NEXUS_FACTIONS[number]): NexusState {
return { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== holderCard), discard: [],
hands: { seller: null, buyer: null, holder: holderCard, emperor: null } };
}
void test('public self-cache invoice remains distinct from an income-bearing sale', () => {
const self = quoteRicheseBetrayalInvoice(soldInput('cache', true));
assert.equal(self.kind, 'purchase');
assert.equal(self.buyer, self.target);
assert.equal(self.originalRecipient, 'emperor');
const sale = quoteRicheseBetrayalInvoice(soldInput('cache', false, 3));
assert.equal(sale.kind, 'sale');
assert.equal(sale.originalRecipient, sale.target);
assert.equal(sale.price, 5);
assert.equal(sale.ownPayment, 2);
assert.equal(sale.allyPayment, 3);
assert.equal(sale.donor, 'emperor');
const bank = soldInput('cache', true);
bank.players = bank.players.filter(p => p.id !== 'emperor');
bank.originalRecipient = null;
assert.equal(quoteRicheseBetrayalInvoice(bank).originalRecipient, null);
});
void test('concealed Black Market sale needs no Richese-family face but cannot become a self-purchase', () => {
const input = soldInput('blackMarket');
const invoice = quoteRicheseBetrayalInvoice(input);
assert.equal(invoice.source, 'blackMarket');
assert.equal(invoice.kind, 'sale');
assert.equal(invoice.card.id, 'base-weapon');
const self = structuredClone(input);
self.lot.outcome = { kind: 'sold', winner: 'seller', amount: 5 };
self.quote.winner = 'seller';
assert.throws(() => quoteRicheseBetrayalInvoice(self), RicheseBetrayalError);
});
void test('quote rejects changed custody, substituted face, wrong invoice recipient or noncanonical cache', () => {
const input = soldInput();
const reject = (patch: Partial<typeof input>) => {
const changed = { ...structuredClone(input), ...patch };
const before = JSON.stringify(changed);
assert.throws(() => quoteRicheseBetrayalInvoice(changed), RicheseBetrayalError);
assert.equal(JSON.stringify(changed), before);
};
reject({ sourceCards: [] });
reject({ physicalCards: [] });
reject({ physicalCards: [input.quote.card, input.quote.card] });
reject({ sourceCards: [{ ...input.quote.card, name: 'Wrong face' }] });
reject({ originalRecipient: 'emperor' });
reject({ donor: 'holder' });
reject({ quote: { ...input.quote, ownPayment: 4 } });
reject({ quote: { ...input.quote, amount: 6 } });
const counterfeit = { ...input.quote.card, effect: 'counterfeit' };
reject({ quote: { ...input.quote, card: counterfeit }, sourceCards: [counterfeit], physicalCards: [counterfeit] });
reject({ lot: { ...input.lot, method: 'normal' } });
});
void test('exact contribution split binds a real reciprocal ally, not an unrelated funding donor', () => {
const input = soldInput('blackMarket', false, 5);
const invoice = quoteRicheseBetrayalInvoice(input);
assert.equal(invoice.ownPayment, 0);
assert.equal(invoice.allyPayment, invoice.price);
const unrelated = { ...input, donor: 'holder' };
assert.throws(() => quoteRicheseBetrayalInvoice(unrelated), /unallied contributor/);
const overspend = { ...input, quote: { ...input.quote, ownPayment: 0, allyPayment: 6 } };
assert.throws(() => quoteRicheseBetrayalInvoice(overspend), RicheseBetrayalError);
const fractional = { ...input, quote: { ...input.quote, ownPayment: 0.5, allyPayment: 4.5 } };
assert.throws(() => quoteRicheseBetrayalInvoice(fractional), RicheseBetrayalError);
});
void test('price boundaries preserve a whole positive invoice without overflow or zero-result reinterpretation', () => {
for (const price of [1, Number.MAX_SAFE_INTEGER]) {
const input = soldInput();
input.lot.outcome = { kind: 'sold', winner: 'buyer', amount: price };
input.quote.amount = price;
input.quote.ownPayment = price;
const invoice = quoteRicheseBetrayalInvoice(input);
assert.equal(invoice.price, price);
assert.equal(invoice.ownPayment, price);
}
for (const price of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const input = soldInput();
input.lot.outcome = { kind: 'sold', winner: 'buyer', amount: price };
input.quote.amount = price;
input.quote.ownPayment = price;
assert.throws(() => quoteRicheseBetrayalInvoice(input), RicheseBetrayalError);
}
});
void test('neutral responders depend on public held-presence and alliances, not private identity', () => {
const publicSeats = players.map(p => ({ ...p, nexusHeld: p.id === 'holder' || p.id === 'seller' }));
assert.deepEqual(requiredRicheseBetrayalResponders(publicSeats), ['holder']);
const relevant = cardsFor('richese');
const unrelated = cardsFor('guild');
assert.equal(richeseBetrayalEligibility({ players, cards: relevant }, 'holder'), null);
assert.match(richeseBetrayalEligibility({ players, cards: unrelated }, 'holder')!, /real Richese Nexus/);
// Both secret variants have identical held-presence and therefore neutral timing.
for (const cards of [relevant, unrelated]) {
assert.deepEqual(requiredRicheseBetrayalResponders(players.map(p => ({ ...p, nexusHeld: cards.hands[p.id] !== null }))), ['holder']);
}
const allied = players.map(p => ({ ...p,
...(p.id === 'holder' ? { ally: 'buyer' } : p.id === 'buyer' ? { ally: 'holder' } : {}),
}));
assert.deepEqual(requiredRicheseBetrayalResponders(allied.map(p => ({ ...p, nexusHeld: true }))), ['emperor']);
assert.match(richeseBetrayalEligibility({ players: allied, cards: relevant }, 'holder')!, /allied player/);
const native = cardsFor('richese');
native.hands.holder = null;
native.hands.seller = 'richese';
assert.match(richeseBetrayalEligibility({ players, cards: native }, 'seller')!, /another faction/);
});
void test('physical singleton Nexus custody and actual native roster determine eligibility', () => {
const cards = cardsFor('richese');
const duplicate = structuredClone(cards);
duplicate.deck.push('richese');
const before = JSON.stringify(duplicate);
assert.throws(() => richeseBetrayalEligibility({ players, cards: duplicate }, 'holder'), RicheseBetrayalError);
assert.equal(JSON.stringify(duplicate), before);
assert.throws(() => richeseBetrayalEligibility({ players: players.filter(p => p.id !== 'seller'), cards }, 'holder'), /native Richese/);
});
void test('completed history survives subsequent card custody and alliances, but preserves original invoice', () => {
const record = receipt();
const later = { turn: 8, players: players.map(p => ({ ...p,
...(p.id === 'holder' ? { ally: 'buyer' } : p.id === 'buyer' ? { ally: 'holder' } : {}),
})) };
// No current Treachery or Nexus zone is an input to intrinsic history validation.
validateRicheseBetrayalReceipt(later, JSON.parse(JSON.stringify(record)) as RicheseBetrayalReceipt);
assert.equal(record.invoice.originalRecipient, 'seller');
assert.equal(record.invoice.price, 5);
const changed = structuredClone(record);
changed.invoice.originalRecipient = null;
assert.throws(() => validateRicheseBetrayalReceipt(later, changed), RicheseBetrayalError);
assert.throws(() => createRicheseBetrayalReceipt(later, { ...record, invoice: changed.invoice }), RicheseBetrayalError);
});
void test('receipt construction detaches accepted physical and acknowledgement evidence', () => {
const input = receipt('pending');
const saved = createRicheseBetrayalReceipt(context, input);
input.invoice.card.name = 'Moved evidence';
input.invoice.price = 99;
input.required.push('emperor');
input.passed.length = 0;
assert.equal(saved.invoice.card.name, 'Ornithopter');
assert.equal(saved.invoice.price, 5);
assert.deepEqual(saved.required, ['holder', 'buyer']);
assert.deepEqual(saved.passed, ['buyer']);
});
void test('lifecycle rejects extra keys, impossible histories, stale pending and a double spend', () => {
const record = receipt();
const invalid = (patch: Partial<RicheseBetrayalReceipt>) => {
const input = { ...structuredClone(record), ...patch };
const before = JSON.stringify(input);
assert.throws(() => createRicheseBetrayalReceipt(context, input), RicheseBetrayalError);
assert.equal(JSON.stringify(input), before);
};
invalid({ actor: 'buyer' });
invalid({ actor: 'seller' });
invalid({ nexusDiscardIndex: -1 });
invalid({ sequence: 0 });
invalid({ turn: 3 });
invalid({ required: ['holder', 'holder'] });
invalid({ passed: ['emperor'] });
invalid({ stage: 'passed', actor: null, nexusDiscardIndex: null });
invalid({ stage: 'pending', actor: null, nexusDiscardIndex: null, passed: ['holder', 'buyer'] });
const extra = { ...record, rawAction: { type: 'bid' } };
assert.throws(() => createRicheseBetrayalReceipt(context, extra), RicheseBetrayalError);
assert.throws(() => validateRicheseBetrayalReceipt({ ...context, turn: 3 }, receipt('pending')), RicheseBetrayalError);
});
void test('independent cursor fails closed for deleted, orphaned, replayed or missing completed frames', () => {
const pending = receipt('pending');
const open = { sequence: 1, current: pending.event, completed: [] };
validateRicheseBetrayalCursor(context, open, pending);
assert.throws(() => validateRicheseBetrayalCursor(context, open, null), RicheseBetrayalError);
assert.throws(() => validateRicheseBetrayalCursor(context, { ...open, current: null }, pending), RicheseBetrayalError);
const completed = receipt('passed');
const closed = { sequence: 1, current: null, completed: [completed] };
validateRicheseBetrayalCursor(context, closed, null);
assert.throws(() => validateRicheseBetrayalCursor(context, { ...closed, completed: [] }, null), RicheseBetrayalError);
assert.throws(() => validateRicheseBetrayalCursor(context, { ...closed, completed: [pending] }, null), RicheseBetrayalError);
assert.throws(() => validateRicheseBetrayalCursor(context, { sequence: 2, current: null, completed: [completed, completed] }, null), RicheseBetrayalError);
});
+23
View File
@@ -278,3 +278,26 @@ void test('Kull preview starts a fresh ready CHOAM lobby once and preserves othe
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
void test('Richese Betrayal profile initializes one fresh paired Nexus game and preserves seats and other saves', (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'richese-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.status, 'setup');
assert.equal(saved.richeseBetrayalPreview, true);
assert.equal(saved.nexusCards!.cards!.deck.length, 12);
assert.equal(new Set(saved.nexusCards!.cards!.deck).size, 12);
assert.equal(saved.richeseCache?.length, 10);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'richese-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'richese-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
+15
View File
@@ -10,6 +10,7 @@ import {
} from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
function battle(advanced = false) {
let g = createGame(
@@ -220,3 +221,17 @@ void test('real Kull offer gives CHOAM public decision ownership before the susp
assert.equal(counter.response?.kind, 'choamWorthless');
assert.deepEqual(owners(counter), counter.players.map(() => null));
});
void test('neutral Richese Betrayal acknowledgement preempts an underlying buyer decision in every seat view', () => {
for (const kind of ['purchase', 'sale'] as const) {
const fixture = createRicheseBetrayalFixture(kind);
for (const player of fixture.game.players) {
const view = viewGame(fixture.game, player.id);
assert.ok(view.richeseBetrayalReaction);
assert.equal(tableActionOwner({
...view, active: fixture.buyer,
decision: { kind: 'auctionPayment', player: fixture.buyer },
}), null);
}
}
});