Files
Dune/tests/fixture-richese-ixian-special-replacement.ts
T

395 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
};
}