feat: support provisional paid Black Market retention

This commit is contained in:
Quintine committed 2026-10-18 03:21:44 +11:00
1 parent 7891517a9b
commit e70cd48172
21 files changed
+862 -151

No files matched your search

+5 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import type { RicheseAuctionMethod } from '../game/richese-auction';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
@@ -14,6 +15,8 @@ export type RicheseBureaucratOptions = {
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
emperor?: boolean;
counter?: boolean;
method?: RicheseAuctionMethod;
direction?: 'clockwise' | 'counterclockwise';
};
export type RicheseBureaucratStep = { actor: string; action: Action };
export function richeseBureaucratPlayer(game: Game, id: string) {
@@ -89,7 +92,8 @@ export function createRicheseBureaucratFixture(options: RicheseBureaucratOptions
const choose = (values: Record<string, unknown>) => act(donor,
{ type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
if (advanced) {
if (source === 'blackMarket') choose({ card: richeseBureaucratPlayer(game, donor).hand[0].id, method: 'silent' });
if (source === 'blackMarket') choose({ card: richeseBureaucratPlayer(game, donor).hand[0].id,
method: options.method ?? 'silent', direction: options.direction ?? 'clockwise' });
else choose({ decline: true });
}
if (source === 'cache') {
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
import {
allowRicheseBureaucratResponses, richeseBureaucratCards, richeseBureaucratPlayer,
} from './fixture-bureaucrat-richese-funding';
/** Original Advanced setup and purchases, not a full-hand state injection.
* Only the conserved, unopened turn-two worm is moved to the next Spice reveal. */
export function createRicheseFullHandSelfPurchaseGame(options: {
skills?: boolean;
emperor?: boolean;
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
} = {}): Game {
const emperor = options.emperor !== false, owner = options.owner ?? 'beneGesserit';
const roster: FactionId[] = ['richese', 'atreides', 'beneGesserit', emperor ? 'emperor' : 'guild'];
assert.ok(roster.includes(owner));
const training: Partial<Record<FactionId, LeaderSkillId>> = {
richese: 'smuggler', atreides: 'planetologist', beneGesserit: 'warmaster',
[emperor ? 'emperor' : 'guild']: 'spice-banker', [owner]: 'bureaucrat',
};
if (owner === 'emperor') training.beneGesserit = 'spice-banker';
let game = createIndependentCardModulesFixture({ advanced: true, factions: roster, expansions: ['choam'],
skills: options.skills ?? true, training, independent: false, tech: false }).game;
const stock = richeseBureaucratCards(game);
assert.equal(stock.length, 45);
assert.equal(new Set(stock).size, stock.length);
const original = richeseBureaucratPlayer(game, 'richese').hand.map(c => c.id);
assert.equal(original.length, 1);
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 5);
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
const choose = (values: Record<string, unknown>) => act('richese',
{ type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
const next = () => {
if (game.turn === 1 && !game.response && !game.phaseOpening) {
if (game.decision?.kind === 'richeseBlackMarket') { choose({ decline: true }); return; }
if (game.decision?.kind === 'richeseDeclaration') { choose({ position: 'first' }); return; }
if (game.decision?.kind === 'richeseCache') {
choose({ card: game.richeseCache![0].id, method: 'silent' }); return;
}
if (game.decision?.kind === 'auctionPayment') {
act(game.decision.player, { type: 'decision', karama: false }); return;
}
if (!game.decision && game.richeseAuction && !game.richeseAuction.outcome) {
const lot = game.richeseAuction;
assert.equal(lot.source, 'cache');
const actor = lot.order.find(id => !lot.acted.includes(id));
assert.ok(actor);
act(actor, { type: 'richeseBid', event: lot.event, amount: actor === 'richese' ? 1 : 0 });
return;
}
if (!game.decision && game.auction) {
const actor = game.auction.active;
act(actor, actor === 'richese' && richeseBureaucratPlayer(game, actor).hand.length < 4
? { type: 'bid', amount: 1 } : { type: 'passBid' });
return;
}
}
if (game.phase === 5 && homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' });
else {
const step = nextHomeworldEcazStep(game);
assert.ok(step, `Missing original full-hand action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(step.actor, step.action);
}
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; !until(game) && n < 800; n++) next();
assert.ok(until(game), 'Reach the original full-hand purchase boundary');
};
act('atreides', { type: 'bribe', target: 'richese', amount: 4 });
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 5, 'A promised bribe is not spendable');
reach(g => g.phase === 3 && g.richeseBidding?.stage === 'normal' && homeworldEcazClean(g));
assert.equal(game.richeseBidding!.position, 'first');
assert.equal(richeseBureaucratPlayer(game, 'richese').hand.length, 2);
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 4);
reach(g => g.phase === 4 && homeworldEcazClean(g));
const full = richeseBureaucratPlayer(game, 'richese');
assert.equal(full.hand.length, handLimit(full));
assert.equal(full.hand.length, 4);
assert.equal(full.hand[0].id, original[0]);
assert.equal(full.spice, 2, 'The cache and two ordinary acquisitions each cost one');
reach(g => g.phase === 8 && homeworldEcazClean(g));
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 6, 'Only first Mentat collects the four-spice bribe');
reach(g => g.turn === 2 && g.phase === 1 && homeworldEcazClean(g));
const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
assert.ok(worm >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
act('richese', { type: 'alliance', target: 'atreides' });
act('atreides', { type: 'alliance', target: 'richese' });
reach(g => g.phase === 3 && g.decision?.kind === 'richeseBlackMarket');
choose({ card: richeseBureaucratPlayer(game, 'richese').hand[0].id, method: 'silent' });
game = allowRicheseBureaucratResponses(game);
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
assert.equal(game.richeseAuction.acted.length, 0);
assert.equal(richeseBureaucratPlayer(game, 'richese').hand.length, 4);
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 6);
assert.equal(richeseBureaucratPlayer(game, 'richese').ally, 'atreides');
assert.equal(richeseBureaucratPlayer(game, 'atreides').ally, 'richese');
assert.deepEqual(richeseBureaucratCards(game), stock);
return game;
}
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
import type { RicheseAuctionMethod } from '../game/richese-auction';
import {
allowRicheseBureaucratResponses, createRicheseBureaucratFixture,
richeseBureaucratCards, richeseBureaucratPlayer,
} from './fixture-bureaucrat-richese-funding';
export type RicheseSelfPurchaseOptions = {
method?: RicheseAuctionMethod;
direction?: 'clockwise' | 'counterclockwise';
price?: number;
allyPayment?: number;
emperor?: boolean;
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
counter?: boolean;
/** An actual original open Black Market lot; never reflag/redeal played state. */
initial?: Game;
};
export function createRicheseSelfPurchaseFixture(options: RicheseSelfPurchaseOptions = {}) {
const method = options.method ?? options.initial?.richeseAuction?.method ?? 'silent';
let game = options.initial ? structuredClone(options.initial)
: createRicheseBureaucratFixture({ source: 'blackMarket', pledge: 0,
method, direction: options.direction, emperor: options.emperor, owner: options.owner }).game;
game = allowRicheseBureaucratResponses(game);
assert.equal(game.phase, 3);
assert.ok(game.richeseAuction && game.richeseAuction.source === 'blackMarket');
assert.equal(game.richeseAuction.method, method);
assert.equal(game.richeseAuction.outcome, null);
assert.equal(game.richeseAuction.acted.length, 0);
const seller = game.richeseAuction.owner;
const player = richeseBureaucratPlayer(game, seller);
assert.equal(player.faction, 'richese');
const card = game.richeseAuction.cardId;
assert.equal(player.hand.filter(c => c.id === card).length, 1);
const trainer = game.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat')?.owner;
const banker = game.leaderSkills?.assignments.find(a => a.skill === 'spice-banker')?.owner;
const emperor = game.players.find(p => p.faction === 'emperor')?.id;
const counterActor = 'beneGesserit';
let counterCard: string | undefined;
if (options.counter) {
const actor = richeseBureaucratPlayer(game, counterActor), held = actor.hand.find(c => c.effect === 'karama');
if (held) counterCard = held.id;
else {
const at = game.deck.findIndex(c => c.effect === 'karama');
assert.ok(at >= 0 && actor.hand.length > 0);
const original = actor.hand[0]; actor.hand[0] = game.deck[at]; game.deck[at] = original;
counterCard = actor.hand[0].id;
}
}
const price = options.price ?? 5, allyPayment = options.allyPayment ?? 0;
const donor = player.ally;
const beforePledge = structuredClone(game), actions: { actor: string; action: Action }[] = [];
if (allyPayment) {
assert.ok(donor, 'Use the original real worm ally, never unpledged funding');
const action: Action = { type: 'pledgeAid', amount: allyPayment };
actions.push({ actor: donor, action }); game = applyAction(game, donor, action);
}
const beforeAction = structuredClone(game);
const action: Action = { type: 'richeseBid', event: game.richeseAuction!.event, amount: price, allyPayment };
return { game, beforePledge, beforeAction, actions, action, seller, payer: seller, donor,
method, price, allyPayment, primary: price - allyPayment, card, trainer, banker, emperor,
counterActor, counterCard, handLimit: handLimit(player),
originalHand: player.hand.map(c => c.id), physical: richeseBureaucratCards(game) };
}
export type RicheseSelfPurchaseFixture = ReturnType<typeof createRicheseSelfPurchaseFixture>;
/** Real method opportunities only. Once Around needs a positive other bid to
* reach the seller's final raise; all-zero keeps its original unsold shortcut. */
export function payRicheseSelfPurchase(f: RicheseSelfPurchaseFixture,
options: { openingBid?: number } = {}): Game {
let game = f.game;
const act = (actor: string, action: Action) => {
f.actions.push({ actor, action }); game = applyAction(game, actor, action);
};
if (f.method === 'silent') {
const order = game.richeseAuction!.order;
act(f.seller, f.action);
for (const actor of order) if (actor !== f.seller)
act(actor, { type: 'richeseBid', event: f.action.event, amount: 0, allyPayment: 0 });
} else {
const openingBid = options.openingBid ?? (f.method === 'onceAround' && f.price >= 2 ? 1 : 0);
let opened = false;
for (let n = 0; game.richeseAuction && !game.richeseAuction.outcome && n < 80; n++) {
const actor = game.richeseAuction.active;
assert.ok(actor, 'The original ordered auction must retain a bidder');
if (actor === f.seller) {
act(actor, { ...f.action, amount: f.price > 0 ? f.price : null });
} else {
const amount = !opened && openingBid > 0 ? openingBid : null;
if (amount !== null) opened = true;
act(actor, { type: 'richeseBid', event: f.action.event, amount, allyPayment: 0 });
}
}
}
assert.ok(!game.richeseAuction || game.richeseAuction.outcome, 'Complete only original auction opportunities');
return game;
}
@@ -132,7 +132,6 @@ void test('personal-price, exact source, full hand and physical singleton corrup
const { g, input } = original('blackMarket');
g.advanced = false;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
assert.throws(() => original('blackMarket', true), /Black Market self-purchase/);
});
void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
-15
View File
@@ -163,21 +163,6 @@ void test('all profiles skip optional Black Market and legally complete its norm
}
});
void test('seller policies never emit the unsupported positive Black Market self-bid', () => {
for (const difficulty of DIFFICULTIES)
for (const method of ['normal', 'onceAround', 'silent'] as const) {
const g = offer(method, 'blackMarket', true);
if (method !== 'silent') g.richeseAuction!.active = 'r';
const action = botActions(projected(g, 'r', difficulty))[0];
assert.deepEqual(action, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount: method === 'silent' ? 0 : null,
allyPayment: 0,
});
assert.doesNotThrow(() => applyAction(g, 'r', action));
}
});
void test('unfunded bidders pass, sealed bidders act once, and hidden opponent state cannot affect the bid', () => {
for (const difficulty of DIFFICULTIES) {
+1 -8
View File
@@ -272,7 +272,7 @@ void test('Atreides inspection grants only its entitled view and survives JSON b
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(g.players[0].hand[0].id, card.id);
});
void test('connected cache-cancel restores the ordinary pool while seller self-bid rejects atomically', () => {
void test('connected cache cancellation restores the ordinary pool', () => {
const before = fixture();
const k = before.deck.find((c) => c.effect === 'karama')!;
before.players[2].hand = [k];
@@ -288,13 +288,6 @@ void test('connected cache-cancel restores the ordinary pool while seller self-b
assert.equal(canceled.players[2].hand.some((c) => c.id === k.id), false);
assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1);
assert.deepEqual(g, snapshot, 'the source declaration is unchanged by the trial cancellation');
const bm = fixture(true);
bm.players[0].hand = [bm.deck.shift()!];
g = begin(bm);
g = decision(g, { card: g.players[0].hand[0].id, method: 'silent' });
const exact = structuredClone(g);
assert.throws(() => bid(g, 'r', 1), /self-bid/);
assert.deepEqual(g, exact);
});
void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => {
+216
View File
@@ -0,0 +1,216 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
allowRicheseBureaucratResponses, chooseRicheseBureaucrat,
richeseBureaucratCards, richeseBureaucratPlayer,
} from './fixture-bureaucrat-richese-funding';
import {
createRicheseSelfPurchaseFixture, payRicheseSelfPurchase,
type RicheseSelfPurchaseFixture,
} from './fixture-richese-self-purchase';
import { nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
// Provisional shared Richese payment inheritance, not an official ruling on
// positive seller participation. The common producer retains the native setup,
// real Mentat bribe/worm alliance and conserved original counter-card swap.
function retained(game: Game, f: RicheseSelfPurchaseFixture) {
assert.deepEqual(richeseBureaucratPlayer(game, f.seller).hand.map(c => c.id), f.originalHand,
'Paid retention neither removes/reappends the offered card nor makes an acquisition');
assert.equal(richeseBureaucratPlayer(game, f.seller).hand.filter(c => c.id === f.card).length, 1);
for (const player of game.players)
assert.deepEqual(player.hand.map(c => c.id), richeseBureaucratPlayer(f.beforeAction, player.id).hand.map(c => c.id));
assert.deepEqual(richeseBureaucratCards(game), f.physical);
assert.deepEqual(game.richeseCache, f.beforeAction.richeseCache);
}
function rejectUnchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected input preserves custody, wallets, prepaid aid and the original opportunity');
}
function emperorAllowance(game: Game, f: RicheseSelfPurchaseFixture) {
retained(game, f);
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.response!.owner, f.emperor);
assert.ok(viewGame(game, f.counterActor).responseControls?.cancelCards.includes(f.counterCard!));
assert.equal(viewGame(game, f.trainer!).bureaucrat.pending, null,
'Native Emperor Karama allowance precedes any optional Bureaucrat diversion');
assert.equal(richeseBureaucratPlayer(game, f.emperor!).spice,
richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
assert.equal(richeseBureaucratPlayer(game, f.seller).spice,
richeseBureaucratPlayer(f.beforeAction, f.seller).spice - f.primary);
assert.equal(game.currentAuctionSale!.winner, f.seller);
assert.equal(game.currentAuctionSale!.seller, f.seller);
assert.equal(game.currentAuctionSale!.origin, 'blackMarket');
assert.equal(game.currentAuctionSale!.amount, f.price);
assert.equal(game.currentAuctionSale!.free, false);
const invoice = game.currentAuctionSale!.bureaucratPayment!;
assert.equal(invoice.payer, f.seller);
assert.equal(invoice.payee, f.emperor);
assert.equal(invoice.amount, f.price);
}
function declaration(game: Game, f: RicheseSelfPurchaseFixture, sold: boolean) {
retained(game, f);
assert.equal(game.phase, 3);
assert.equal(game.richeseBidding!.blackMarketSold, sold);
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.decision!.player, f.seller);
assert.equal(game.currentAuctionSale, null);
assert.equal(game.richeseAuction, null);
assert.equal(game.response, null);
}
void test('provisional Normal own5 retention pays Emperor once, permits native Karama then Bureaucrat2, and resumes original normal controls', () => {
const f = createRicheseSelfPurchaseFixture({ method: 'normal', price: 5, emperor: true, counter: true });
let game = f.game;
for (let n = 0; game.richeseAuction!.active !== f.seller && n < game.players.length; n++) {
const actor = game.richeseAuction!.active!;
game = applyAction(game, actor, { type: 'richeseBid', event: f.action.event, amount: null });
}
assert.equal(game.richeseAuction!.active, f.seller);
rejectUnchanged(game, f.seller, { ...f.action, event: game.richeseBidding!.event });
rejectUnchanged(game, f.seller, { ...f.action, amount: 6 });
game = payRicheseSelfPurchase({ ...f, game });
emperorAllowance(game, f);
game = allowRicheseBureaucratResponses(game);
assert.equal(game.decision?.kind, 'bureaucratPayment');
const event = game.bureaucratPaymentEvent!;
for (const player of game.players) {
const pending = viewGame(game, player.id).bureaucrat.pending!;
assert.equal(pending.payee, f.emperor);
assert.equal(pending.amount, 5);
assert.equal(pending.payer, f.seller);
}
game = chooseRicheseBureaucrat(game, true);
declaration(game, f, true);
for (const player of game.players) {
const delta = -Number(player.id === f.seller) * 5
+ Number(player.id === f.emperor) * 3;
assert.equal(player.spice, richeseBureaucratPlayer(f.beforeAction, player.id).spice + delta);
}
assert.equal(viewGame(game, f.trainer!).bureaucrat.usedThisPhase, true);
rejectUnchanged(game, f.seller, f.action);
rejectUnchanged(game, f.trainer!, { type: 'decision', event, redirect: true });
const normalCount = game.richeseBidding!.normalCount!;
game = applyAction(game, f.seller,
{ type: 'decision', event: game.richeseBidding!.event, position: 'last' });
game = allowRicheseBureaucratResponses(game);
assert.equal(game.richeseBidding!.stage, 'normal');
assert.equal(game.auction!.cards.length, normalCount);
const next = nextHomeworldEcazStep(game);
assert.ok(next);
assert.equal(next.action.type, 'passBid');
assert.equal(next.actor, game.auction!.active, 'Use the original next controller, not a fixed Storm opener');
game = applyAction(game, next.actor, next.action);
retained(game, f);
assert.equal(game.richeseBidding!.blackMarketSold, true);
assert.equal(game.richeseBidding!.normalCount, normalCount, 'Continuing the auction does not count retention twice');
});
void test('provisional Once Around outbids a real1 with R5/ally3, while all-other-pass remains free unsold without an owner opportunity', () => {
const f = createRicheseSelfPurchaseFixture({ method: 'onceAround', direction: 'counterclockwise',
price: 8, allyPayment: 3, emperor: true, counter: true });
const free = payRicheseSelfPurchase({ ...f, actions: [] }, { openingBid: 0 });
declaration(free, f, false);
for (const player of free.players)
assert.equal(player.spice, richeseBureaucratPlayer(f.beforeAction, player.id).spice);
assert.equal(free.aid[f.donor!].amount, 3, 'Free unsold retention consumes none of the real prepaid aid');
assert.equal(viewGame(free, f.trainer!).bureaucrat.usedThisPhase, false);
rejectUnchanged(free, f.seller, f.action);
let game = f.game;
const openingActor = game.richeseAuction!.active!;
assert.notEqual(openingActor, f.seller);
game = applyAction(game, openingActor, { type: 'richeseBid', event: f.action.event, amount: 1, allyPayment: 0 });
assert.equal(game.richeseAuction!.bidder, openingActor);
assert.equal(game.richeseAuction!.bid, 1);
for (let n = 0; game.richeseAuction!.active !== f.seller && n < game.players.length; n++)
game = applyAction(game, game.richeseAuction!.active!, { type: 'richeseBid', event: f.action.event, amount: null });
assert.equal(game.richeseAuction!.active, f.seller);
rejectUnchanged(game, openingActor, { type: 'richeseBid', event: f.action.event, amount: 2 });
rejectUnchanged(game, f.seller, { ...f.action, amount: 9 });
rejectUnchanged(game, f.seller, { ...f.action, allyPayment: 4 });
game = applyAction(game, f.seller, f.action);
emperorAllowance(game, f);
assert.equal(game.aid[f.donor!].amount, 0);
assert.deepEqual(game.currentAuctionSale!.bureaucratPayment!.contributions,
[{ payer: f.seller, amount: 5 }, { payer: f.donor!, amount: 3 }]);
game = allowRicheseBureaucratResponses(game);
assert.equal(game.decision?.kind, 'bureaucratPayment');
const source = game.bureaucratPayments!.pending!.source;
assert.equal(source.payee, f.emperor);
assert.equal(source.amount, 8);
for (const player of game.players) {
const pending = viewGame(game, player.id).bureaucrat.pending!;
assert.equal(pending.payer, null, 'Actual allied payment shares remain private');
assert.equal(pending.payee, f.emperor);
assert.equal(pending.amount, 8, 'Public payment is the whole original price');
}
game = chooseRicheseBureaucrat(game, false);
declaration(game, f, true);
for (const player of game.players) {
const delta = -Number(player.id === f.seller) * 5 - Number(player.id === f.donor) * 3
+ Number(player.id === f.emperor) * 8;
assert.equal(player.spice, richeseBureaucratPlayer(f.beforePledge, player.id).spice + delta,
'Only actual retained funding is paid; the outbid bidder is not charged and Richese is not refunded');
}
assert.equal(game.aid[f.donor!].amount, 0);
assert.equal(viewGame(game, f.trainer!).bureaucrat.usedThisPhase, false);
assert.equal(free.richeseBidding!.normalCount, game.richeseBidding!.normalCount! + 1,
'Exactly the positive sale, not free retention, removes one ordinary lot');
rejectUnchanged(game, f.seller, f.action);
game = applyAction(game, f.seller,
{ type: 'decision', event: game.richeseBidding!.event, position: 'first' });
game = allowRicheseBureaucratResponses(game);
assert.equal(game.decision?.kind, 'richeseCache');
assert.equal(game.decision!.player, f.seller);
assert.equal(game.richeseBidding!.blackMarketSold, true);
assert.equal(game.auction, null, 'Cache-first continues to the actual cache choice before dealing ordinary cards');
retained(game, f);
});
void test('original no-Emperor Nexus table pays for retained ownership without inventing a free seller acquisition', () => {
let game = createGame('RETAINNEXUS', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game = applyAction(game, game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, game.host, { type: 'start', advancedPreview: true });
for (let n = 0; game.decision?.kind !== 'richeseBlackMarket' && n < 300; n++) {
let step: { actor: string; action: Action } | undefined;
if (game.status === 'setup') {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
} else step = nextHomeworldEcazStep(game) ?? undefined;
assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.decision?.kind, 'richeseBlackMarket');
const card = richeseBureaucratPlayer(game, 'richese').hand[0].id;
game = applyAction(game, 'richese', { type: 'decision', event: game.richeseBidding!.event, card, method: 'silent' });
game = allowRicheseBureaucratResponses(game);
const before = structuredClone(game), event = game.richeseAuction!.event;
game = applyAction(game, 'richese', { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
for (const actor of before.richeseAuction!.order) if (actor !== 'richese')
game = applyAction(game, actor, { type: 'richeseBid', event, amount: 0 });
game = allowRicheseBureaucratResponses(game);
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.richeseBidding!.blackMarketSold, true);
assert.equal(richeseBureaucratPlayer(game, 'richese').spice, richeseBureaucratPlayer(before, 'richese').spice - 1);
for (const player of game.players) {
assert.deepEqual(player.hand, richeseBureaucratPlayer(before, player.id).hand);
if (player.id !== 'richese') assert.equal(player.spice, richeseBureaucratPlayer(before, player.id).spice);
}
assert.deepEqual(game.nexusCards, before.nexusCards);
assert.deepEqual(richeseBureaucratCards(game), richeseBureaucratCards(before));
});
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import {
allowRicheseBureaucratResponses, richeseBureaucratCards, richeseBureaucratPlayer,
} from './fixture-bureaucrat-richese-funding';
import {
createRicheseSelfPurchaseFixture, payRicheseSelfPurchase,
} from './fixture-richese-self-purchase';
import { createRicheseFullHandSelfPurchaseGame } from './fixture-richese-self-purchase-full-hand';
import {
advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep,
} from './fixture-spice-banker-income';
function rejectUnchanged(game: Game, actor: string, action: Action): void {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before);
}
void test('provisional Silent paid retention admits a full seller, seals its bid, and reduces the real ordinary pool once unlike zero retention', () => {
const initial = createRicheseFullHandSelfPurchaseGame();
const f = createRicheseSelfPurchaseFixture({ initial, price: 1, counter: true });
const seller = richeseBureaucratPlayer(f.beforeAction, f.seller);
assert.equal(seller.hand.length, handLimit(seller));
assert.ok(f.game.richeseAuction!.eligible.includes(f.seller));
assert.equal(f.game.players.filter(p => p.hand.length < handLimit(p)).length, 3);
let sealed = applyAction(f.game, f.seller, f.action);
assert.deepEqual(richeseBureaucratPlayer(sealed, f.seller).hand.map(c => c.id), f.originalHand);
assert.equal(richeseBureaucratPlayer(sealed, f.seller).spice, seller.spice);
for (const player of sealed.players) {
const auction = viewGame(sealed, player.id).richeseAuction!;
assert.equal(auction.ownBid, player.id === f.seller ? 1 : null);
assert.equal(auction.revealedBids, null);
assert.equal(auction.bid, 0);
assert.equal(auction.bidder, null);
assert.equal(auction.cardId, player.id === f.seller ? f.card : null);
assert.ok(auction.submitted.includes(f.seller));
}
rejectUnchanged(sealed, f.seller, f.action);
for (const actor of sealed.richeseAuction!.order.filter(id => id !== f.seller))
sealed = applyAction(sealed, actor, { type: 'richeseBid', event: f.action.event, amount: 0 });
assert.equal(sealed.response?.kind, 'emperorIncome');
for (const player of sealed.players)
assert.equal(viewGame(sealed, player.id).richeseAuction!.revealedBids![f.seller], 1);
const paid = allowRicheseBureaucratResponses(sealed);
assert.deepEqual(richeseBureaucratPlayer(paid, f.seller).hand.map(c => c.id), f.originalHand);
assert.equal(richeseBureaucratPlayer(paid, f.seller).hand.filter(c => c.id === f.card).length, 1);
assert.equal(richeseBureaucratPlayer(paid, f.seller).spice, seller.spice - 1);
assert.equal(richeseBureaucratPlayer(paid, f.emperor!).spice,
richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice + 1);
assert.equal(paid.decision?.kind, 'richeseDeclaration');
assert.equal(paid.richeseAuction, null);
assert.equal(paid.richeseBidding!.blackMarketSold, true);
assert.equal(paid.richeseBidding!.normalCount, 1);
assert.deepEqual(richeseBureaucratCards(paid), f.physical);
rejectUnchanged(paid, f.seller, f.action);
const ordinary = allowRicheseBureaucratResponses(applyAction(paid, f.seller,
{ type: 'decision', event: paid.richeseBidding!.event, position: 'last' }));
assert.equal(ordinary.auction!.cards.length, 1);
assert.notEqual(ordinary.auction!.active, f.seller);
assert.deepEqual(richeseBureaucratCards(ordinary), f.physical);
const zero = createRicheseSelfPurchaseFixture({ initial, price: 0 });
const retained = allowRicheseBureaucratResponses(payRicheseSelfPurchase(zero));
assert.equal(retained.richeseBidding!.blackMarketSold, false);
assert.equal(retained.richeseBidding!.normalCount, 2);
assert.equal(richeseBureaucratPlayer(retained, zero.seller).spice, seller.spice);
assert.deepEqual(richeseBureaucratPlayer(retained, zero.seller).hand.map(c => c.id), zero.originalHand);
const unsoldOrdinary = allowRicheseBureaucratResponses(applyAction(retained, zero.seller,
{ type: 'decision', event: retained.richeseBidding!.event, position: 'last' }));
assert.equal(unsoldOrdinary.auction!.cards.length, 2);
assert.deepEqual(richeseBureaucratCards(unsoldOrdinary), zero.physical);
});
void test('bare Advanced without Skills earns the same full hand and pays five for original-card retention', () => {
const initial = createRicheseFullHandSelfPurchaseGame({ skills: false });
const f = createRicheseSelfPurchaseFixture({ initial, price: 5, counter: true });
const done = allowRicheseBureaucratResponses(payRicheseSelfPurchase(f));
assert.equal(done.phase, 3);
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.equal(richeseBureaucratPlayer(done, f.seller).spice,
richeseBureaucratPlayer(f.beforeAction, f.seller).spice - 5);
assert.equal(richeseBureaucratPlayer(done, f.emperor!).spice,
richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice + 5);
assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
assert.equal(done.richeseBidding!.normalCount, 1);
assert.deepEqual(richeseBureaucratCards(done), f.physical);
});
void test('full-hand allied three plus five: original Emperor Karama banks two payer legs, never offers Bureaucrat, and defers only one Banker spice', () => {
const f = createRicheseSelfPurchaseFixture({ initial: createRicheseFullHandSelfPurchaseGame(),
price: 8, allyPayment: 5, counter: true });
const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
const bankerBalance = richeseBureaucratPlayer(f.beforeAction, f.banker!).spice;
const used = structuredClone(f.beforeAction.bureaucratPayments?.used ?? []);
const income = payRicheseSelfPurchase(f);
assert.equal(income.response?.kind, 'emperorIncome');
assert.deepEqual(richeseBureaucratPlayer(income, f.seller).hand.map(c => c.id), f.originalHand);
assert.equal(richeseBureaucratPlayer(income, f.seller).spice,
richeseBureaucratPlayer(f.beforePledge, f.seller).spice - 3);
assert.equal(richeseBureaucratPlayer(income, f.donor!).spice,
richeseBureaucratPlayer(f.beforePledge, f.donor!).spice - 5);
assert.equal(viewGame(income, f.trainer!).bureaucrat.pending, null);
assert.deepEqual(viewGame(income, f.banker!).spiceBankerIncome!.deferred, []);
assert.ok(viewGame(income, f.counterActor).responseControls!.cancelCards.includes(f.counterCard!));
const canceled = applyAction(income, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
const done = allowRicheseBureaucratResponses(canceled);
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.equal(done.richeseBidding!.normalCount, 1);
assert.equal(richeseBureaucratPlayer(done, f.emperor!).spice,
richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
assert.equal(richeseBureaucratPlayer(done, f.banker!).spice, bankerBalance);
assert.equal(viewGame(done, f.trainer!).bureaucrat.pending, null);
assert.deepEqual(done.bureaucratPayments?.used ?? [], used);
assert.equal(viewGame(done, f.trainer!).bureaucrat.usedThisPhase, false);
const grants = done.spiceBankerIncome!.sources.slice(sourceCount).filter(row => row.grant);
assert.equal(grants.length, 1);
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.seller, amount: 3 }, { payer: f.donor!, amount: 5 }]);
assert.deepEqual(viewGame(done, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
assert.equal(done.aid[f.donor!]?.amount ?? 0, 0);
assert.deepEqual(done.richeseFunding, {});
assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
assert.deepEqual(richeseBureaucratCards(done), f.physical);
assert.equal(done.discard.filter(c => c.id === f.counterCard).length, 1);
rejectUnchanged(done, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
let beforeMentat = applyAction(done, f.seller,
{ type: 'decision', event: done.richeseBidding!.event, position: 'last' });
for (let n = 0; (beforeMentat.phase !== 7 || beforeMentat.phaseOpening || beforeMentat.response || beforeMentat.decision) && n < 400; n++) {
const step = nextSpiceBankerIncomeNativeStep(beforeMentat);
assert.ok(step);
beforeMentat = applyAction(beforeMentat, step.actor, step.action);
}
assert.equal(beforeMentat.phase, 7);
assert.equal(richeseBureaucratPlayer(beforeMentat, f.banker!).spice, bankerBalance);
assert.deepEqual(viewGame(beforeMentat, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
rejectUnchanged(beforeMentat, f.banker!, { type: 'bribe', target: f.seller, amount: bankerBalance + 1 });
const collected = advanceSpiceBankerIncomeToMentat(beforeMentat);
assert.equal(richeseBureaucratPlayer(collected, f.banker!).spice, bankerBalance + 1);
assert.deepEqual(viewGame(collected, f.banker!).spiceBankerIncome!.deferred, []);
const collection = collected.spiceBankerIncome!.collections.at(-1)!;
assert.deepEqual(collection.credits, [{ owner: f.banker, amount: 1 }]);
assert.equal(collection.grants.filter(cursor => cursor === grants[0].cursor).length, 1);
assert.deepEqual(richeseBureaucratCards(collected), f.physical);
});
void test('without Emperor the full seller pays five to the Bank, not itself, and the actual Guild Banker receives one deferred credit', () => {
const f = createRicheseSelfPurchaseFixture({ initial: createRicheseFullHandSelfPurchaseGame({ emperor: false }), price: 5 });
const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
const balances = f.beforeAction.players.map(p => [p.id, p.spice] as const);
const done = allowRicheseBureaucratResponses(payRicheseSelfPurchase(f));
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.deepEqual(done.players.map(p => [p.id, p.spice]),
balances.map(([id, amount]) => [id, amount - (id === f.seller ? 5 : 0)]));
assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
assert.equal(done.richeseBidding!.normalCount, 1);
assert.equal(viewGame(done, f.trainer!).bureaucrat.pending, null);
assert.equal(viewGame(done, f.trainer!).bureaucrat.usedThisPhase, false);
const grants = done.spiceBankerIncome!.sources.slice(sourceCount).filter(row => row.grant);
assert.equal(grants.length, 1);
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.seller, amount: 5 }]);
assert.deepEqual(viewGame(done, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
assert.deepEqual(richeseBureaucratCards(done), f.physical);
});
@@ -144,31 +144,6 @@ void test('an open lot performs no settlement or future availability checks', ()
assert.deepEqual(value, before);
});
void test('sold validation retains reserved-card, hand-capacity, self-purchase and exact-funding precedence', () => {
const value = input('blackMarket');
value.winner = { id: 'r', handCount: 4, handLimit: 4, spice: 0 };
value.lot = {
...value.lot,
outcome: { kind: 'sold', winner: 'r', amount: 6 },
};
value.card = null;
value.funding = null;
reject(value, /reserved auction card/);
value.card = richeseCards()[0];
reject(value, /winning hand has no room/);
value.winner = { ...value.winner, handCount: 3 };
reject(value, /Black Market self-purchase/);
value.lot = { ...value.lot, source: 'cache' };
reject(value, /exact funding commitment/);
value.funding = { amount: 6, allyPayment: 2, donor: 'a' };
reject(value, /no longer funded/);
value.winner = { ...value.winner, spice: 4 };
assert.equal(
quoteRicheseSettlement(value).kind,
'sold',
'cache owner purchase remains accepted',
);
});
void test('server-supplied faction hand limits preserve last-slot purchases and reject full winners', () => {
for (const limit of [4, 5, 8]) {