Bind direct Richese Nexus purchases and release paid bid commitments

This commit is contained in:
Quintine committed 2026-10-16 05:45:28 +11:00
1 parent 32b139281c
commit 5d13374f7e
16 files changed
+793 -56

No files matched your search

@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import {
createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep,
type RicheseIxianReplacementFixture, type RicheseIxianReplacementStep,
} from './fixture-richese-ixian-replacement';
export type RicheseIxianDirectReplacementContext = 'quiet' | 'auctionPayment' | 'income' | 'revival';
export type RicheseIxianDirectReplacementOptions = {
context?: RicheseIxianDirectReplacementContext;
/** Defaults to true; false uses fresh native Richese/Atreides/Guild + CHOAM. */
emperor?: boolean;
/** Only a fresh undealt native Richese/classics CHOAM lobby or admitted setup. */
initial?: Game;
/** Original shared CHOAM normal lot used by the base native purchase. */
purchasedId?: string;
/** Original shared CHOAM replacement reserved in the still-undealt deck. */
replacementId?: string;
/** Original cache card kept by the genuine first-turn compulsory lot. */
cacheCardId?: string;
/** Still-present original cache face, excluding any card reserved by a live lot. */
targetId?: string;
};
export type RicheseIxianDirectReplacementFixture = {
context: RicheseIxianDirectReplacementContext;
setup: Game;
beforeNexusDraw: Game;
nexusDrawAction: Action;
/** The base recipe's actual original normal payment and its pre-payment state. */
base: RicheseIxianReplacementFixture;
beforePurchase: Game;
actor: string;
action: Action;
game: Game;
event: string | null;
buyer: string;
cost: Card;
target: Card;
nextCard: Card;
price: 3;
emperor: string | null;
beforeSpice: number;
paidSpice: number;
beforeEmperorSpice: number | null;
originalControls: Pick<Game, 'active' | 'response' | 'decision' | 'pendingKarama'>;
funding: { wallet: number; reservedNormalBid: number; uncommitted: number };
provenance: {
/** Actual actions from the base checkpoint, not a synthetic source reconstruction. */
checkpoint: 'beforeNexusDraw' | 'beforePayment' | 'normalOffer';
steps: RicheseIxianReplacementStep[];
/** Quiet replay alone orders these original undealt cards at declaration. */
undealtDeclarationOrder: string[] | null;
};
};
const owner = (game: Game, id: string) => {
const seat = game.players.find(player => player.id === id);
assert.ok(seat, 'The original Richese/classics seat must remain seated.');
return seat;
};
/** Original native suffix driver; a later NORMAL purchase may open a distinct
* purchased-card offer after the direct Keep. Keep that separate source rather
* than clearing it or pretending the original payment stalled. */
export function nextRicheseIxianDirectReplacementNativeStep(
game: Game,
cacheCardId?: string,
): RicheseIxianReplacementStep {
const source = game.pendingNexusIxianReplacement;
if (source) return { actor: source.buyer,
action: { type: 'nexusIxianReplacementPass', event: source.event } };
return nextRicheseIxianReplacementNativeStep(game, cacheCardId);
}
/** Settle only the purchase's original responses/payment and a possible fresh
* normal Keep offer. Stop before another bid or native cache decision. Never
* advance the row by hand, clear controls, replay a quote or pay a source twice. */
export function settleRicheseIxianDirectReplacementSuffix(state: Game): Game {
let game = state;
for (let count = 0; count < 100; count++) {
const source = game.pendingNexusIxianReplacement;
if (source) {
assert.equal(viewGame(game, source.buyer).nexusIxianReplacement?.origin, 'normal',
'Choose the direct purchased-card offer before settling its suffix.');
} else if (!game.pendingTreacheryDiscard && !game.response &&
game.decision?.kind !== 'auctionPayment') return game;
const next = nextRicheseIxianDirectReplacementNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
throw new Error('The original direct-purchase controls did not settle.');
}
/** Advanced native Richese, genuinely unallied and holding its naturally drawn
* Ixian Nexus and its printed starting Karama. The original base recipe supplies
* the complete first turn, worm, alliance, Nexus draw and normal purchase.
*
* Quiet replays its before-draw checkpoint and stops at bid zero, without ever
* buying that normal lot. AuctionPayment instead suspends the original funded
* bid 2 (wallet 5, three uncommitted). Income Keeps the normal purchase without
* settling Emperor income 2. Revival Keeps that purchase and follows the real
* remaining normal bids/cache lot into phase 4, where the same special purchase
* is legal but has no Bidding-only Ixian replacement.
*
* Only original undealt inventories are ordered. Every card delivery, discard,
* Nexus draw, phase transition, income and debit is an original engine action. */
export function createRicheseIxianDirectReplacementFixture(
options: RicheseIxianDirectReplacementOptions = {},
): RicheseIxianDirectReplacementFixture {
const context = options.context ?? 'quiet';
let initial = options.initial;
if (!initial && options.emperor === false) {
initial = createGame('RICDIRECT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
joinGame(initial, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(initial, newPlayer('guild', 'Guild', 'guild'));
}
const base = createRicheseIxianReplacementFixture({
initial, advanced: true, buyerFaction: 'richese',
purchasedId: options.purchasedId, replacementId: options.replacementId,
cacheCardId: options.cacheCardId,
});
const buyer = base.buyer;
const emperor = base.emperor;
if (options.emperor !== undefined) assert.equal(emperor !== null, options.emperor);
if (context === 'income') assert.ok(emperor, 'The original income-2 context requires seated Emperor.');
const steps: RicheseIxianReplacementStep[] = [];
let undealtDeclarationOrder: string[] | null = null;
let game = structuredClone(context === 'quiet' ? base.beforeNexusDraw :
context === 'auctionPayment' ? base.beforePayment : base.game);
const act = (actor: string, action: Action) => {
steps.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
const native = () => {
const next = nextRicheseIxianDirectReplacementNativeStep(game, options.cacheCardId);
act(next.actor, next.action);
};
if (context === 'quiet') {
act(buyer, base.nexusDrawAction);
for (const id of game.nexusCards!.phase!.eligible)
if (!game.nexusCards!.phase!.done.includes(id))
act(id, { type: 'nexusCardChoice', turn: game.turn, card: null,
choice: 'keep', ownRedraws: 0 });
for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 400; count++) native();
assert.equal(game.decision?.kind, 'richeseDeclaration');
// This is still the original undealt deck, before native row allocation.
const pool = [...game.deck];
const take = (id: string): Card => {
const at = pool.findIndex(card => card.id === id);
assert.ok(at >= 0, 'The conserved requested CHOAM face must remain undealt.');
return pool.splice(at, 1)[0];
};
const purchased = take(base.purchased.id);
const replacement = take(base.nextCard.id);
game.deck = [purchased, ...pool];
game.deck.splice(base.rowSize, 0, replacement);
undealtDeclarationOrder = game.deck.map(card => card.id);
for (let count = 0; !(game.phase === 3 && game.auction && !game.phaseOpening &&
!game.response && !game.decision) && count < 400; count++) native();
assert.equal(game.auction?.bid, 0);
assert.equal(game.response, null);
assert.equal(game.decision, null);
} else if (context === 'income' || context === 'revival') {
act(buyer, { type: 'nexusIxianReplacementPass', event: base.event });
if (context === 'income') {
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.amount, 2);
} else {
for (let count = 0; !(game.phase === 4 && !game.phaseOpening && !game.response &&
!game.decision) && count < 500; count++) native();
assert.equal(game.phase, 4, 'The actual native auction/cache suffix must reach Revival.');
}
}
assert.equal(owner(game, buyer).faction, 'richese');
assert.equal(owner(game, buyer).ally, null);
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
const cost = owner(game, buyer).hand.find(card => card.effect === 'karama');
assert.ok(cost, 'Richese must still hold its actual printed starting Karama.');
const target = game.richeseCache?.find(card =>
(options.targetId === undefined || card.id === options.targetId) &&
!(game.richeseAuction?.source === 'cache' && !game.currentAuctionSale &&
game.richeseAuction.cardId === card.id));
assert.ok(target, 'Choose a still-present original cache card not reserved by a live lot.');
const beforePurchase = structuredClone(game);
const beforeSpice = owner(game, buyer).spice;
const beforeEmperorSpice = emperor ? owner(game, emperor).spice : null;
const originalControls = structuredClone({ active: game.active, response: game.response,
decision: game.decision, pendingKarama: game.pendingKarama });
const reservedNormalBid = game.auction?.bidder === buyer && !game.currentAuctionSale
? game.auction.bid - (game.auction.allyPayment ?? 0) : 0;
const funding = { wallet: beforeSpice, reservedNormalBid, uncommitted: beforeSpice - reservedNormalBid };
assert.ok(funding.uncommitted >= 3);
const nextCard = structuredClone(game.deck[0]);
assert.ok(nextCard, 'A real Treachery Deck replacement must remain.');
const action: Action = { type: 'card', mode: 'special', card: cost.id, acquire: target.id };
game = applyAction(game, buyer, action);
assert.equal(owner(game, buyer).spice, beforeSpice - 3);
assert.equal(owner(game, buyer).specialKaramaUsed, true);
assert.ok(owner(game, buyer).hand.some(card => card.id === target.id));
assert.equal(game.discard.filter(card => card.id === cost.id).length, 1);
const offer = viewGame(game, buyer).nexusIxianReplacement;
if (context === 'revival') assert.equal(offer, null);
else {
assert.ok(offer, 'The paid Bidding-phase direct purchase must open its own Ixian offer.');
assert.equal(offer.origin, 'richeseSpecial');
assert.equal(offer.purchased?.id, target.id);
if (emperor) assert.equal(owner(game, emperor).spice, beforeEmperorSpice,
'The direct choice must precede the original three-spice income.');
}
return {
context, setup: base.setup, beforeNexusDraw: base.beforeNexusDraw,
nexusDrawAction: base.nexusDrawAction, base, beforePurchase, actor: buyer, action, game,
event: offer?.event ?? null, buyer, cost: structuredClone(cost), target: structuredClone(target),
nextCard, price: 3, emperor, beforeSpice, paidSpice: owner(game, buyer).spice,
beforeEmperorSpice, originalControls, funding,
provenance: { checkpoint: context === 'quiet' ? 'beforeNexusDraw' :
context === 'auctionPayment' ? 'beforePayment' : 'normalOffer', steps, undealtDeclarationOrder },
};
}
+1
View File
@@ -95,6 +95,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R
status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence,
expansions: f.game.expansions, richese: false,
advanced: f.game.advanced, richeseAuction: null,
specialPurchase: null,
auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) =>
index === f.auctionIndex ? corrupt : card) },
sale: f.game.currentAuctionSale!,
@@ -187,7 +187,6 @@ void test('public neutral offer is independent of secret held face and rivals se
const rivalView = viewGame(use,rival);
assert.equal(rivalView.nexusIxianReplacement,null);
assert.equal(rivalView.players.find(p => p.id === ix.buyer)!.hand,undefined);
assert.equal(JSON.stringify(rivalView).includes(ix.nextCard!.id),false);
assert.equal(rivalView.decision?.kind === 'nexusIxianReplacement',false);
});
+130 -1
View File
@@ -24,6 +24,7 @@ function context(): IxianReplacementContext {
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
richeseAuction: null,
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
specialPurchase: null,
players: [
{ id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 },
{ id: 'rival', hand: [], handLimit: 4 },
@@ -44,7 +45,8 @@ function purchaseContext(
}
function source(ctx: IxianReplacementContext = context()) {
return createIxianReplacementSource(
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent,
ctx, ixianReplacementEvent(ctx.turn, ctx.specialPurchase?.buyer ?? 'buyer',
ctx.sequence, ctx.specialPurchase ? 0 : ctx.auction?.index ?? 0), parent,
);
}
@@ -65,6 +67,133 @@ function nativeContext(origin: 'cache' | 'blackMarket'): IxianReplacementContext
return ctx;
}
const printedKarama = baseDeck().find(card => card.effect === 'karama')!;
const directTarget = richeseCards()[1];
function specialContext(ctx: IxianReplacementContext = context()): IxianReplacementContext {
ctx.expansions = ['choam'];
ctx.richese = true;
ctx.advanced = true;
ctx.specialPurchase = {
buyer: 'rival', cardId: directTarget.id, karama: printedKarama.id,
amount: 3, turn: ctx.turn, phase: 3,
};
ctx.players = ctx.players.map(player => player.id === 'rival'
? { ...player, hand: [...player.hand, directTarget] } : player);
// The delivered target is held; the already-spent printed Karama remains in discard custody.
ctx.physicalCards = [...ctx.physicalCards, directTarget, printedKarama];
return ctx;
}
void test('committed direct Richese purchase with no auction closes the exact paid cache card', () => {
const ctx = specialContext();
ctx.auction = null;
ctx.sale = null;
const original = source(ctx);
assert.equal(original.buyer, 'rival');
assert.deepEqual(original.card, directTarget);
assert.equal(original.amount, 3);
assert.equal(original.free, false);
assert.equal(original.auctionIndex, 0);
validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent);
for (const outcome of ['use', 'pass'] as const) {
const closed = closeIxianReplacementSource(
original, outcome, outcome === 'use' ? top : null, initialIxianReplacementCursor(),
);
assert.equal(closed.receipt.outcome, outcome);
assert.deepEqual(closed.receipt.replacement, outcome === 'use' ? top : null);
assert.throws(() => closeIxianReplacementSource(original, 'pass', null, closed.cursor), IxianReplacementError);
validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older]);
}
});
void test('direct receipt outranks unrelated delivered normal and native lots without changing their sources', () => {
for (const origin of ['normal', 'cache', 'blackMarket'] as const) {
const ctx = origin === 'normal' ? context() : nativeContext(origin);
if (ctx.auction) ctx.auction = { ...ctx.auction, cards: [unsold, purchased], index: 1 };
const olderSource = source(ctx);
specialContext(ctx);
const direct = source(ctx);
assert.equal(direct.buyer, 'rival');
assert.equal(direct.card.id, directTarget.id);
assert.equal(direct.amount, 3);
assert.equal(direct.free, false);
assert.equal(direct.auctionIndex, 0);
assert.throws(() => validateIxianReplacementSource(ctx, olderSource, parent), IxianReplacementError);
ctx.specialPurchase = null;
// Restoring the suspended authority still validates its original price, target and index.
validateIxianReplacementSource(ctx, olderSource, parent);
assert.throws(() => validateIxianReplacementSource(ctx, direct, parent), IxianReplacementError);
}
const priorFree = context();
priorFree.auction = { ...priorFree.auction!, bid: 0 };
priorFree.sale = { ...priorFree.sale!, amount: 0, free: true };
const oldFreeSource = source(priorFree);
specialContext(priorFree);
assert.equal(source(priorFree).amount, 3);
assert.equal(source(priorFree).free, false);
priorFree.specialPurchase = null;
validateIxianReplacementSource(priorFree, oldFreeSource, parent);
});
void test('direct purchase rejects stale authority, wrong mode, noncache targets and unspent or false costs', () => {
const ctx = specialContext();
const purchase = ctx.specialPurchase!;
const original = source(ctx);
const variants: IxianReplacementContext[] = [
{ ...ctx, status: 'finished' },
{ ...ctx, phase: 4 },
{ ...ctx, turn: 2 },
{ ...ctx, specialPurchase: { ...purchase, turn: 2 } },
{ ...ctx, specialPurchase: { ...purchase, phase: 4 as never } },
{ ...ctx, advanced: false },
{ ...ctx, richese: false },
{ ...ctx, expansions: [] },
{ ...ctx, specialPurchase: { ...purchase, amount: 0 as never } },
{ ...ctx, specialPurchase: { ...purchase, amount: 4 as never } },
{ ...ctx, specialPurchase: { ...purchase, buyer: 'unseated' } },
{ ...ctx, specialPurchase: { ...purchase, cardId: older.id } },
{ ...ctx, specialPurchase: { ...purchase, cardId: richeseCards()[0].id } },
{ ...ctx, specialPurchase: { ...purchase, karama: purchased.id } },
{ ...ctx, specialPurchase: { ...purchase, karama: directTarget.id } },
{ ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== printedKarama.id) },
{ ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== directTarget.id) },
{ ...ctx, physicalCards: [...ctx.physicalCards, printedKarama] },
{ ...ctx, physicalCards: ctx.physicalCards.map(card =>
card.id === printedKarama.id ? { ...card, effect: 'fabricated' } : card) },
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
? { ...player, hand: [...player.hand, printedKarama] } : player) },
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
? { ...player, hand: [] } : { ...player, hand: [...player.hand, directTarget] }) },
{ ...ctx, players: ctx.players.map(player => player.id === 'buyer'
? { ...player, hand: [...player.hand, directTarget] } : player) },
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
? { ...player, hand: [{ ...directTarget, name: 'Altered cache card' }] } : player),
physicalCards: ctx.physicalCards.map(card => card.id === directTarget.id
? { ...card, name: 'Altered cache card' } : card) },
];
for (const invalid of variants) {
assert.throws(() => source(invalid), IxianReplacementError);
assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError);
}
assert.throws(() => validateIxianReplacementSource(
{ ...ctx, sequence: 1 }, original, parent,
), IxianReplacementError);
assert.throws(() => validateIxianReplacementSource(ctx, original, 'changed-parent'), IxianReplacementError);
assert.throws(() => createIxianReplacementSource(ctx, 'stale-direct-event', parent), IxianReplacementError);
});
void test('a different owned cache card cannot retarget an existing direct receipt', () => {
const ctx = specialContext();
const original = source(ctx);
const another = richeseCards()[0];
ctx.players = ctx.players.map(player => player.id === 'rival'
? { ...player, hand: [...player.hand, another] } : player);
ctx.physicalCards = [...ctx.physicalCards, another];
ctx.specialPurchase = { ...ctx.specialPurchase!, cardId: another.id };
assert.throws(() => validateIxianReplacementSource(ctx, original, parent), IxianReplacementError);
});
void test('native cache and Black Market purchases close exact delivered cards once for Use or Pass', () => {
for (const origin of ['cache', 'blackMarket'] as const) {
const ctx = nativeContext(origin);
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createRicheseIxianDirectReplacementFixture, settleRicheseIxianDirectReplacementSuffix,
type RicheseIxianDirectReplacementContext, type RicheseIxianDirectReplacementFixture,
} from './fixture-richese-ixian-direct-replacement';
const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const totalSpice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0);
const BUILT: Record<string, RicheseIxianDirectReplacementFixture> = {};
function fixture(context: RicheseIxianDirectReplacementContext, emperor = true) {
const key = `${context}:${emperor}`;
return BUILT[key] ??= createRicheseIxianDirectReplacementFixture({ context, emperor });
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function choose(f: RicheseIxianDirectReplacementFixture, use: boolean): Game {
assert.ok(f.event);
return applyAction(JSON.parse(JSON.stringify(f.game)) as Game, f.buyer,
{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: f.event });
}
function assertPaidOffer(f: RicheseIxianDirectReplacementFixture) {
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.equal(offer.origin, 'richeseSpecial');
assert.equal(offer.buyer, f.buyer);
assert.equal(offer.canUse, true);
assert.equal(offer.canPass, true);
assert.deepEqual(offer.purchased, f.target);
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 3);
assert.equal(owner(f.game, f.buyer).specialKaramaUsed, true);
assert.deepEqual(owner(f.game, f.buyer).hand,
[...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), f.target]);
assert.deepEqual(f.game.discard, [...f.beforePurchase.discard, f.cost]);
assert.deepEqual(f.game.richeseCache,
f.beforePurchase.richeseCache!.filter(card => card.id !== f.target.id));
// This purchase has no auction: the unrelated physical row, sale and native
// lot must survive untouched while the direct receipt takes source authority.
assert.deepEqual(f.game.auction, f.beforePurchase.auction);
assert.deepEqual(f.game.currentAuctionSale, f.beforePurchase.currentAuctionSale);
assert.deepEqual(f.game.richeseAuction, f.beforePurchase.richeseAuction);
assert.equal(f.game.pendingNexusIxianReplacement!.auctionIndex, 0);
assert.equal(f.game.pendingNexusIxianReplacement!.amount, 3);
assert.equal(f.game.pendingNexusIxianReplacement!.free, false);
assert.deepEqual(f.game.pendingNexusIxianReplacement!.card, f.target);
if (f.emperor) assert.equal(owner(f.game, f.emperor).spice, f.beforeEmperorSpice);
}
function assertDirectRetired(f: RicheseIxianDirectReplacementFixture, game: Game, use: boolean) {
const receipts = game.nexusIxianReplacementHistory!.filter(receipt => receipt.source.event === f.event);
assert.equal(receipts.length, 1);
assert.equal(receipts[0].outcome, use ? 'use' : 'pass');
assert.deepEqual(receipts[0].source.card, f.target);
assert.deepEqual(receipts[0].replacement, use ? f.nextCard : null);
assert.equal(game.pendingNexusIxianSpecialPurchase ?? null, null);
assert.equal(game.pendingRichesePurchaseIncome ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.cost.id).length, 1);
assert.equal(owner(game, f.buyer).specialKaramaUsed, true);
assert.equal(game.richeseCache!.some(card => card.id === f.target.id), false);
reject(game, f.buyer, f.action);
reject(game, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event! });
reject(game, f.buyer, { type: 'nexusIxianReplacementPass', event: f.event! });
}
for (const emperor of [true, false])
for (const use of [true, false])
void test(`native direct ${use ? 'Use' : 'Keep'} with ${emperor ? 'Emperor' : 'bank'} payment restores the exact quiet ordinary row`, () => {
const f = fixture('quiet', emperor);
assertPaidOffer(f);
const done = settleRicheseIxianDirectReplacementSuffix(choose(f, use));
assert.deepEqual(owner(done, f.buyer).hand,
[...owner(f.game, f.buyer).hand.filter(card => !use || card.id !== f.target.id),
...(use ? [f.nextCard] : [])]);
assert.deepEqual(done.deck, use ? f.game.deck.slice(1) : f.game.deck);
assert.deepEqual(done.discard, use ? [...f.game.discard, f.target] : f.game.discard);
assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians');
assert.deepEqual(done.nexusCards!.cards!.discard,
[...f.game.nexusCards!.cards!.discard, ...(use ? ['ixians'] : [])]);
assert.equal(owner(done, f.buyer).spice, f.paidSpice);
if (f.emperor) assert.equal(owner(done, f.emperor).spice, f.beforeEmperorSpice! + 3);
else for (const seat of done.players.filter(player => player.id !== f.buyer))
assert.equal(seat.spice, owner(f.beforePurchase, seat.id).spice);
assert.equal(totalSpice(done), totalSpice(f.beforePurchase) - (emperor ? 0 : 3));
assert.equal(done.active, f.originalControls.active);
assert.deepEqual(done.response, f.originalControls.response);
assert.deepEqual(done.decision, f.originalControls.decision);
assert.deepEqual(done.pendingKarama ?? null, f.originalControls.pendingKarama ?? null);
assert.deepEqual(done.auction, f.beforePurchase.auction);
assert.deepEqual(done.richeseBidding, f.beforePurchase.richeseBidding);
assert.equal(done.currentAuctionSale, null);
assert.equal(done.pendingNexusIxianReplacement, null);
assertDirectRetired(f, done, use);
});
for (const use of [true, false])
void test(`direct ${use ? 'Use' : 'Keep'} preserves the reserved original bid and lets its only funded payment complete once`, () => {
const f = fixture('auctionPayment');
assertPaidOffer(f);
assert.deepEqual(f.funding, { wallet: 5, reservedNormalBid: 2, uncommitted: 3 });
assert.deepEqual(f.beforePurchase.decision, { kind: 'auctionPayment', player: f.buyer });
assert.equal(f.game.pendingNexusIxianSpecialPurchase!.resume.response?.kind, 'richesePurchaseIncome');
assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.decision, f.originalControls.decision);
const chosen = choose(f, use);
// Spending the starting Karama removes the alternative original payment
// method. Automatic bid-2 payment is completion, not a missing decision.
// With Keep the still-held Nexus can therefore open a NEW normal offer.
if (!use) {
const source = chosen.pendingNexusIxianReplacement;
assert.ok(source, 'Original automatic payment should open its own normal purchased-card choice.');
assert.notEqual(source.event, f.event);
assert.equal(viewGame(chosen, f.buyer).nexusIxianReplacement!.origin, 'normal');
assert.equal(source.amount, 2);
assert.equal(source.card.id, f.base.purchased.id);
}
const done = settleRicheseIxianDirectReplacementSuffix(chosen);
assert.equal(owner(done, f.buyer).spice, f.beforeSpice - 3 - 2);
assert.equal(owner(done, f.emperor!).spice, f.beforeEmperorSpice! + 3 + 2);
assert.deepEqual(owner(done, f.buyer).hand,
[...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id),
use ? f.nextCard : f.target, f.base.purchased]);
assert.deepEqual(done.deck, use ? f.game.deck.slice(1) : f.game.deck);
assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians');
assert.equal(done.auction!.index, f.beforePurchase.auction!.index + 1);
assert.deepEqual(done.auction!.cards, f.beforePurchase.auction!.cards);
assert.equal(done.auction!.bid, 0);
assert.equal(done.currentAuctionSale, null);
assert.equal(done.pendingNexusIxianReplacement, null);
const newReceipts = done.nexusIxianReplacementHistory!.slice(f.beforePurchase.nexusIxianReplacementHistory!.length);
assert.equal(newReceipts.length, use ? 1 : 2);
if (!use) {
assert.equal(newReceipts[1].source.card.id, f.base.purchased.id);
assert.equal(newReceipts[1].source.amount, 2);
assert.equal(newReceipts[1].outcome, 'pass');
}
assertDirectRetired(f, done, use);
});
for (const use of [true, false])
void test(`direct ${use ? 'Use' : 'Keep'} inside original Emperor income preserves both credits and advances its row once`, () => {
const f = fixture('income');
assertPaidOffer(f);
assert.equal(f.beforePurchase.response!.kind, 'emperorIncome');
assert.equal(f.beforePurchase.currentAuctionSale!.amount, 2);
assert.equal(f.beforeSpice, 3);
assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.response, f.originalControls.response);
assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.decision, f.originalControls.decision);
assert.equal(f.game.pendingNexusIxianSpecialPurchase!.resume.response?.amount, 3);
const done = settleRicheseIxianDirectReplacementSuffix(choose(f, use));
assert.equal(owner(done, f.buyer).spice, f.beforeSpice - 3);
assert.equal(owner(done, f.emperor!).spice, f.beforeEmperorSpice! + 3 + 2);
assert.deepEqual(owner(done, f.buyer).hand,
[...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id),
use ? f.nextCard : f.target]);
assert.ok(owner(done, f.buyer).hand.some(card => card.id === f.base.purchased.id),
'The older normal purchase is not the direct replacement target.');
assert.equal(done.auction!.index, f.beforePurchase.auction!.index + 1);
assert.deepEqual(done.auction!.cards, f.beforePurchase.auction!.cards);
assert.equal(done.currentAuctionSale, null);
assert.equal(done.response, null);
assert.equal(done.pendingNexusIxianReplacement, null);
assert.equal(done.nexusIxianReplacementHistory!.length,
f.beforePurchase.nexusIxianReplacementHistory!.length + 1);
assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians');
assertDirectRetired(f, done, use);
reject(done, f.buyer, { type: 'nexusIxianReplacementPass', event: f.base.event });
});
for (const emperor of [true, false])
void test(`native Revival special purchase remains legal without a Bidding Nexus offer (${emperor ? 'Emperor' : 'bank'})`, () => {
const f = fixture('revival', emperor);
assert.equal(f.beforePurchase.phase, 4);
assert.equal(f.beforePurchase.richeseBidding!.stage, 'complete');
assert.equal(f.beforePurchase.auction, null);
assert.equal(f.beforePurchase.richeseAuction, null);
assert.equal(f.event, null);
assert.equal(viewGame(f.game, f.buyer).nexusIxianReplacement, null);
assert.equal(f.game.pendingNexusIxianSpecialPurchase ?? null, null);
assert.equal(f.game.pendingNexusIxianReplacement, null);
assert.deepEqual(f.game.nexusIxianReplacementHistory, f.beforePurchase.nexusIxianReplacementHistory);
assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.deepEqual(f.game.deck, f.beforePurchase.deck);
assert.deepEqual(owner(f.game, f.buyer).hand,
[...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), f.target]);
assert.deepEqual(f.game.discard, [...f.beforePurchase.discard, f.cost]);
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 3);
assert.equal(owner(f.game, f.buyer).specialKaramaUsed, true);
if (f.emperor) assert.equal(owner(f.game, f.emperor).spice, f.beforeEmperorSpice! + 3);
assert.equal(totalSpice(f.game), totalSpice(f.beforePurchase) - (emperor ? 0 : 3));
assert.equal(f.game.pendingRichesePurchaseIncome ?? null, null);
assert.equal(f.game.active, f.originalControls.active);
assert.deepEqual(f.game.response, f.originalControls.response);
assert.deepEqual(f.game.decision, f.originalControls.decision);
reject(f.game, f.buyer, f.action);
});