Bind Ixian Nexus replacement to original Richese seller lots

This commit is contained in:
Quintine committed 2026-10-16 05:02:51 +11:00
1 parent 634ed293af
commit 32b139281c
18 files changed
+881 -118

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ export function nextRicheseIxianReplacementNativeStep(
keep: owner.hand.length < handLimit(owner) } };
}
const lot = game.richeseAuction;
if (!decision && lot && !lot.outcome) {
if (!decision && !game.response && lot && !lot.outcome) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))
: lot.active;
@@ -0,0 +1,394 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import type { RicheseAuction } from '../game/richese-auction';
import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
import {
createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep,
type RicheseIxianReplacementStep,
} from './fixture-richese-ixian-replacement';
/** The two native Richese sources a genuine purchased-card replacement may bind:
* the compulsory cache lot and the owner's own Black Market lot. */
export type RicheseIxianSpecialReplacementOrigin = 'cache' | 'blackMarket';
/** A real positive Once Around sale or the genuine zero-bid outcome. */
export type RicheseIxianSpecialReplacementMode = 'sold' | 'unbid';
/** The engine's own native lot facts, as bound by the live game. */
export type RicheseIxianSpecialReplacementLot =
Pick<RicheseAuction, 'event' | 'cardId' | 'source' | 'owner' | 'outcome'>;
export type RicheseIxianSpecialReplacementOptions = {
/** Continue only a fresh authenticated undealt Richese/CHOAM lobby or its admitted setup. */
initial?: Game;
/** Native profile mode. The Black Market offer exists only in Advanced. */
advanced?: boolean;
/** Native source of the genuine purchase. Defaults to the compulsory cache lot. */
origin?: RicheseIxianSpecialReplacementOrigin;
/** Real positive Once Around sale (default) or the genuine zero-bid outcome. */
mode?: RicheseIxianSpecialReplacementMode;
/** Shared CHOAM addition bought as the ordinary normal lot before the source lot. */
purchasedId?: string;
/** Original shared CHOAM addition left as the actual top card after that normal purchase. */
replacementId?: string;
/** Physical cache card the genuine turn-one cache lot keeps; supplies a Black Market offer. */
cacheCardId?: string;
/** Cache origin: physical cache card the genuine compulsory cache lot offers. */
offerCardId?: string;
/** Positive winning bid for the genuine Once Around sale. Default 1. */
bid?: number;
};
type RicheseIxianSpecialReplacementCommon = {
/** Admitted fresh setup before its first turn; only undealt decks were ordered. */
setup: Game;
/** Genuine unallied Ixian Nexus holder about to close the Nexus; only undealt decks ordered. */
beforeNexusDraw: Game;
nexusDrawAction: Action;
/** The exact physical card this fixture offered in the real native lot. */
offered: Card;
/** The real native lot's own event; never a fabricated auction row. */
lotEvent: string;
lotMethod: 'onceAround';
/** Live native lot at the returned state, or null once the round retired it. */
richeseAuction: RicheseIxianSpecialReplacementLot | null;
buyer: string;
/** The Richese round owner whose own card or cache card was offered. */
seller: string;
emperor: string | null;
origin: RicheseIxianSpecialReplacementOrigin;
advanced: boolean;
/** The original physical cache at this point; a purchase may only shrink it by its own lot. */
cacheBefore: Card[];
/** Cards a genuine turn-one cache lot kept instead of buying. */
keptCacheCards: string[];
/** Black Market origin: the genuine pre-decline offer decision this fixture replaced. */
blackMarketOffer: { actor: string; event: string; card: Card } | null;
/** Where the original native suffix resumes after the buyer's choice. */
suffix: 'phase' | 'declaration';
};
export type RicheseIxianSpecialReplacementSaleFixture = RicheseIxianSpecialReplacementCommon & {
mode: 'sold';
/** State with the pending purchased-card offer, after payment and delivery only. */
game: Game;
/** Public purchased-card event opened for this genuinely paid native sale. */
event: string;
/** The exact delivered card the offer binds; never a top deck or unsold lot card. */
purchased: Card;
/** Actual top of the Treachery Deck at the offer: the only legal replacement draw. */
nextCard: Card;
bid: number;
/** Real native state immediately before the action that paid and delivered the lot. */
beforePurchase: Game;
/** The real native action that closed the lot, and by whom it was taken. */
purchaseActor: string;
purchaseAction: Action;
/** Buyer hand before paying for the source lot; the delivered card is excluded. */
buyerHandBefore: Card[];
beforeSpice: number;
/** Buyer spice at the offer: the native payment is already debited exactly once. */
paidSpice: number;
/** Seller spice at the offer: the native credit has not happened yet. */
sellerSpiceAtOffer: number;
/** Emperor spice at the offer; an unallied native sale owes no Emperor gift. */
emperorSpiceAtOffer: number | null;
};
export type RicheseIxianSpecialReplacementUnbidFixture = RicheseIxianSpecialReplacementCommon & {
mode: 'unbid';
/** State where the genuine zero-bid outcome retired the lot; no sale was ever paid. */
game: Game;
/** Real native state immediately before the action that retired the lot. */
beforeFinalAction: Game;
finalActor: string;
finalAction: Action;
/** Cache origin: the keeper's original unbid choice, applied only when room existed. */
kept: boolean | null;
/** The physical card no purchase ever bound. */
card: Card;
};
export type RicheseIxianSpecialReplacementFixture =
| RicheseIxianSpecialReplacementSaleFixture
| RicheseIxianSpecialReplacementUnbidFixture;
function player(game: Game, id: string) {
const seat = game.players.find(candidate => candidate.id === id);
assert.ok(seat, `The original seat ${id} must remain at the table.`);
return seat;
}
function liveLot(game: Game): RicheseIxianSpecialReplacementLot | null {
const lot = game.richeseAuction;
return lot ? {
event: lot.event, cardId: lot.cardId, source: lot.source,
owner: lot.owner, outcome: structuredClone(lot.outcome),
} : null;
}
/** Exported native seam: drive the original undealt Richese/CHOAM turn with the
* genuine native policy, so a throwaway API/browser smoke can reach the source. */
export function nextRicheseIxianSpecialReplacementNativeStep(
game: Game,
cacheCardId?: string,
): RicheseIxianReplacementStep {
return nextRicheseIxianReplacementNativeStep(game, cacheCardId);
}
function nativeStep(game: Game, cacheCardId?: string): Game {
const next = nextRicheseIxianSpecialReplacementNativeStep(game, cacheCardId);
return applyAction(game, next.actor, next.action);
}
/** Drive only the original native suffix after the buyer's own choice: the cache
* lot ends its round into the phase boundary, the Black Market sale resumes the
* original declaration. Never bids, pays, draws or invents a new lot. */
export function settleRicheseIxianSpecialReplacementSuffix(
state: Game,
origin: RicheseIxianSpecialReplacementOrigin,
cacheCardId?: string,
): Game {
let game = state;
for (let count = 0; count < 50; count++) {
if (origin === 'cache'
? game.richeseBidding?.stage === 'complete'
: game.decision?.kind === 'richeseDeclaration') return game;
game = nativeStep(game, cacheCardId);
}
throw new Error('The original native Richese suffix did not settle.');
}
/** A genuine special-origin purchase for the Ixian Nexus purchased-card choice.
*
* Provenance, all through the original private initializer and original actions:
* the profile is the fresh native Richese + Atreides + Emperor/CHOAM prototype
* with the real closing Nexus. The unallied Atreides holder buys a real normal
* CHOAM lot at the last scheduled lot and keeps it with its Ixian Nexus card
* (cache origin), or the fixture replays the genuine Nexus turn from its own
* undealt snapshot (Black Market origin).
*
* Cache origin then lets the compulsory cache lot run as a real Once Around for
* a card the ten-card cache still holds. Black Market origin stops at Richese's
* own pre-decline window and offers the one card a genuine turn-one cache lot
* actually kept in the owner's hand. The buyer's positive bid is a real funded
* native bid. All bid, wallet, clock and card changes come from native actions;
* no played state is assigned.
*
* The returned `game` already contains the paid sale (buyer debited, card
* delivered) and the pending purchased-card offer; the original seller credit,
* lot retirement and native suffix still belong to the original continuation,
* so Use/Keep can never re-debit or re-credit the purchase. */
export function createRicheseIxianSpecialReplacementFixture(
options: RicheseIxianSpecialReplacementOptions = {},
): RicheseIxianSpecialReplacementFixture {
const origin = options.origin ?? 'cache';
const mode = options.mode ?? 'sold';
const advanced = options.advanced ?? origin === 'blackMarket';
if (origin === 'blackMarket')
assert.ok(advanced,
'The native Black Market offer exists only in the Advanced profile; pass advanced: true or leave it unset.');
const bid = options.bid ?? 1;
assert.ok(Number.isSafeInteger(bid) && bid > 0,
'A genuine Once Around purchase needs a positive integer bid.');
const cacheCardId = options.cacheCardId;
const base = createRicheseIxianReplacementFixture({
initial: options.initial, advanced, endingAuction: true,
purchasedId: options.purchasedId, replacementId: options.replacementId, cacheCardId,
});
assert.equal(base.advanced, advanced);
const seller = base.setup.players.find(candidate => candidate.faction === 'richese')!.id;
const buyer = base.buyer;
assert.notEqual(buyer, seller,
'The genuine source purchase needs a buyer other than its own Richese seller.');
const emperor = base.setup.players.find(candidate => candidate.faction === 'emperor')?.id ?? null;
assert.equal(player(base.game, buyer).ally, null,
'The source purchase needs the unallied Ixian Nexus holder as its buyer.');
assert.equal(base.game.nexusCards!.cards!.hands[buyer], 'ixians',
'The source purchase needs its buyer still holding the genuine Ixian Nexus card.');
assert.ok(!base.setup.players.some(candidate => candidate.faction === 'ixians'),
'The native Richese/CHOAM profile must have native Ixians absent.');
const keptCacheCards = [...base.keptCacheCards];
const cacheBefore = structuredClone(base.cacheAtPurchase);
let game: Game;
let offered: Card;
let blackMarketOffer: RicheseIxianSpecialReplacementCommon['blackMarketOffer'] = null;
if (origin === 'cache') {
// Keep the real normal purchase and the unallied Ixian Nexus card, then let
// Richese's own compulsory cache offer stand on the original physical cache.
game = settleIxianReplacementFixture(applyAction(structuredClone(base.game), buyer,
{ type: 'nexusIxianReplacementPass', event: base.event }));
assert.equal(game.pendingNexusIxianReplacement, null,
'The genuine normal purchase must close before the compulsory cache lot.');
assert.deepEqual(game.decision, { kind: 'richeseCache', player: seller });
assert.equal(game.richeseBidding!.stage, 'cacheOffer');
const cache = game.richeseCache;
assert.ok(cache, 'The cache lot needs the original physical cache.');
assert.deepEqual(cache, cacheBefore,
'The genuine normal purchase must not touch the compulsory cache.');
const chosen = options.offerCardId ?? cache.find(card => !keptCacheCards.includes(card.id))!.id;
const cached = cache.find(card => card.id === chosen);
assert.ok(cached, 'The offered cache card must still be in the physical cache.');
assert.ok(!keptCacheCards.includes(cached.id),
'The cache lot must not re-offer a card an earlier genuine lot already kept.');
offered = structuredClone(cached);
game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event,
card: offered.id, method: 'onceAround', direction: 'clockwise' });
} else {
game = structuredClone(base.beforeNexusDraw);
game = applyAction(game, buyer, base.nexusDrawAction);
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
for (const id of game.nexusCards!.phase!.eligible)
if (!game.nexusCards!.phase!.done.includes(id))
game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn,
card: null, choice: 'keep', ownRedraws: 0 });
for (let count = 0; game.decision?.kind !== 'richeseBlackMarket' && count < 600; count++)
game = nativeStep(game, cacheCardId);
const decision = game.decision;
assert.equal(decision?.kind, 'richeseBlackMarket',
'The genuine Advanced round must still offer its own Black Market window.');
assert.equal(game.richeseBidding!.stage, 'blackMarketOffer');
assert.equal(game.richeseAuction, null);
const held = player(game, seller).hand.filter(card => keptCacheCards.includes(card.id));
assert.equal(held.length, 1,
'The genuine turn-one cache Keep must supply exactly one held Richese card.');
const candidate = options.offerCardId
? held.find(card => card.id === options.offerCardId)
: held[0];
assert.ok(candidate, 'The Black Market offer needs a genuinely held Richese card.');
offered = structuredClone(candidate);
blackMarketOffer = { actor: decision.player, event: game.richeseBidding!.event,
card: structuredClone(offered) };
assert.equal(game.richeseOfferedCard, null);
game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event,
card: offered.id, method: 'onceAround', direction: 'clockwise' });
assert.equal(game.richeseAuction?.source, 'blackMarket');
assert.equal(game.richeseAuction!.cardId, offered.id);
assert.equal(game.richeseOfferedCard?.id, offered.id);
}
const open = game.richeseAuction;
assert.ok(open, 'The genuine native Once Around lot must be open.');
assert.equal(open.source, origin);
assert.equal(open.method, 'onceAround');
assert.equal(open.cardId, offered.id);
assert.equal(open.owner, seller);
assert.equal(open.outcome, null);
const lotEvent = open.event;
let beforeFinalAction = structuredClone(game);
let finalActor = '';
let finalAction: Action = { type: 'richeseBid', event: lotEvent, amount: null };
for (let count = 0; count < 200; count++) {
if (mode === 'sold' && game.pendingNexusIxianReplacement) break;
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) {
const next = nextRicheseIxianSpecialReplacementNativeStep(game, cacheCardId);
beforeFinalAction = structuredClone(game);
finalActor = next.actor;
finalAction = next.action;
game = applyAction(game, next.actor, next.action);
continue;
}
const lot = game.richeseAuction;
if (!lot || lot.outcome) break;
assert.equal(lot.event, lotEvent, 'The genuine lot must keep its own event.');
const actor = lot.active;
assert.ok(actor, 'A genuine Once Around lot always names its next bidder.');
const action: Action = mode === 'sold' && actor === buyer && lot.bid === 0
? { type: 'richeseBid', event: lotEvent, amount: bid }
: { type: 'richeseBid', event: lotEvent, amount: null };
beforeFinalAction = structuredClone(game);
finalActor = actor;
finalAction = action;
game = applyAction(game, actor, action);
}
const common = {
setup: base.setup, beforeNexusDraw: base.beforeNexusDraw,
nexusDrawAction: base.nexusDrawAction, offered, lotEvent, lotMethod: 'onceAround' as const,
richeseAuction: liveLot(game), buyer, seller, emperor, origin, advanced,
cacheBefore, keptCacheCards, blackMarketOffer,
suffix: (origin === 'cache' ? 'phase' : 'declaration') as 'phase' | 'declaration',
};
if (mode === 'sold') {
const closed = game.richeseAuction;
assert.ok(closed, 'The paid native lot must remain the offer’s own source.');
assert.deepEqual(closed.outcome, { kind: 'sold', winner: buyer, amount: bid });
const source = game.pendingNexusIxianReplacement;
assert.ok(source, 'The genuinely paid native sale must open the purchased-card offer.');
assert.equal(source.version, 1);
assert.equal(source.buyer, buyer);
assert.equal(source.turn, game.turn);
assert.equal(source.auctionIndex, 0,
'A native Richese lot binds lot index 0, never a fabricated Game or normal-auction row.');
assert.equal(source.amount, bid);
assert.equal(source.free, false);
assert.deepEqual(source.card, offered);
assert.deepEqual(game.decision, { kind: 'nexusIxianReplacement', player: buyer,
event: source.event });
assert.equal(game.auction, null);
assert.equal(game.currentAuctionSale?.origin, origin);
assert.equal(game.players.flatMap(seat => seat.hand)
.filter(card => card.id === offered.id).length, 1,
'The paid native card must be delivered exactly once.');
if (origin === 'cache') {
assert.equal(game.richeseCache!.some(card => card.id === offered.id), false);
assert.deepEqual(game.richeseCache,
cacheBefore.filter(card => card.id !== offered.id));
} else {
assert.deepEqual(game.richeseCache, cacheBefore,
'A Black Market sale must never touch the compulsory cache.');
assert.equal(player(game, seller).hand.some(card => card.id === offered.id), false,
'The concealed Black Market card must leave the seller’s own hand.');
assert.equal(player(beforeFinalAction, seller).hand.some(card => card.id === offered.id),
true, 'The Black Market offer must come from a genuinely held card.');
}
assert.ok(game.deck.length > 0);
const nextCard = structuredClone(game.deck[0]);
assert.notEqual(nextCard.id, offered.id);
return {
...common, mode: 'sold', game, event: source.event,
purchased: structuredClone(offered), nextCard, bid,
beforePurchase: beforeFinalAction, purchaseActor: finalActor, purchaseAction: finalAction,
buyerHandBefore: structuredClone(player(beforeFinalAction, buyer).hand),
beforeSpice: player(beforeFinalAction, buyer).spice,
paidSpice: player(game, buyer).spice,
sellerSpiceAtOffer: player(game, seller).spice,
emperorSpiceAtOffer: emperor ? player(game, emperor).spice : null,
};
}
assert.equal(finalAction.type, 'richeseBid');
assert.equal(finalAction.amount, null,
'A genuine unbid outcome can only come from real zero offers.');
assert.equal(game.pendingNexusIxianReplacement, null,
'A lot that was never sold is not a purchase and opens no purchased-card offer.');
assert.equal(game.currentAuctionSale, null);
if (origin === 'cache') {
const lot = game.richeseAuction;
assert.ok(lot, 'The unbid cache lot waits for its owner’s own choice.');
assert.deepEqual(lot.outcome, { kind: 'unbid' });
assert.deepEqual(game.decision, { kind: 'richeseUnbid', player: seller });
const sellerSeat = player(game, seller);
const kept = sellerSeat.hand.length < handLimit(sellerSeat);
game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event,
keep: kept });
assert.equal(game.pendingNexusIxianReplacement, null);
assert.equal(player(game, seller).hand.some(card => card.id === offered.id), kept);
assert.equal((game.richeseRemoved ?? []).some(card => card.id === offered.id), !kept);
return {
...common, richeseAuction: liveLot(game), mode: 'unbid', game,
beforeFinalAction, finalActor, finalAction, kept, card: structuredClone(offered),
};
}
assert.equal(game.richeseAuction, null, 'The retained Black Market lot must retire itself.');
assert.deepEqual(game.decision, { kind: 'richeseDeclaration', player: seller });
assert.equal(game.richeseBidding!.blackMarketSold, false);
assert.equal(player(game, seller).hand.some(card => card.id === offered.id), true,
'The unbid Black Market card must stay with its own seller.');
assert.equal(game.richeseOfferedCard, null);
return {
...common, richeseAuction: null, mode: 'unbid', game,
beforeFinalAction, finalActor, finalAction, kept: null, card: structuredClone(offered),
};
}
+1
View File
@@ -94,6 +94,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R
const ctx: IxianReplacementContext = {
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,
auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) =>
index === f.auctionIndex ? corrupt : card) },
sale: f.game.currentAuctionSale!,
+2 -3
View File
@@ -173,13 +173,12 @@ void test('public neutral offer is independent of secret held face and rivals se
const irrelevant = createIxianNexusReplacementFixture({face:'richese'});
const rival = ix.game.players.find(p => p.id !== ix.buyer)!.id;
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,viewGame(irrelevant.game,rival).nexusIxianReplacement);
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,
{event:ix.event,buyer:ix.buyer,canPass:false,canUse:false,blocked:null,purchased:null});
assert.equal(viewGame(ix.game,rival).nexusIxianReplacement!.purchased,null);
assert.equal(viewGame(ix.game,rival).nexusIxianReplacement!.canUse,false);
assert.equal(viewGame(ix.game,ix.buyer).nexusIxianReplacement!.canUse,true);
const otherOffer = viewGame(irrelevant.game,irrelevant.buyer).nexusIxianReplacement!;
assert.equal(otherOffer.canPass,true);
assert.equal(otherOffer.canUse,false);
assert.equal(otherOffer.blocked,null);
reject(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementUse',event:irrelevant.event});
const pass = applyAction(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementPass',event:irrelevant.event});
assert.equal(pass.nexusCards!.cards!.hands[irrelevant.buyer],'richese');
+113 -1
View File
@@ -20,8 +20,9 @@ const parent = 'original-native-paid-sale-parent';
function context(): IxianReplacementContext {
return {
status: 'playing', phase: 3, turn: 1, sequence: 0,
expansions: [], richese: false,
expansions: [], richese: false, advanced: false,
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
richeseAuction: null,
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
players: [
{ id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 },
@@ -47,6 +48,117 @@ function source(ctx: IxianReplacementContext = context()) {
);
}
function nativeContext(origin: 'cache' | 'blackMarket'): IxianReplacementContext {
const card = origin === 'cache' ? richeseCards()[0] : purchased;
const ctx = context();
ctx.expansions = ['choam'];
ctx.richese = true;
ctx.advanced = origin === 'blackMarket';
ctx.auction = null;
ctx.richeseAuction = {
event: `native-${origin}-auction`, cardId: card.id, source: origin, owner: 'rival',
outcome: { kind: 'sold', winner: 'buyer', amount: 7 },
};
ctx.sale = { winner: 'buyer', amount: 7, free: false, origin, seller: 'rival' };
ctx.players = [{ ...ctx.players[0], hand: [{ ...older }, { ...card }] }, ctx.players[1]];
ctx.physicalCards = [{ ...older }, { ...card }, { ...top }];
return ctx;
}
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);
const original = source(ctx);
const delivered = ctx.players[0].hand.find(card => card.id === ctx.richeseAuction!.cardId)!;
assert.deepEqual(original.card, delivered);
assert.equal(original.amount, 7);
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);
assert.throws(() => closeIxianReplacementSource(original, 'use', top, closed.cursor), IxianReplacementError);
// A later census need not retain the purchased or drawn card at its old location.
validateIxianReplacementHistory(
JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older],
);
}
}
});
void test('successive single native lots use the independent sequence without inventing normal rows', () => {
const cache = nativeContext('cache');
const first = closeIxianReplacementSource(source(cache), 'pass', null, initialIxianReplacementCursor());
const blackMarket = nativeContext('blackMarket');
blackMarket.sequence = first.cursor.sequence;
const second = closeIxianReplacementSource(source(blackMarket), 'use', top, first.cursor);
assert.equal(first.receipt.source.auctionIndex, 0);
assert.equal(second.receipt.source.auctionIndex, 0);
assert.notEqual(first.receipt.source.event, second.receipt.source.event);
validateIxianReplacementHistory([first.receipt, second.receipt], second.cursor);
});
void test('native purchase authority rejects missing, open, unbid, mismatched and competing lots', () => {
for (const origin of ['cache', 'blackMarket'] as const) {
const ctx = nativeContext(origin);
const lot = ctx.richeseAuction!;
const original = source(ctx);
const variants: IxianReplacementContext[] = [
{ ...ctx, richeseAuction: null },
{ ...ctx, richeseAuction: { ...lot, outcome: null } },
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'unbid' } } },
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 0 } } },
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'rival', amount: 7 } } },
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 8 } } },
{ ...ctx, richeseAuction: { ...lot, source: origin === 'cache' ? 'blackMarket' : 'cache' } },
{ ...ctx, richeseAuction: { ...lot, cardId: unsold.id } },
{ ...ctx, richeseAuction: { ...lot, owner: 'unseated' }, sale: { ...ctx.sale!, seller: 'unseated' } },
{ ...ctx, sale: { ...ctx.sale!, seller: null } },
{ ...ctx, sale: { ...ctx.sale!, seller: 'buyer' } },
{ ...ctx, sale: { ...ctx.sale!, amount: 8 } },
{ ...ctx, sale: { ...ctx.sale!, free: true } },
{ ...ctx, sale: { ...ctx.sale!, origin: 'normal', seller: null } },
{ ...ctx, sale: null },
{ ...ctx, auction: context().auction },
{ ...ctx, expansions: [] },
{ ...ctx, richese: false },
...(origin === 'blackMarket' ? [{ ...ctx, advanced: false }] : []),
];
for (const invalid of variants) {
assert.throws(() => source(invalid), IxianReplacementError);
assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError);
}
// Another canonical card already in the hand cannot replace the actual lot.
const swapped = { ...ctx, richeseAuction: { ...lot, cardId: older.id } };
assert.throws(() => validateIxianReplacementSource(swapped, original, parent), IxianReplacementError);
const absent = {
...ctx, players: [{ ...ctx.players[0], hand: [older] }, ctx.players[1]],
};
assert.throws(() => source(absent), IxianReplacementError);
const duplicate = {
...ctx, players: [{ ...ctx.players[0], hand: [...ctx.players[0].hand, original.card] }, ctx.players[1]],
};
assert.throws(() => source(duplicate), IxianReplacementError);
}
});
void test('normal purchases preserve their actual nonzero lot index and saved continuation', () => {
const ctx = context();
ctx.auction = { ...ctx.auction!, cards: [unsold, purchased], index: 1 };
const original = createIxianReplacementSource(
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 1), parent,
);
assert.equal(original.auctionIndex, 1);
validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent);
const closed = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor);
});
void test('retained purchases keep saved receipt history linear through the game horizon', () => {
const physical = baseDeck();
const history: IxianReplacementReceipt[] = [];
+1 -50
View File
@@ -4,7 +4,7 @@ import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
import {
createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep,
createRicheseIxianReplacementFixture,
type RicheseIxianReplacementFixture,
} from './fixture-richese-ixian-replacement';
@@ -121,52 +121,3 @@ void test(`${advanced ? 'Advanced' : 'Basic'} native final-lot CHOAM purchase cl
assert.equal(use.nexusCards!.cards!.hands[f.buyer], null);
});
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} Richese cache lot keeps the Nexus replacement gated to normal purchases`, () => {
const f = fixtureFor(advanced, 'atreides', true);
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const richese = f.game.players.find(player => player.faction === 'richese')!.id;
// Keep the purchased card and the unallied Nexus card, so the buyer is still
// an eligible Nexus holder when the round reaches its own compulsory cache lot.
let game = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementPass', event: offer.event }));
assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(game.auction, null);
assert.equal(game.richeseBidding!.stage, 'cacheOffer');
assert.deepEqual(game.decision, { kind: 'richeseCache', player: richese });
assert.equal(game.pendingNexusIxianReplacement, null);
const cacheCard = game.richeseCache![0];
const before = { buyer: owner(game, f.buyer).spice, richese: owner(game, richese).spice,
emperor: owner(game, f.emperor!).spice };
game = applyAction(game, richese, { type: 'decision', event: game.richeseBidding!.event,
card: cacheCard.id, method: 'onceAround', direction: 'clockwise' });
assert.equal(game.richeseAuction?.source, 'cache');
assert.equal(game.richeseAuction?.cardId, cacheCard.id);
assert.equal(game.richeseAuction?.owner, richese);
// A real Once Around: the native policy declines for every other seat, the
// unallied Nexus holder buys the cache card with its own spice.
for (let count = 0; game.richeseAuction && !game.richeseAuction.outcome && count < 20; count++) {
const lot: NonNullable<Game['richeseAuction']> = game.richeseAuction;
const next = nextRicheseIxianReplacementNativeStep(game);
assert.equal(next.actor, lot.active);
assert.equal(game.pendingNexusIxianReplacement, null);
game = next.actor === f.buyer
? applyAction(game, f.buyer, { type: 'richeseBid', event: lot.event,
amount: lot.bid === 0 ? 1 : null })
: applyAction(game, next.actor, next.action);
}
// The cache purchase retires its own lot with no replacement frame, while the
// same buyer's normal purchase above did open one.
assert.equal(game.richeseAuction, null);
assert.equal(game.pendingNexusIxianReplacement, null);
assert.equal(game.decision, null);
assert.equal(game.currentAuctionSale, null);
assert.ok(owner(game, f.buyer).hand.some(card => card.id === cacheCard.id));
assert.ok(!game.richeseCache!.some(card => card.id === cacheCard.id));
assert.equal(owner(game, f.buyer).spice, before.buyer - 1);
assert.equal(owner(game, richese).spice, before.richese + 1);
assert.equal(owner(game, f.emperor!).spice, before.emperor);
assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(game.richeseBidding!.stage, 'complete');
assert.equal(game.phase, 4);
});
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
createRicheseIxianSpecialReplacementFixture, settleRicheseIxianSpecialReplacementSuffix,
type RicheseIxianSpecialReplacementOrigin, type RicheseIxianSpecialReplacementSaleFixture,
type RicheseIxianSpecialReplacementUnbidFixture,
} from './fixture-richese-ixian-special-replacement';
const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const BUILT: Record<string, RicheseIxianSpecialReplacementSaleFixture> = {};
/** One genuine native run per source/mode; each test reloads its own copy. */
function saleFixture(
origin: RicheseIxianSpecialReplacementOrigin,
advanced: boolean,
): RicheseIxianSpecialReplacementSaleFixture {
const key = `${origin}:${advanced}`;
const held = BUILT[key];
if (held) return held;
const created = createRicheseIxianSpecialReplacementFixture({ origin, advanced });
if (created.mode !== 'sold') throw new Error('The paid native source sale is missing.');
BUILT[key] = created;
return created;
}
const UNBID: Partial<Record<RicheseIxianSpecialReplacementOrigin, RicheseIxianSpecialReplacementUnbidFixture>> = {};
function unbidFixture(origin: RicheseIxianSpecialReplacementOrigin): RicheseIxianSpecialReplacementUnbidFixture {
const held = UNBID[origin];
if (held) return held;
const created = createRicheseIxianSpecialReplacementFixture({ origin, mode: 'unbid' });
if (created.mode !== 'unbid') throw new Error('The genuine unbid outcome is missing.');
UNBID[origin] = created;
return created;
}
const SOURCES: [RicheseIxianSpecialReplacementOrigin, boolean][] = [
['cache', false], ['cache', true], ['blackMarket', true],
];
for (const [origin, advanced] of SOURCES)
void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Once Around purchase is already paid when its purchased-card offer opens`, () => {
const f = saleFixture(origin, advanced);
// The public offer binds the exact concealed/delivered native card, never a
// top deck card and never an unsold lot card.
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.equal(offer.origin, origin);
assert.equal(offer.canPass, true);
assert.equal(offer.canUse, true);
assert.deepEqual(offer.purchased, f.purchased);
// The genuine payment is already debited exactly once; the seller is not yet
// credited and no Emperor gift exists for this unallied native sale.
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - f.bid);
assert.equal(owner(f.game, f.seller).spice, owner(f.beforePurchase, f.seller).spice);
assert.equal(owner(f.game, f.emperor!).spice, owner(f.beforePurchase, f.emperor!).spice);
assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.deepEqual(owner(f.game, f.buyer).hand,
[...owner(f.beforePurchase, f.buyer).hand, f.purchased]);
if (origin === 'cache') {
assert.deepEqual(f.game.richeseCache,
f.cacheBefore.filter(card => card.id !== f.purchased.id));
assert.equal(owner(f.beforePurchase, f.seller).hand.some(card => card.id === f.purchased.id), false);
} else {
assert.deepEqual(f.game.richeseCache, f.cacheBefore,
'A Black Market sale never touches the compulsory cache.');
assert.equal(owner(f.beforePurchase, f.seller).hand.some(card => card.id === f.purchased.id), true);
assert.equal(owner(f.game, f.seller).hand.some(card => card.id === f.purchased.id), false);
}
});
for (const [origin, advanced] of SOURCES)
void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Use spends the Ixian Nexus on that card and leaves the original native suffix intact`, () => {
const f = saleFixture(origin, advanced);
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const used = settleRicheseIxianSpecialReplacementSuffix(applyAction(structuredClone(f.game), f.buyer,
{ type: 'nexusIxianReplacementUse', event: offer.event }), origin);
// Only the purchased native card is discarded and only the actual top card
// is drawn; the paid purchase is never re-debited or credited.
assert.deepEqual(owner(used, f.buyer).hand,
[...owner(f.game, f.buyer).hand.filter(card => card.id !== f.purchased.id), f.nextCard]);
assert.deepEqual(used.discard, [...f.game.discard, f.purchased]);
assert.deepEqual(used.deck, f.game.deck.slice(1));
assert.equal(used.nexusCards!.cards!.hands[f.buyer], null);
assert.deepEqual(used.nexusCards!.cards!.discard,
[...f.game.nexusCards!.cards!.discard, 'ixians']);
assert.equal(owner(used, f.buyer).spice, f.paidSpice);
assert.equal(owner(used, f.seller).spice, f.sellerSpiceAtOffer + f.bid);
assert.equal(owner(used, f.emperor!).spice, f.emperorSpiceAtOffer);
assert.equal(used.pendingNexusIxianReplacement, null);
assert.equal(used.currentAuctionSale, null);
assert.equal(used.richeseAuction, null);
assert.equal(used.richeseBidding!.blackMarketSold, origin === 'blackMarket');
if (origin === 'cache') {
assert.deepEqual(used.richeseCache, f.game.richeseCache);
assert.equal(used.richeseBidding!.stage, 'complete');
assert.equal(used.decision, null);
assert.equal(used.phase, 4);
} else {
assert.deepEqual(used.richeseCache, f.cacheBefore);
assert.equal(used.richeseBidding!.stage, 'declaration');
assert.deepEqual(used.decision, { kind: 'richeseDeclaration', player: f.seller });
}
});
for (const [origin, advanced] of SOURCES)
void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Keep retains the exact purchased card and the Ixian Nexus while the original seller is credited once`, () => {
const f = saleFixture(origin, advanced);
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const kept = settleRicheseIxianSpecialReplacementSuffix(applyAction(structuredClone(f.game), f.buyer,
{ type: 'nexusIxianReplacementPass', event: offer.event }), origin);
assert.deepEqual(owner(kept, f.buyer).hand, owner(f.game, f.buyer).hand);
assert.ok(owner(kept, f.buyer).hand.some(card => card.id === f.purchased.id));
assert.deepEqual(kept.deck, f.game.deck);
assert.deepEqual(kept.discard, f.game.discard);
assert.deepEqual(kept.nexusCards!.cards!.hands[f.buyer], f.game.nexusCards!.cards!.hands[f.buyer]);
assert.equal(kept.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(owner(kept, f.buyer).spice, f.paidSpice);
assert.equal(owner(kept, f.seller).spice, f.sellerSpiceAtOffer + f.bid);
assert.equal(owner(kept, f.emperor!).spice, f.emperorSpiceAtOffer);
assert.equal(kept.pendingNexusIxianReplacement, null);
assert.equal(kept.currentAuctionSale, null);
assert.equal(kept.richeseAuction, null);
assert.deepEqual(kept.richeseCache,
origin === 'cache' ? f.game.richeseCache : f.cacheBefore);
if (origin === 'cache') {
assert.equal(kept.richeseBidding!.stage, 'complete');
assert.equal(kept.decision, null);
assert.equal(kept.phase, 4);
assert.equal(kept.richeseBidding!.blackMarketSold, false);
} else {
assert.equal(kept.richeseBidding!.stage, 'declaration');
assert.deepEqual(kept.decision, { kind: 'richeseDeclaration', player: f.seller });
assert.equal(kept.richeseBidding!.blackMarketSold, true);
}
});
void test('Basic native unbid cache lot is not a purchase: the owner keeps the card with no spice, Nexus spend or offer', () => {
const f = unbidFixture('cache');
// The compulsory cache lot really ran its own Once Around and every genuine
// offer was zero, so the lot retired without a sale.
assert.equal(f.game.richeseAuction, null);
assert.equal(f.game.pendingNexusIxianReplacement, null);
assert.notEqual(f.game.decision?.kind, 'nexusIxianReplacement');
assert.equal(f.game.currentAuctionSale, null);
assert.equal(f.game.richeseBidding!.stage, 'complete');
assert.equal(f.game.richeseBidding!.blackMarketSold, false);
// A Keep is not a purchase: no spice moved anywhere and no Emperor gift exists.
for (const seat of f.game.players)
assert.equal(owner(f.game, seat.id).spice, owner(f.beforeFinalAction, seat.id).spice);
assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians');
// The kept card left the original cache into the owner's own hand.
assert.equal(f.game.richeseCache!.some(card => card.id === f.card.id), false);
assert.equal(owner(f.game, f.seller).hand.some(card => card.id === f.card.id), true);
assert.equal((f.game.richeseRemoved ?? []).some(card => card.id === f.card.id), false);
assert.deepEqual(f.game.richeseCache, f.cacheBefore.filter(card => card.id !== f.card.id));
});
void test('Advanced native unbid Black Market lot retains its concealed card with no sale, payment or offer', () => {
const f = unbidFixture('blackMarket');
assert.equal(f.game.richeseAuction, null);
assert.equal(f.game.pendingNexusIxianReplacement, null);
assert.equal(f.game.currentAuctionSale, null);
assert.equal(f.game.richeseBidding!.blackMarketSold, false);
assert.equal(f.game.richeseBidding!.stage, 'declaration');
assert.deepEqual(f.game.decision, { kind: 'richeseDeclaration', player: f.seller });
for (const seat of f.game.players)
assert.equal(owner(f.game, seat.id).spice, owner(f.beforeFinalAction, seat.id).spice);
assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.ok(owner(f.game, f.seller).hand.some(card => card.id === f.card.id));
assert.ok(f.keptCacheCards.includes(f.card.id));
assert.deepEqual(f.game.richeseCache, f.cacheBefore,
'An unbid Black Market lot must not consume a compulsory cache card.');
});