Implement bounded Richese Nexus Betrayal auctions
This commit is contained in:
1 parent
a921c0da9b
commit
9c0000e469
25 files changed
+2208
-39
No files matched your search
@@ -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), []);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user