Connect Richese Nexus to Karama acquisitions

This commit is contained in:
Quintine committed 2026-10-25 23:14:17 +11:00
1 parent cf268243f3
commit 3bf7d26e80
18 files changed
+1513 -110

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createRicheseNormalBetrayalFixture,
actRicheseNormalBetrayal,
advanceRicheseNormalBetrayal,
nextRicheseNormalBetrayal,
passRicheseNormalBetrayal,
finishRicheseNormalBetrayal,
richeseNormalBetrayalClean,
richeseNormalBetrayalPlayer,
richeseNormalBetrayalInventory,
richeseNormalBetrayalNexusInventory,
type RicheseNormalBetrayalStep,
} from './fixture-richese-normal-betrayal';
import {
createNexusEmperorSellerFixture, nextNexusEmperorSellerStep,
} from './fixture-nexus-emperor-seller';
export type RicheseKaramaBetrayalStep = RicheseNormalBetrayalStep;
export const actRicheseKaramaBetrayal = actRicheseNormalBetrayal;
export const nextRicheseKaramaBetrayal = nextRicheseNormalBetrayal;
export const advanceRicheseKaramaBetrayal = advanceRicheseNormalBetrayal;
export const passRicheseKaramaBetrayal = passRicheseNormalBetrayal;
export const finishRicheseKaramaBetrayal = finishRicheseNormalBetrayal;
export const richeseKaramaBetrayalClean = richeseNormalBetrayalClean;
export const richeseKaramaBetrayalPlayer = richeseNormalBetrayalPlayer;
export const richeseKaramaBetrayalInventory = richeseNormalBetrayalInventory;
export const richeseKaramaBetrayalNexusInventory = richeseNormalBetrayalNexusInventory;
export function commitRicheseKaramaBetrayal(game: Game, fixture: { target: string; action: Action },
actions: RicheseKaramaBetrayalStep[] = []) {
const before = structuredClone(game), start = actions.length;
game = actRicheseKaramaBetrayal(game, fixture.target, fixture.action, actions);
return { game, before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
}
/** Both inputs fork real earlier snapshots of the original source programme.
* Ordinary uses its LABELLED recycled first normal lot before bids/cost.
* Quiet special branches before any controlled recycle; its selected cache
* card is still native original custody. No paused counter/cost is installed. */
export function createRicheseKaramaBetrayalFixture(options: {
origin?: 'ordinary' | 'special'; advanced?: boolean; richeseCard?: boolean;
parent?: 'quiet' | 'emperorIncome'; holderNexus?: 'richese' | 'guild'; otherBidder?: boolean;
} = {}) {
const origin = options.origin ?? 'ordinary', parent = options.parent ?? 'quiet';
const advanced = options.advanced ?? origin === 'special';
assert.ok(origin !== 'special' || advanced, 'Native private Richese special Karama requires Advanced');
assert.ok(parent === 'quiet' || origin === 'special', 'The incoming Emperor response belongs to the native special branch');
const f = createRicheseNormalBetrayalFixture({ advanced, richeseCard: options.richeseCard,
karama: true, holderNexus: options.holderNexus, allyPayment: parent === 'emperorIncome' ? 2 : 0 });
assert.ok(f.karama);
let game: Game, actions: RicheseKaramaBetrayalStep[];
if (origin === 'ordinary') {
game = structuredClone(f.beforeBids);
actions = structuredClone(f.actions.slice(0, f.actions.length - f.bidding.steps.length));
if (options.otherBidder) {
for (let count = 0; game.auction!.active !== f.holder && count < game.players.length; count++)
game = actRicheseKaramaBetrayal(game, game.auction!.active, { type: 'passBid' }, actions);
assert.equal(game.auction!.active, f.holder);
game = actRicheseKaramaBetrayal(game, f.holder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
}
} else if (parent === 'quiet') {
// Fork the actual final Nexus draw. Repeating only its normal phase-opening
// controls earns this branch's quiet Charity boundary and exact action log;
// no later controlled main-deck recycle or normal bid belongs to this source.
const lastDraw = f.nexusDraws.at(-1); assert.ok(lastDraw);
const drawStep = lastDraw.steps.at(-1); assert.ok(drawStep);
const drawAt = f.actions.findIndex(step => step.actor === drawStep.actor &&
JSON.stringify(step.action) === JSON.stringify(drawStep.action)); assert.ok(drawAt >= 0);
game = structuredClone(lastDraw.after);
actions = structuredClone(f.actions.slice(0, drawAt + 1));
game = advanceRicheseKaramaBetrayal(game, state => state.turn === 2 && state.phase === 2 &&
richeseKaramaBetrayalClean(state), actions);
assert.deepEqual(richeseKaramaBetrayalInventory(game), richeseKaramaBetrayalInventory(f.beforeRecycle));
assert.deepEqual(game.players.map(player => ({ id: player.id, hand: player.hand, spice: player.spice })),
f.beforeRecycle.players.map(player => ({ id: player.id, hand: player.hand, spice: player.spice })));
} else {
actions = structuredClone(f.actions);
game = passRicheseKaramaBetrayal(structuredClone(f.game), actions);
assert.equal(game.decision?.kind, 'auctionPayment');
game = actRicheseKaramaBetrayal(game, f.target, { type: 'decision', karama: false }, actions);
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.amount ?? game.auction?.bid, 3);
assert.equal(richeseKaramaBetrayalPlayer(game, f.target).spice, 4);
}
const cost = richeseKaramaBetrayalPlayer(game, f.target).hand.find(card => card.id === f.karama); assert.ok(cost);
assert.equal(cost.effect, 'karama');
assert.equal(richeseKaramaBetrayalPlayer(game, f.holder).ally, null);
assert.equal(game.nexusCards!.cards!.hands[f.holder], options.holderNexus ?? 'richese');
const chosen = origin === 'special'
? game.richeseCache!.find(card => card.id === 'richese-stone-burner')
: game.auction!.cards[game.auction!.index];
assert.ok(chosen); assert.notEqual(chosen.id, cost.id);
if (origin === 'ordinary') {
assert.equal(game.auction!.bid, options.otherBidder ? 1 : 0);
assert.equal(game.auction!.bidder, options.otherBidder ? f.holder : null);
assert.equal(chosen.id, f.card);
} else {
const offer = viewGame(game, f.target).richeseSpecialKarama;
assert.ok(offer && !offer.blocked && offer.cards.some(card => card.id === chosen.id));
assert.ok(!richeseKaramaBetrayalPlayer(game, f.target).specialKaramaUsed);
}
assert.ok(!game.pendingRicheseBetrayal);
assert.deepEqual(richeseKaramaBetrayalInventory(game), f.originalInventory);
assert.deepEqual(richeseKaramaBetrayalNexusInventory(game), f.originalNexusInventory);
const action: Action = origin === 'ordinary'
? { type: 'card', mode: 'purchase', card: cost.id }
: { type: 'card', mode: 'special', card: cost.id, acquire: chosen.id };
const source = structuredClone(game);
return {
game, source, beforeCost: source, origin, sourceKind: origin === 'ordinary' ? 'karama' : 'specialKarama',
parent, advanced, otherBidder: options.otherBidder ?? false,
target: f.target, buyer: f.target, richese: f.target, owner: f.target,
holder: f.holder, emperor: f.emperor, ally: f.ally,
card: chosen.id, acquire: chosen.id, purchaseCard: structuredClone(chosen),
karama: cost.id, costCard: structuredClone(cost), action, activationAction: action,
originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
originalInventory: f.originalInventory, originalCards: f.originalInventory,
originalNexusInventory: f.originalNexusInventory,
controlledRecycle: origin === 'ordinary' || parent === 'emperorIncome',
recycle: origin === 'ordinary' || parent === 'emperorIncome' ? f.recycle : null,
parentFixture: f, actions, steps: actions, sourceActionCount: actions.length,
producerActionCount: f.sourceActionCount,
staging: [
'Borrow the original Richese/CHOAM/Emperor/Atreides audit source with real main35/cache10, original base Karama dealt to Richese, actual cache Karama retention/first normal Shield purchase, later native Worm/alliance and actual CHOAM-held Richese Nexus. No dealt hand, wallet, alliance, force, phase, current reaction or Karama receipt is installed.',
origin === 'ordinary'
? 'Ordinary Karama source forks exact beforeBids: the actual first normal lot has zero bid/no bidder by default; optional otherBidder really lets CHOAM bid one before the declaration. The original base cost Karama is still held. The same explicitly CONTROLLED original Richese-Karama discard-to-unopened-main recycle supplies the family target; ordinary MISS instead uses the existing selected unopened ordinary face. Native card mode purchase is still undeclared.'
: parent === 'quiet'
? 'Private special source forks the actual final Nexus draw and completes only its original phase-opening controls to quiet Charity2, matching the earlier beforeRecycle physical hands/balances. No controlled main-deck recycle or later auction belongs to this source. The actual remaining Stone Burner cache card, three own spice and original base Karama remain unspent.'
: 'Native parent source really passes the prior normal Betrayal and pays the actual three-spice winning bid using one own plus two actually pledged ally spice. It stops with Richese four spice and the original EmperorIncome3 still pending. That prior normal lot retains its explicitly controlled recycled target. The new private cache purchase is undeclared; its original response must survive either result.',
'This four-seat branch does not claim a joint Emperor-Nexus special source: its original roster contains Emperor, while the existing joint-purchase profile explicitly requires Emperor absent. The separately exported canonical joint source retains its original Emperor-absent roster; no native card is swapped between these profiles.',
],
};
}
export type RicheseKaramaBetrayalFixture = ReturnType<typeof createRicheseKaramaBetrayalFixture>;
/** Original Emperor-absent canonical roster. The actual Emperor Nexus and
* Richese counter draw precede one native unbid-cache Karama retention. No
* ordinary recycle, held-card exchange or resource staging supplies this case. */
export function createRicheseJointKaramaBetrayalFixture() {
const f = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese', betrayal: 'before' });
const target = f.winner, holder = 'guild', draw = f.chronology.draw;
const drawAt = f.actions.findIndex(step => step.actor === draw.step.actor &&
JSON.stringify(step.action) === JSON.stringify(draw.step.action)); assert.ok(drawAt >= 0);
let game = structuredClone(draw.after);
const actions: RicheseKaramaBetrayalStep[] = structuredClone(f.actions.slice(0, drawAt + 1));
const act = (actor: string, action: Action) => { game = actRicheseKaramaBetrayal(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 600 && !until(game); count++) {
const d = game.decision;
if (!game.response && !game.phaseOpening && d?.kind === 'richeseDeclaration')
act(d.player, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
else {
const step = nextNexusEmperorSellerStep(game); act(step.actor, step.action);
}
}
assert.ok(until(game), 'The actual joint-source prefix must reach its next native control');
};
assert.equal(game.nexusCards!.cards!.hands[target], 'emperor');
assert.equal(game.nexusCards!.cards!.hands[holder], null);
const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('richese'); assert.ok(from >= 0);
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
const beforeCounterDraw = structuredClone(game), counterDrawStart = actions.length;
act(holder, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 });
const counterDraw = { before: beforeCounterDraw, steps: structuredClone(actions.slice(counterDrawStart)), after: structuredClone(game) };
reach(state => state.turn === 2 && state.phase === 2 && richeseKaramaBetrayalClean(state));
const afterClosingDraw = structuredClone(game);
let cost = richeseKaramaBetrayalPlayer(game, target).hand.find(card => card.effect === 'karama');
let costAcquisition: { before: Game; steps: RicheseKaramaBetrayalStep[]; after: Game } | null = null;
if (!cost) {
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
const chosen = game.richeseCache!.find(card => card.id === 'richese-karama'); assert.ok(chosen);
const before = structuredClone(game), start = actions.length;
act(target, { type: 'decision', event: game.richeseBidding!.event, card: chosen.id,
method: 'onceAround', direction: 'clockwise' });
for (let count = 0; game.decision?.kind !== 'richeseUnbid' && count < 20; count++) {
const lot = game.richeseAuction!; assert.ok(lot && lot.active && !game.response);
act(lot.active, { type: 'richeseBid', event: lot.event, amount: null });
}
assert.equal(game.decision?.kind, 'richeseUnbid');
act(target, { type: 'decision', event: game.richeseBidding!.event, keep: true });
reach(richeseKaramaBetrayalClean);
costAcquisition = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
cost = richeseKaramaBetrayalPlayer(game, target).hand.find(card => card.id === chosen.id); assert.ok(cost);
}
assert.ok(cost);
assert.ok(!game.players.some(player => player.faction === 'emperor'));
assert.equal(richeseKaramaBetrayalPlayer(game, target).ally, null);
assert.equal(richeseKaramaBetrayalPlayer(game, holder).ally, null);
assert.equal(game.nexusCards!.cards!.hands[target], 'emperor');
assert.equal(game.nexusCards!.cards!.hands[holder], 'richese');
const offer = viewGame(game, target).richeseSpecialKarama; assert.ok(offer && !offer.blocked);
// The borrowed native first-turn driver already retains Stone Burner. Choose
// another actual, presently eligible cache face rather than assuming it is
// still in the cache after that real acquisition.
const chosen = offer.cards.find(card => card.id !== cost.id); assert.ok(chosen);
assert.ok(game.richeseCache!.some(card => card.id === chosen.id));
assert.equal(offer.emperorNexus.blocked, null); assert.ok(offer.emperorNexus.event);
const action: Action = { type: 'card', mode: 'special', card: cost.id, acquire: chosen.id,
emperorNexus: true, nexusEvent: offer.emperorNexus.event };
const source = structuredClone(game), originalInventory = f.originalCards;
const originalNexusInventory = richeseKaramaBetrayalNexusInventory(game);
assert.deepEqual(richeseKaramaBetrayalInventory(game), originalInventory);
return {
game, source, beforeCost: source, origin: 'special' as const, sourceKind: 'specialKarama' as const,
advanced: true, parent: 'quiet' as const, jointEmperor: true,
target, buyer: target, richese: target, owner: target, holder, emperor: null,
card: chosen.id, acquire: chosen.id, purchaseCard: structuredClone(chosen),
karama: cost.id, costCard: structuredClone(cost), action, activationAction: action,
nexusEvent: offer.emperorNexus.event, beforeCounterDraw, counterDraw, afterClosingDraw, costAcquisition,
originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
originalInventory, originalCards: originalInventory, originalNexusInventory,
controlledRecycle: false, actions, steps: actions, sourceActionCount: actions.length,
parentFixture: f,
staging: [
'Fork the exact original Richese/CHOAM/Harkonnen/Fremen/Guild canonical audit prefix after Richese actually draws Emperor Nexus at the second-turn worm. CHOAM/Fremen alliance is native; Richese and Guild remain unallied. Select only the still-unopened Richese Nexus draw for Guild, then finish actual closing/opening controls.',
'If no original held Karama exists, declare the actual next cache Richese-Karama lot using Once Around; all original bidders pass and Richese really keeps that unbid cache card. Its original cache decreases and its real hand gains the physical cost. No dealt card, wallet, alliance, phase, Nexus or paused receipt is installed; no controlled recycled normal lot supplies this branch.',
`Stop before declaring the native joint special action with its actual private Emperor Nexus event. The held Karama, held Emperor singleton, three own spice proof, selected remaining original cache card ${chosen.id} and once-use remain unspent. The original first-turn driver already retained Stone Burner through its real unbid choice; the live private special-purchase view selects this different eligible cache card. The distinct Guild-held Richese singleton supplies the real pre-cost counter.`,
],
};
}
+5 -2
View File
@@ -88,16 +88,19 @@ void test('original Richese Nexus profiles compose, but unsupported full overlay
const skills = original();
skills.g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
assert.equal(quoteEmperorNexusSpecialPurchase(skills.g, 'r').blocked, null);
const independent = original();
independent.g.ecazTreachery = true;
assert.equal(quoteEmperorNexusSpecialPurchase(independent.g, 'r').blocked, null,
'The selected E1/E2 independent-card profile already supports native purchases.');
for (const change of [
(g: Game) => { g.advancedPreview = true; },
(g: Game) => { g.ecazTreachery = true; },
(g: Game) => { g.sandtrout = true; },
(g: Game) => { g.nexusCards = undefined; },
(g: Game) => { g.expansions = ['choam', 'ix', 'ecaz']; },
]) {
const { g, activation, acquire } = original();
change(g);
assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked);
assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked, change.toString());
assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id),
EmperorNexusPurchaseError);
}
@@ -0,0 +1,156 @@
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, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type GameView } from '../game/engine';
import { botNexusRicheseBetrayalActions } from '../game/bot-nexus-richese-betrayal';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
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 };
},
});
const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
/** Projection overrides isolate real controls and policies. Native direct
* acquisition/cost settlement belongs to the separate engine producer cases. */
function projected(source: 'karama' | 'specialKarama'): GameView {
const fixture = createRicheseBetrayalFixture('purchase');
const game = viewGame(fixture.game, fixture.holder);
assert.ok(game.richeseBetrayalReaction);
game.richeseBetrayalReaction.source = source;
game.richeseBetrayalReaction.price = source === 'karama' ? 0 : 3;
return game;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, busy, act() {} }));
}
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;
}
function choose(game: GameView, prefix: string, busy = false): Action[] {
const actions: Action[] = [];
click(NexusRicheseBetrayal({ game, busy, act(action) { actions.push(action); } }), prefix);
return actions;
}
void test('proactive ordinary Karama is an intended zero-spice acquisition with an explicit blind commitment', () => {
const game = projected('karama');
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /before acquisition/);
assert.match(html, /intends to acquire the concealed ordinary auction lot using ordinary Karama/);
assert.match(html, /No spice would be paid/);
assert.doesNotMatch(html, /\bwon\b|for 0 spice|buying its own cache card|concealed Black Market lot/);
assert.match(html, /Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped/);
assert.match(html, /Otherwise your Nexus remains spent and the original purchase continues/);
assert.match(html, /Provisional order: Betrayal resolves before Richese spends the costs of this intended acquisition/);
assert.match(html, /intended card is discarded without acquisition and Richese keeps its unplayed activation Karama/);
assert.match(html, /Spend Richese Nexus · attempt to stop this purchase/);
assert.match(html, /Passing lets the declared acquisition continue/);
assert.doesNotMatch(html, /refund|once-per-game special use remains available/);
}
});
void test('native special purchase stays private and explains unspent Karama, three spice and once-use', () => {
const game = projected('specialKarama');
const html = markup(game);
assert.match(html, /intends to acquire a privately selected Richese cache card using its native special Karama/);
assert.match(html, /listed price is 3 spice; the chosen face remains private/);
assert.match(html, /intended card is discarded without acquisition and Richese keeps its unplayed activation Karama/);
assert.match(html, /three spice are not paid and its once-per-game special use remains available/);
assert.match(html, /Use Richese Nexus · stop this private acquisition/);
assert.doesNotMatch(html, /\bwon\b|Provisional online rule: spend|Otherwise your Nexus remains spent|refund|Emperor Nexus|joint funding/);
game.richeseBetrayalReaction!.price = 0;
assert.equal(markup(game), html, 'The public listed-price explanation does not identify a private funding method.');
});
void test('both actual acquisition buttons send the unchanged single Use and Pass callbacks', () => {
for (const source of ['karama', 'specialKarama'] as const) {
const game = projected(source), event = game.richeseBetrayalReaction!.event;
assert.deepEqual(choose(game, source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus'),
[{ type: 'richeseBetrayalUse', event }]);
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
}
});
void test('busy and private eligibility restrictions prevent spending but preserve Pass and persistent source guidance', () => {
for (const source of ['karama', 'specialKarama'] as const) {
const game = projected(source), prefix = source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus';
assert.match(markup(game, true), /disabled=""[^>]*>(?:Spend|Use) Richese Nexus/);
assert.deepEqual(choose(game, prefix, true), []);
assert.deepEqual(choose(game, 'Pass', true), []);
game.richeseBetrayalReaction!.blocked = 'Your accepted promise reserves this Nexus.';
assert.match(markup(game), /accepted promise reserves this Nexus/);
assert.match(markup(game), /Provisional order/);
assert.deepEqual(choose(game, prefix), []);
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event: game.richeseBetrayalReaction!.event }]);
game.richeseBetrayalReaction!.blocked = null;
game.richeseBetrayalReaction!.canUse = false;
assert.deepEqual(choose(game, prefix), []);
game.richeseBetrayalReaction!.hasPassed = true;
assert.deepEqual(choose(game, 'Pass'), []);
game.richeseBetrayalReaction!.hasPassed = false;
game.richeseBetrayalReaction!.canPass = false;
assert.deepEqual(choose(game, prefix), []);
game.richeseBetrayalReaction!.canPass = true;
game.richeseBetrayalReaction!.canUse = true;
game.status = 'finished';
assert.deepEqual(choose(game, prefix), []);
}
});
void test('neither intended source reads an auction face, private cache, wallet or Nexus funding', () => {
for (const source of ['karama', 'specialKarama'] as const) {
const game = projected(source);
Object.defineProperty(game, 'auction', { get() { throw new Error('unentitled ordinary face'); } });
Object.defineProperty(game, 'richeseAuction', { get() { throw new Error('unrelated public cache lot'); } });
for (const player of game.players)
for (const field of ['hand', 'spice', 'richeseCache'])
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
assert.match(markup(game), /before acquisition/);
assert.deepEqual(choose(game, source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus'),
[{ type: 'richeseBetrayalUse', event: game.richeseBetrayalReaction!.event }]);
}
});
void test('all profiles pass blind Karama but may use the certain special veto only through private projected eligibility', () => {
for (const profile of DIFFICULTIES)
for (const source of ['karama', 'specialKarama'] as const) {
const game = projected(source), own = game.players.find(player => player.id === game.me)!;
own.bot = profile;
const event = game.richeseBetrayalReaction!.event;
const expected = [{ type: source === 'karama' ? 'richeseBetrayalPass' : 'richeseBetrayalUse', event }];
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
assert.deepEqual(botActions(game), expected);
delete own.bot; own.autopilot = profile;
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
game.richeseBetrayalReaction!.canUse = false;
assert.deepEqual(botNexusRicheseBetrayalActions(game), [{ type: 'richeseBetrayalPass', event }]);
game.richeseBetrayalReaction!.hasPassed = true;
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
}
});
aliases.deregister();
+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Card } from '../game/cards';
import type { NexusPlayer } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import {
createRicheseBetrayalReceipt, quoteRicheseKaramaBetrayalInvoice,
richeseBetrayalEvent, richeseBetrayalVetoesPurchase,
validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt,
RicheseBetrayalError, type RicheseBetrayalInvoice,
} from '../game/nexus-richese-betrayal';
const players: NexusPlayer[] = [
{ id: 'richese', faction: 'richese' },
{ id: 'holder', faction: 'fremen' },
{ id: 'emperor', faction: 'emperor' },
];
const costCard: Card = { id: 'cost-karama', name: 'Karama', kind: 'special', effect: 'karama' };
const ordinary: Card = { id: 'normal-weapon', name: 'Maula Pistol', kind: 'projectile' };
const spare: Card = { id: 'spare-source', name: 'Shield', kind: 'shield' };
type KaramaInput = Parameters<typeof quoteRicheseKaramaBetrayalInvoice>[0];
function purchaseInput(source: KaramaInput['source'] = 'karama', card: Card = richeseCards()[0]): KaramaInput {
return {
players: players.map(player => ({ ...player })), owner: 'richese', source,
card: { ...card }, costCard: { ...costCard }, ownerCards: [{ ...costCard }],
sourceCards: [{ ...card }, { ...spare }],
physicalCards: [{ ...costCard }, { ...card }, { ...spare }], originalRecipient: 'emperor',
};
}
function usedReceipt(invoice: RicheseBetrayalInvoice) {
return createRicheseBetrayalReceipt({ turn: 2, players }, {
event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1,
round: 'round-2', lot: 'lot-2', invoice, required: ['holder'], passed: [],
stage: 'used', actor: 'holder', nexusDiscardIndex: 0,
});
}
function reject(input: KaramaInput) {
const before = JSON.stringify(input);
assert.throws(() => quoteRicheseKaramaBetrayalInvoice(input), RicheseBetrayalError);
assert.equal(JSON.stringify(input), before);
}
void test('pre-cost Karama invoices preserve the declared nominal price and clone the selected card', () => {
for (const [source, price] of [['karama', 0], ['specialKarama', 3]] as const) {
const input = purchaseInput(source);
const before = JSON.stringify(input);
const invoice = quoteRicheseKaramaBetrayalInvoice(input);
assert.deepEqual(invoice, {
kind: 'purchase', target: 'richese', buyer: 'richese', source,
price, ownPayment: price, allyPayment: 0, donor: null,
originalRecipient: 'emperor', card: input.card,
});
assert.equal(JSON.stringify(input), before);
assert.notEqual(invoice.card, input.card);
assert.notEqual(invoice.card, input.sourceCards[0]);
invoice.card.name = 'Changed output';
assert.equal(JSON.stringify(input), before);
assert.deepEqual(input.ownerCards, [costCard]);
}
});
void test('both nominal sources retain the original Emperor-or-bank recipient without creating a payment', () => {
for (const source of ['karama', 'specialKarama'] as const) {
const bank = purchaseInput(source);
bank.players = bank.players.filter(player => player.id !== 'emperor');
bank.originalRecipient = null;
assert.equal(quoteRicheseKaramaBetrayalInvoice(bank).originalRecipient, null);
const wrongRecipient = purchaseInput(source);
wrongRecipient.originalRecipient = 'holder';
reject(wrongRecipient);
const missingEmperor = purchaseInput(source);
missingEmperor.originalRecipient = null;
reject(missingEmperor);
}
});
void test('ordinary Karama admits either hidden family while special Karama binds a canonical cache card', () => {
for (const [card, hit] of [[richeseCards()[0], true], [ordinary, false]] as const) {
const input = purchaseInput('karama', card);
const before = JSON.stringify(input);
const invoice = quoteRicheseKaramaBetrayalInvoice(input);
assert.equal(invoice.source, 'karama');
assert.equal(invoice.price, 0);
assert.equal(richeseBetrayalVetoesPurchase(invoice), hit);
assert.equal(JSON.stringify(input), before);
}
assert.equal(richeseBetrayalVetoesPurchase(quoteRicheseKaramaBetrayalInvoice(purchaseInput('specialKarama'))), true);
reject(purchaseInput('specialKarama', ordinary));
reject(purchaseInput('specialKarama', { ...richeseCards()[0], name: 'Counterfeit Richese face' }));
});
void test('the cost stays held and distinct from one exact selected source card', () => {
const sameCard = purchaseInput();
sameCard.card = { ...sameCard.costCard };
reject(sameCard);
const noHeldCost = purchaseInput();
noHeldCost.ownerCards = [];
reject(noHeldCost);
const duplicateHeldCost = purchaseInput();
duplicateHeldCost.ownerCards = [costCard, { ...costCard }];
reject(duplicateHeldCost);
const substitutedCost = purchaseInput();
substitutedCost.ownerCards = [{ ...costCard, name: 'Different held face' }];
reject(substitutedCost);
const noSource = purchaseInput();
noSource.sourceCards = [spare];
reject(noSource);
const duplicateSource = purchaseInput();
duplicateSource.sourceCards = [duplicateSource.card, { ...duplicateSource.card }];
reject(duplicateSource);
const substitutedSource = purchaseInput();
substitutedSource.sourceCards = [{ ...substitutedSource.card, name: 'Different source face' }];
reject(substitutedSource);
for (const id of [costCard.id, richeseCards()[0].id]) {
const absent = purchaseInput();
absent.physicalCards = absent.physicalCards.filter(card => card.id !== id);
reject(absent);
const duplicated = purchaseInput();
duplicated.physicalCards = [...duplicated.physicalCards, { ...duplicated.physicalCards.find(card => card.id === id)! }];
reject(duplicated);
}
});
void test('Karama purchase quotes reject foreign owners, wrong producer names and malformed physical faces', () => {
for (const owner of ['', 'holder', 'unknown']) {
const input = purchaseInput();
input.owner = owner;
reject(input);
}
const wrongSource = purchaseInput();
wrongSource.source = 'normal' as KaramaInput['source'];
reject(wrongSource);
const malformedCost = purchaseInput();
malformedCost.costCard = { ...costCard, kind: 'invalid' } as unknown as Card;
reject(malformedCost);
const malformedSource = purchaseInput();
malformedSource.card = { ...ordinary, id: '' };
reject(malformedSource);
});
void test('receipt validation keeps zero and three prices exclusive to their original Karama source', () => {
for (const source of ['karama', 'specialKarama'] as const) {
const invoice = quoteRicheseKaramaBetrayalInvoice(purchaseInput(source));
const invalid: RicheseBetrayalInvoice[] = [
...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, source === 'karama' ? 3 : 0].map(price => ({
...invoice, price, ownPayment: price,
})),
{ ...invoice, kind: 'sale', buyer: 'holder', originalRecipient: 'richese' },
{ ...invoice, allyPayment: 1, ownPayment: invoice.price - 1, donor: 'emperor' },
];
for (const changed of invalid) assert.throws(() => usedReceipt(changed), RicheseBetrayalError);
}
const legacyZero = { ...quoteRicheseKaramaBetrayalInvoice(purchaseInput()), source: 'normal' as const };
assert.throws(() => usedReceipt(legacyZero), RicheseBetrayalError);
});
void test('a blind ordinary Karama miss remains valid used history with one committed Nexus cost', () => {
for (const [card, hit] of [[richeseCards()[0], true], [ordinary, false]] as const) {
const receipt = usedReceipt(quoteRicheseKaramaBetrayalInvoice(purchaseInput('karama', card)));
assert.equal(receipt.stage, 'used');
assert.equal(receipt.nexusDiscardIndex, 0);
assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), hit);
validateRicheseBetrayalReceipt({ turn: 3, players }, JSON.parse(JSON.stringify(receipt)));
validateRicheseBetrayalCursor({ turn: 3, players }, {
sequence: 1, current: null, completed: [receipt],
}, null);
assert.throws(() => validateRicheseBetrayalReceipt({ turn: 3, players }, {
...receipt, nexusDiscardIndex: null,
}), RicheseBetrayalError);
}
});
void test('all older source receipt bytes remain unchanged by the new Karama source names', () => {
for (const source of ['cache', 'blackMarket', 'normal'] as const) {
const invoice: RicheseBetrayalInvoice = source === 'blackMarket' ? {
kind: 'sale', target: 'richese', buyer: 'emperor', source,
price: 5, ownPayment: 5, allyPayment: 0, donor: null,
originalRecipient: 'richese', card: { ...ordinary },
} : {
kind: 'purchase', target: 'richese', buyer: 'richese', source,
price: 5, ownPayment: 5, allyPayment: 0, donor: null,
originalRecipient: 'emperor', card: richeseCards()[0],
};
const receipt = usedReceipt(invoice);
const event = JSON.stringify(['nexusRicheseBetrayal', 2, 1, 'round-2', 'lot-2']);
assert.equal(receipt.signature, JSON.stringify([
'richeseBetrayalReceipt', 1, event, 2, 1, 'round-2', 'lot-2',
invoice.kind, 'richese', invoice.buyer, source, 5, 5, 0, null, invoice.originalRecipient,
[invoice.card.id, invoice.card.name, invoice.card.kind, invoice.card.effect ?? null],
['holder'], [], 'used', 'holder', 0,
]));
}
});
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { quoteAuctionContinuation, type AuctionContinuationInput } from '../game/auction-continuation-quote';
import {
quoteNormalAuctionRetirement, NormalAuctionRetirementError,
type NormalAuctionRetirementInput,
} from '../game/normal-auction-retirement';
function fixture(): NormalAuctionRetirementInput {
const cards = baseDeck().slice(0, 3);
const held = baseDeck()[3];
return {
turn: 2, advanced: false, order: ['r', 'a', 'i', 'g'],
players: [
{ id: 'r', faction: 'richese', spice: 5, hand: [], handLimit: 4 },
{ id: 'a', faction: 'atreides', spice: 5, hand: [], handLimit: 4 },
{ id: 'i', faction: 'ixians', spice: 5, hand: [held], handLimit: 4 },
{ id: 'g', faction: 'guild', spice: 5, hand: [], handLimit: 4 },
],
physicalCards: [...cards, held],
auction: { cards, index: 0, bid: 0, bidder: null, active: 'r', passed: [], opener: 0 },
};
}
function round(input: NormalAuctionRetirementInput) {
input.richeseBidding = {
owner: 'r', event: 'round-2', turn: 2, stage: 'normal', position: 'last',
normalCount: 3, blackMarketSold: false, cacheCanceled: false,
};
}
function paid(input: NormalAuctionRetirementInput): AuctionContinuationInput {
return {
...input, status: 'playing', phase: 3,
auction: { ...input.auction!, bid: 4, bidder: 'r' },
};
}
function reject(input: NormalAuctionRetirementInput) {
const before = JSON.stringify(input);
assert.throws(() => quoteNormalAuctionRetirement(input), NormalAuctionRetirementError);
assert.equal(JSON.stringify(input), before);
}
void test('unbought retirement advances the original pool without a bidder, sale or payment', () => {
const input = fixture(), before = structuredClone(input);
const next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.auction.index, 1);
assert.equal(next.auction.bid, 0);
assert.equal(next.auction.bidder, null);
assert.equal(next.auction.allyPayment, 0);
assert.equal(next.active, 'a');
assert.deepEqual(next.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
assert.equal(Object.hasOwn(next, 'sale'), false);
assert.deepEqual(input, before);
next.auction.cards[1].name = 'Changed output';
assert.deepEqual(input, before);
});
void test('retirement rotates the physical opener rather than another seat currently leading the bid', () => {
const input = fixture();
input.auction = { ...input.auction!, bid: 5, bidder: 'g', active: 'i', passed: ['r', 'a'] };
const next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.active, 'a');
assert.equal(next.auction.opener, 1);
assert.equal(next.auction.bidder, null);
});
void test('next-lot retirement skips full hands, removes Sapho and clears the prior inspection', () => {
const input = fixture();
input.players = input.players.map(player => player.id === 'a'
? { ...player, hand: [{ id: 'a-full' }], handLimit: 1 } : player);
input.auction = { ...input.auction!, saphoOrder: ['g', 'i', 'a', 'r'], peekKnown: true,
occupiedEvent: 'original-occupied-event' };
const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.active, 'i');
assert.equal(next.auction.opener, 2);
assert.deepEqual(next.auction.passed, []);
assert.equal(next.auction.peekKnown, false);
assert.equal(Object.hasOwn(next.auction, 'saphoOrder'), false);
assert.equal(next.auction.occupiedEvent, 'original-occupied-event');
assert.deepEqual(input, before);
});
void test('exhaustion keeps the 7 October last-cache stock and cancellation rule', () => {
for (const [stock, canceled, after] of [[1, false, 'richeseCache'], [0, false, 'phase'], [1, true, 'phase']] as const) {
const input = fixture();
round(input);
input.auction!.index = 2;
input.richeseCacheCount = stock;
input.richeseBidding!.cacheCanceled = canceled;
assert.deepEqual(quoteNormalAuctionRetirement(input), {
kind: 'normalEnd', returned: [], after, completeRound: after === 'phase',
});
}
const ordinary = fixture();
ordinary.auction!.index = 2;
assert.deepEqual(quoteNormalAuctionRetirement(ordinary), {
kind: 'normalEnd', returned: [], after: 'phase', completeRound: false,
});
});
void test('full hands return only the untouched remainder as independent physical snapshots', () => {
const input = fixture();
input.players = input.players.map(player => ({ ...player, hand: [{ id: `${player.id}-full` }], handLimit: 1 }));
const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalEnd');
if (next.kind !== 'normalEnd') throw new Error('No seat can buy the remaining cards.');
assert.deepEqual(next.returned, input.auction!.cards.slice(1));
next.returned[0].name = 'Changed output';
assert.deepEqual(input, before);
});
void test('next inspection offers retain Advanced Ix priority and ordinary Atreides fallback', () => {
const input = fixture();
input.advanced = true;
const ix = quoteNormalAuctionRetirement(input);
assert.equal(ix.kind, 'normalLot');
if (ix.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(ix.offer, { kind: 'ixTechnology', player: 'i' });
input.ixTechnologyTurn = input.turn;
const atreides = quoteNormalAuctionRetirement(input);
assert.equal(atreides.kind, 'normalLot');
if (atreides.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(atreides.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
input.players = input.players.map(player => player.id === 'a' ? { ...player, faction: 'beneGesserit' } : player);
const neither = quoteNormalAuctionRetirement(input);
assert.equal(neither.kind, 'normalLot');
if (neither.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(neither.offer, null);
});
void test('the existing paid continuation delegates unchanged and preserves declared-cost hand projection', () => {
const input = paid(fixture());
assert.deepEqual(quoteAuctionContinuation(input, { kind: 'next' }).next, quoteNormalAuctionRetirement(input));
const cost = { id: 'r-last-slot' };
input.players = input.players.map(player => ({ ...player, hand: [player.id === 'r' ? cost : { id: `${player.id}-full` }], handLimit: 1 }));
const before = structuredClone(input);
const next = quoteAuctionContinuation(input, { kind: 'next' }, { player: 'r', card: cost.id }).next;
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('Spending the real card opens the original slot.');
assert.equal(next.active, 'r');
assert.deepEqual(input, before);
const ixInput = paid(fixture());
ixInput.advanced = true;
const ixCard = ixInput.players.find(player => player.id === 'i')!.hand[0].id;
const afterIxCost = quoteAuctionContinuation(ixInput, { kind: 'next' }, { player: 'i', card: ixCard }).next;
assert.equal(afterIxCost.kind, 'normalLot');
if (afterIxCost.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(afterIxCost.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
});
void test('retirement rejects invalid source indices, order, hand limits and remaining physical custody', () => {
for (const index of [-1, 3, 0.5, NaN, Infinity]) {
const input = fixture();
input.auction!.index = index;
reject(input);
}
const badOpener = fixture();
badOpener.auction!.opener = 4;
reject(badOpener);
const nullBidderPrice = fixture();
nullBidderPrice.auction!.bid = 1;
reject(nullBidderPrice);
const duplicateOrder = fixture();
duplicateOrder.order = ['r', 'r'];
reject(duplicateOrder);
const noHandLimit = fixture();
noHandLimit.players = noHandLimit.players.map(player => player.id === 'r' ? { ...player, handLimit: 0 } : player);
reject(noHandLimit);
const noPhysical = fixture();
noPhysical.physicalCards = noPhysical.physicalCards.filter(card => card.id !== noPhysical.auction!.cards[1].id);
reject(noPhysical);
const duplicatePhysical = fixture();
duplicatePhysical.physicalCards = [...duplicatePhysical.physicalCards, duplicatePhysical.auction!.cards[1]];
reject(duplicatePhysical);
const duplicateCurrent = fixture();
duplicateCurrent.auction!.cards[1] = duplicateCurrent.auction!.cards[0];
reject(duplicateCurrent);
});
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createRicheseKaramaBetrayalFixture as fixture,
createRicheseJointKaramaBetrayalFixture as jointFixture,
actRicheseKaramaBetrayal as act,
commitRicheseKaramaBetrayal as commit,
passRicheseKaramaBetrayal as pass,
finishRicheseKaramaBetrayal as finish,
richeseKaramaBetrayalPlayer as player,
richeseKaramaBetrayalInventory as inventory,
richeseKaramaBetrayalNexusInventory as nexusInventory,
type RicheseKaramaBetrayalFixture,
} from './fixture-richese-karama-betrayal';
const wallets = (g: Game) => Object.fromEntries(g.players.map(p => [p.id, p.spice]));
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
function open(f: Pick<RicheseKaramaBetrayalFixture, 'game' | 'action' | 'target' | 'origin' | 'sourceKind' |
'card' | 'karama' | 'holder' | 'originalInventory' | 'originalNexusInventory'>) {
const g = commit(f.game, f).game, receipt = g.pendingRicheseBetrayal?.receipt; assert.ok(receipt);
assert.equal(receipt.invoice.source, f.sourceKind); assert.equal(receipt.invoice.card.id, f.card);
assert.equal(receipt.invoice.buyer, f.target); assert.equal(receipt.invoice.price, f.origin === 'ordinary' ? 0 : 3);
assert.deepEqual(wallets(g), wallets(f.game));
assert.ok(player(g, f.target).hand.some(card => card.id === f.karama));
assert.equal(player(g, f.target).hand.some(card => card.id === f.card), false);
assert.equal(g.discard.some(card => card.id === f.karama), false);
assert.equal(!!player(g, f.target).specialKaramaUsed, false);
assert.equal(g.nexusCards!.cards!.hands[f.holder], 'richese');
assert.equal(viewGame(g, f.holder).richeseBetrayalReaction!.canUse, true);
assert.deepEqual(inventory(g), f.originalInventory); assert.deepEqual(nexusInventory(g), f.originalNexusInventory);
return { game: g, event: receipt.event, use: { type: 'richeseBetrayalUse', event: receipt.event } as Action };
}
function conserved(g: Game, f: Pick<RicheseKaramaBetrayalFixture, 'originalInventory' | 'originalNexusInventory'>) {
assert.ok(!g.pendingRicheseBetrayal); assert.equal(g.richeseBetrayal!.current, null);
assert.deepEqual(inventory(g), f.originalInventory); assert.deepEqual(nexusInventory(g), f.originalNexusInventory);
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} ordinary Karama Richese-family veto happens before its original physical cost`, () => {
const f = fixture({ advanced }), pending = open(f), before = wallets(f.game), index = f.game.auction!.index;
assert.equal(f.controlledRecycle, true); assert.notEqual(f.karama, f.card);
assert.equal(f.sourceActionCount, f.actions.length);
const done = finish(act(pending.game, f.holder, pending.use));
assert.deepEqual(wallets(done), before);
assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
assert.equal(done.discard.some(card => card.id === f.karama), false);
assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(done, f.target).hand.some(card => card.id === f.card), false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
assert.equal(done.auction!.index, index + 1); assert.ok(!done.currentAuctionSale);
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); conserved(done, f);
});
void test(`${mode} ordinary Karama MISS spends its Nexus then the actual Karama and acquires free once`, () => {
const f = fixture({ advanced, richeseCard: false }), pending = open(f), before = wallets(f.game);
const done = finish(act(pending.game, f.holder, pending.use));
assert.deepEqual(wallets(done), before);
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
assert.equal(done.discard.some(card => card.id === f.card), false);
assert.equal(!!player(done, f.target).specialKaramaUsed, false);
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); conserved(done, f);
});
void test(`${mode} ordinary Karama all-pass retains the Nexus and completes its native free acquisition`, () => {
const f = fixture({ advanced }), pending = open(f), before = wallets(f.game);
const done = finish(pass(pending.game));
assert.deepEqual(wallets(done), before);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed'); conserved(done, f);
});
}
void test('an actual competing ordinary bid is retained unpaid through the pre-cost Karama choice', () => {
for (const use of [false, true]) {
const f = fixture({ advanced: true, otherBidder: true }), pending = open(f), before = wallets(f.game);
assert.equal(f.game.auction!.bidder, f.holder); assert.equal(f.game.auction!.bid, 1);
const done = finish(use ? act(pending.game, f.holder, pending.use) : pass(pending.game));
assert.deepEqual(wallets(done), before);
assert.equal(player(done, f.target).hand.some(card => card.id === f.karama), use);
assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !use);
assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), false); conserved(done, f);
}
});
void test('native private special veto discards only the selected cache face before Karama, spice and once-use costs', () => {
const f = fixture({ origin: 'special' }), pending = open(f), before = wallets(f.game), cache = f.game.richeseCache!;
assert.equal(f.game.phase, 2); assert.equal(f.controlledRecycle, false);
const done = finish(act(pending.game, f.holder, pending.use));
assert.deepEqual(wallets(done), before);
assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
assert.equal(!!player(done, f.target).specialKaramaUsed, false);
assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(done.richeseCache!.map(card => card.id), cache.filter(card => card.id !== f.card).map(card => card.id));
assert.equal(done.discard.some(card => card.id === f.karama), false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
assert.ok(!done.pendingRichesePurchaseIncome); assert.ok(!done.currentAuctionSale); conserved(done, f);
});
void test('native private special all-pass pays three, disposes the original Karama and settles native Emperor income once', () => {
const f = fixture({ origin: 'special' }), pending = open(f), before = wallets(f.game);
const committed = pass(pending.game);
assert.equal(player(committed, f.target).spice, before[f.target] - 3);
assert.equal(player(committed, f.target).specialKaramaUsed, true);
// Spending the only opposing Karama leaves no income cancellation actor;
// native automatic response settlement has already credited the Emperor.
assert.ok(!committed.response); assert.ok(!committed.pendingRichesePurchaseIncome);
assert.equal(player(committed, f.emperor).spice, before[f.emperor] + 3);
const done = finish(committed);
assert.equal(player(done, f.target).spice, before[f.target] - 3);
assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
assert.equal(done.richeseCache!.length, f.game.richeseCache!.length - 1);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
});
for (const use of [false, true]) void test(
`native private special ${use ? 'veto' : 'allowance'} preserves the already pending ordinary Emperor income parent`, () => {
const f = fixture({ origin: 'special', parent: 'emperorIncome' }), pending = open(f), before = wallets(f.game);
assert.equal(f.game.response?.kind, 'emperorIncome');
assert.equal(f.game.currentAuctionSale?.amount ?? f.game.auction?.bid, 3);
assert.equal(player(f.game, f.target).spice, 4);
const committed = use ? act(pending.game, f.holder, pending.use) : pass(pending.game);
if (use) {
assert.deepEqual(committed.response, f.originalResponse);
assert.equal(player(committed, f.target).spice, 4);
assert.ok(player(committed, f.target).hand.some(card => card.id === f.karama));
assert.equal(!!player(committed, f.target).specialKaramaUsed, false);
} else {
// The preceding paid auction really acquired another Karama, so the
// child income still has a cancellation actor and saves its parent.
assert.equal(committed.response?.kind, 'richesePurchaseIncome');
assert.equal(committed.response?.amount, 3);
assert.deepEqual(committed.pendingRichesePurchaseIncome!.resume.response, f.originalResponse);
assert.equal(player(committed, f.emperor).spice, before[f.emperor]);
assert.equal(player(committed, f.target).spice, 1);
}
const done = finish(committed);
assert.equal(player(done, f.emperor).spice, before[f.emperor] + (use ? 3 : 6));
assert.equal(player(done, f.target).spice, use ? 4 : 1);
assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !use);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!use));
assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
},
);
void test('all four existing counter policies act legally on both ordinary faces and the private special source', () => {
for (const options of [{ richeseCard: true }, { richeseCard: false }, { origin: 'special' as const }]) {
const f = fixture(options), pending = open(f);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(pending.game, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action, difficulty);
assert.ok(action.type === 'richeseBetrayalPass' || action.type === 'richeseBetrayalUse');
assert.equal(action.event, pending.event);
const done = finish(act(pending.game, f.holder, action)); conserved(done, f);
}
}
});
void test('pre-cost Karama counters reject underlying resubmission and stale owner replies without changing physical costs', () => {
for (const origin of ['ordinary', 'special'] as const) {
const f = fixture({ origin }), pending = open(f);
reject(pending.game, f.target, f.action);
reject(pending.game, f.target, pending.use);
reject(pending.game, f.holder, { ...pending.use, event: `${pending.event}:stale` });
const done = finish(act(pending.game, f.holder, pending.use));
reject(done, f.holder, pending.use);
assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
conserved(done, f);
}
});
for (const use of [false, true]) void test(
`native joint Emperor-Nexus private special ${use ? 'veto preserves both original costs' : 'allowance spends both costs and retains proved spice'}`, () => {
const f = jointFixture(), before = wallets(f.game), pending = open(f);
assert.equal(f.controlledRecycle, false); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.game.players.some(player => player.faction === 'emperor'), false);
assert.equal(f.game.nexusCards!.cards!.hands[f.target], 'emperor');
assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'richese');
if (f.costAcquisition) {
assert.ok(f.costAcquisition.steps.some(step => step.actor === f.target && step.action.card === f.karama));
assert.ok(f.costAcquisition.steps.some(step => step.actor === f.target && step.action.keep === true));
}
const historyBefore = f.game.nexusEmperorPurchaseHistory?.length ?? 0;
const done = finish(use ? act(pending.game, f.holder, pending.use) : pass(pending.game));
assert.deepEqual(wallets(done), before);
assert.equal(player(done, f.target).hand.some(card => card.id === f.karama), use);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!use));
assert.equal(done.nexusCards!.cards!.hands[f.target], use ? 'emperor' : null);
assert.equal(done.nexusCards!.cards!.hands[f.holder], use ? null : 'richese');
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, Number(!use));
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(use));
assert.equal(!!player(done, f.target).specialKaramaUsed, !use);
assert.equal(done.nexusEmperorPurchaseHistory?.length ?? 0, historyBefore + Number(!use));
assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
},
);