Connect native Richese normal-purchase Ixian Nexus replacement

This commit is contained in:
Quintine committed 2026-10-16 04:01:06 +11:00
1 parent 7ef00e0a65
commit 634ed293af
13 files changed
+720 -33

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeIxianNexusReplacementGameForAudit, joinGame,
newPlayer, type Action, type Game,
} from '../game/engine';
import { choamDeck, treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import type { IxianReplacementFixture } from './fixture-nexus-ixian-replacement';
export type RicheseIxianReplacementStep = { actor: string; action: Action };
export type RicheseIxianReplacementOptions = {
/** Continue only a fresh authenticated undealt Richese/CHOAM lobby or its admitted setup. */
initial?: Game;
advanced?: boolean;
/** Seated seat that makes the original normal purchase; Richese may buy too. */
buyerFaction?: 'atreides' | 'richese';
/** Shared CHOAM addition bought as an ordinary normal-auction lot. */
purchasedId?: string;
/** Other shared CHOAM addition left as the real top card after that purchase. */
replacementId?: string;
/** Physical cache card the genuine turn-one cache lot offers. */
cacheCardId?: string;
/** Buy the final normal lot so the native pool closes instead of advancing. */
endingAuction?: boolean;
};
export type RicheseIxianReplacementFixture = IxianReplacementFixture & {
/** Admitted fresh setup before its first turn; only undealt decks were ordered. */
setup: Game;
beforeNexusDraw: Game;
nexusDrawAction: Action;
nextCard: Card;
/** Native normal lot count for this Richese round (the cache reduction). */
rowSize: number;
advanced: boolean;
expansions: readonly string[];
factions: string[];
/** The original ten-card cache minus only the cards a real cache lot kept. */
cacheAtPurchase: Card[];
keptCacheCards: string[];
};
/** Native policy for the fresh Richese/CHOAM Nexus profile. Richese's own
* windows come first: the compulsory cache lot is announced last so the normal
* pool leads, no Black Market card is offered, and the turn-one cache lot is a
* real Once Around whose bids are all genuine zero offers. Every other window
* is the ordinary native no-spend policy, with no card, spice or force staged. */
export function nextRicheseIxianReplacementNativeStep(
game: Game,
cacheCardId?: string,
): RicheseIxianReplacementStep {
const round = game.richeseBidding;
const decision = game.decision;
if (decision?.kind === 'richeseDeclaration') {
assert.ok(round, 'The native Richese round must still be open.');
return { actor: decision.player,
action: { type: 'decision', event: round.event, position: 'last' } };
}
if (decision?.kind === 'richeseBlackMarket') {
assert.ok(round, 'A Black Market offer needs its original Richese round.');
return { actor: decision.player,
action: { type: 'decision', event: round.event, decline: true } };
}
if (decision?.kind === 'richeseCache') {
const cache = game.richeseCache;
assert.ok(round, 'The cache offer needs its original Richese round.');
assert.ok(cache?.length, 'The cache offer needs its physical cache.');
const card = cache.find(candidate => candidate.id === cacheCardId) ?? cache[0];
return { actor: decision.player, action: { type: 'decision', event: round.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (decision?.kind === 'richeseUnbid') {
assert.ok(round, 'An unbid cache card needs its original Richese round.');
const owner = game.players.find(player => player.id === decision.player);
assert.ok(owner, 'The unbid cache card needs its physical owner.');
return { actor: decision.player, action: { type: 'decision', event: round.event,
keep: owner.hand.length < handLimit(owner) } };
}
const lot = game.richeseAuction;
if (!decision && lot && !lot.outcome) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))
: lot.active;
assert.ok(actor, 'The original Richese lot must still name its next bidder.');
return { actor, action: { type: 'richeseBid', event: lot.event,
amount: lot.method === 'silent' ? 0 : null } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, 'The native Richese/CHOAM turn must still supply a next step.');
return next;
}
function step(game: Game, cacheCardId?: string): Game {
const next = nextRicheseIxianReplacementNativeStep(game, cacheCardId);
return applyAction(game, next.actor, next.action);
}
/** Only reorders still-undealt original cards; no hand, lot or Nexus receipt is
* manufactured. The requested buyer draws its printed Karama first. */
function orderStartingCards(game: Game, buyer: string, reserved: readonly string[]): void {
const pool = [...game.deck];
const front: Card[] = [];
for (const player of game.players) {
const index = pool.findIndex(card => !reserved.includes(card.id) &&
(player.id === buyer ? card.effect === 'karama' : card.effect !== 'karama'));
assert.ok(index >= 0, 'A conserved original starting face must remain undealt.');
front.push(pool.splice(index, 1)[0]);
}
game.deck = [...front, ...pool.filter(card => !reserved.includes(card.id)),
...pool.filter(card => reserved.includes(card.id))];
}
/** Proves the purchase and the replacement stay inside their own original
* inventories: the shared CHOAM deck is conserved apart from the ten-card
* Richese cache, which is conserved with whatever a real cache lot kept. */
function assertOriginalInventory(game: Game): void {
const physical = [
...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard,
...(game.auction?.cards.slice(game.auction.index) ?? []),
];
const cache = physical.filter(card => card.id.startsWith('richese-'));
assert.deepEqual(physical.filter(card => !cache.includes(card)).map(card => card.id).sort(),
treacheryDeck(['choam']).map(card => card.id).sort());
assert.deepEqual([...game.richeseCache!.map(card => card.id),
...(game.richeseRemoved ?? []).map(card => card.id), ...cache.map(card => card.id)].sort(),
richeseCards().map(card => card.id).sort());
}
/** Fresh native Richese + classics with CHOAM alone: a real first turn, the
* genuine closing Nexus draw by an unallied buyer, and an original paid normal
* purchase of a shared CHOAM addition. Richese's own cache stays a separate
* ten-card collection (the compulsory cache lot runs Once Around and is kept,
* never bought through the Nexus replacement). Returned before the payment. */
export function createRicheseIxianReplacementFixture(
options: RicheseIxianReplacementOptions = {},
): RicheseIxianReplacementFixture {
const purchasedId = options.purchasedId ?? 'ix-poison-tooth';
const replacementId = options.replacementId ?? 'ix-artillery';
assert.notEqual(purchasedId, replacementId);
for (const id of [purchasedId, replacementId])
assert.ok(choamDeck().some(card => card.id === id),
'Only an original shared CHOAM addition may be bought or become the replacement.');
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
game.players.every(player => player.hand.length === 0)),
'Continue only an admitted fresh lobby or its undealt native prototype setup.');
} else {
game = createGame('RICNIXREP', newPlayer('richese', 'Richese', 'richese'),
options.advanced ?? false, ['choam']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.deepEqual(game.expansions, ['choam'], 'This profile is CHOAM alone, with no Ix deck.');
const buyerSeat = game.players.find(player => player.faction === (options.buyerFaction ?? 'atreides'));
assert.ok(buyerSeat, 'The requested buyer faction must be seated in the Richese roster.');
assert.ok(game.players.some(player => player.faction === 'richese'),
'The native profile needs its seated Richese.');
const buyer = buyerSeat.id;
const partners = game.players.filter(player => player.id !== buyer);
assert.equal(partners.length, 2, 'The closing Nexus needs two other seats to ally.');
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game.nexusCards ??= { cards: null, phase: null };
game = initializeIxianNexusReplacementGameForAudit(game);
}
const cache = game.richeseCache;
assert.ok(cache, 'The admitted Richese profile must own its physical cache.');
assert.equal(cache.length, richeseCards().length);
const cacheCardId = options.cacheCardId ?? cache[0].id;
assert.ok(cache.some(card => card.id === cacheCardId),
'The chosen cache card must be one of the original ten.');
assert.ok(game.deck.some(card => card.id === purchasedId));
assert.ok(game.deck.some(card => card.id === replacementId));
orderStartingCards(game, buyer, [purchasedId, replacementId]);
const nexus = game.nexusCards!.cards;
assert.ok(nexus, 'The fresh Nexus profile must own its physical cards.');
assert.ok(Object.values(nexus.hands).every(face => face === null),
'No Nexus card is held before the closing Nexus.');
assert.ok(nexus.deck.includes('ixians'));
nexus.deck = ['ixians', ...nexus.deck.filter(face => face !== 'ixians')];
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game, cacheCardId);
assert.equal(game.status, 'playing');
assert.ok(game.players.every(player => player.hand.length === 1),
'Every seat holds exactly its one original starting card.');
assert.ok(game.players.find(player => player.id === buyer)!.hand.some(card => card.effect === 'karama'),
'The buyer holds its printed Karama while still paying its own spice.');
// Only the two still-unplayed Spice positions are ordered so the first turn
// cannot supply the closing worm; every later card is native.
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, at) => at >= position && 'territory' in card);
assert.ok(index >= position, 'A conserved original territory Spice Card must remain.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
const turnOne = game.turn;
for (let count = 0; count < 1200; count++) {
if (game.turn > turnOne && game.phase === 0 && !game.phaseOpening && !game.response && !game.decision) break;
game = step(game, cacheCardId);
}
assert.equal(game.turn, 2, 'A complete native first turn must supply the next Storm.');
assert.equal(game.richeseCache!.length, richeseCards().length - 1,
'Exactly one compulsory cache lot ran, keeping its single original card.');
for (let count = 0; game.phase !== 1 && count < 300; count++) game = step(game, cacheCardId);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
assert.ok(worm >= 0, 'An original worm Spice Card must remain for the closing Nexus.');
game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]);
for (let count = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
!game.phaseOpening && !game.response && !game.decision) && count < 300; count++) game = step(game, cacheCardId);
assert.equal(game.phase, 1);
assert.equal(game.nexus, true);
// A real alliance between the two non-recipients leaves the buyer unallied.
game = applyAction(game, partners[0].id, { type: 'alliance', target: partners[1].id });
game = applyAction(game, partners[1].id, { type: 'alliance', target: partners[0].id });
for (let count = 0; game.nexusCards?.phase?.stage !== 'drawing' && count < 300; count++) game = step(game, cacheCardId);
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const beforeNexusDraw = structuredClone(game);
const nexusDrawAction: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
game = applyAction(game, buyer, 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 });
// Reach the genuine Richese declaration of the auction turn, then order only
// the still-undealt deck before the native row allocation consumes it.
for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 400; count++) game = step(game, cacheCardId);
const declaration = game.decision;
assert.equal(declaration?.kind, 'richeseDeclaration');
const round = game.richeseBidding!;
const rowSize = game.richeseBidding!.normalCount!;
assert.ok(Number.isSafeInteger(rowSize) && rowSize >= 1,
'The original Richese round must schedule at least one normal lot.');
const purchaseIndex = options.endingAuction ? rowSize - 1 : 0;
const pool = [...game.deck];
const reserve = (id: string): Card => {
const at = pool.findIndex(card => card.id === id);
assert.ok(at >= 0, `The still-undealt deck must hold ${id}.`);
return pool.splice(at, 1)[0];
};
const originalPurchase = reserve(purchasedId);
const originalReplacement = reserve(replacementId);
game.deck = [...pool];
game.deck.splice(purchaseIndex, 0, originalPurchase);
// Native allocation takes one lot per scheduled seat; the remaining deck top
// is the requested original replacement, never a reserved physical lot.
game.deck.splice(rowSize, 0, originalReplacement);
game = applyAction(game, declaration.player, { type: 'decision', event: round.event, position: 'last' });
for (let count = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && count < 400; count++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game, game.decision.player, { type: 'decision', karama: false });
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = step(game, cacheCardId);
else if (game.auction.index < purchaseIndex) {
const active = game.auction.active;
game = applyAction(game, active, active !== buyer && game.auction.bid === 0
? { type: 'bid', amount: 1 } : { type: 'passBid' });
} else if (game.auction.active === buyer && game.auction.bid === 0)
game = applyAction(game, buyer, { type: 'bid', amount: 2 });
else game = applyAction(game, game.auction.active, { type: 'passBid' });
}
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: buyer });
const auction = game.auction!;
assert.equal(auction.cards[auction.index].id, purchasedId);
assert.equal(auction.bid, 2);
assert.equal(auction.allyPayment ?? 0, 0);
assert.equal(game.deck[0].id, replacementId);
assert.equal(game.richeseBidding!.stage, 'normal');
assertOriginalInventory(game);
const beforePayment = structuredClone(game);
const owner = game.players.find(player => player.id === buyer)!;
const emperor = game.players.find(player => player.faction === 'emperor')?.id ?? null;
const beforeEmperorSpice = emperor ? game.players.find(player => player.id === emperor)!.spice : null;
const beforeSpice = owner.spice;
const purchased = structuredClone(auction.cards[auction.index]);
const cacheAtPurchase = structuredClone(game.richeseCache!);
const keptCacheCards = game.players.flatMap(player => player.hand)
.filter(card => card.id.startsWith('richese-')).map(card => card.id);
const paymentAction: Action = { type: 'decision', karama: false };
game = applyAction(game, buyer, paymentAction);
assert.ok(game.pendingNexusIxianReplacement, 'The normal purchase must offer the Nexus replacement.');
assert.equal(game.pendingNexusIxianReplacement.buyer, buyer);
assert.equal(game.decision?.kind, 'nexusIxianReplacement');
assert.deepEqual(game.richeseCache, cacheAtPurchase, 'The purchase must not touch the Richese cache.');
return {
setup, beforeNexusDraw, nexusDrawAction, beforePayment, game, buyer,
event: game.pendingNexusIxianReplacement.event, purchased, paymentAction, beforeSpice,
paidSpice: game.players.find(player => player.id === buyer)!.spice, emperor, beforeEmperorSpice,
nextCard: structuredClone(game.deck[0]), auctionIndex: beforePayment.auction!.index,
rowSize, advanced: game.advanced, expansions: game.expansions,
factions: game.players.map(player => player.faction),
cacheAtPurchase, keptCacheCards,
};
}
+1
View File
@@ -93,6 +93,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R
] as Card[]) {
const ctx: IxianReplacementContext = {
status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence,
expansions: f.game.expansions, richese: false,
auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) =>
index === f.auctionIndex ? corrupt : card) },
sale: f.game.currentAuctionSale!,
+123 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, type Card } from '../game/cards';
import { baseDeck, choamDeck, ixDeck, treacheryDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
IxianReplacementError,
closeIxianReplacementSource,
@@ -19,6 +20,7 @@ const parent = 'original-native-paid-sale-parent';
function context(): IxianReplacementContext {
return {
status: 'playing', phase: 3, turn: 1, sequence: 0,
expansions: [], richese: false,
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
players: [
@@ -28,6 +30,17 @@ function context(): IxianReplacementContext {
physicalCards: [{ ...older }, { ...purchased }, { ...top }, { ...unsold }],
};
}
function purchaseContext(
card: Card, expansions: readonly string[], richese = false,
): IxianReplacementContext {
const ctx = context();
ctx.expansions = expansions;
ctx.richese = richese;
ctx.auction!.cards = [{ ...card }];
ctx.players = [{ ...ctx.players[0], hand: [{ ...card }] }, ctx.players[1]];
ctx.physicalCards = [{ ...card }];
return ctx;
}
function source(ctx: IxianReplacementContext = context()) {
return createIxianReplacementSource(
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent,
@@ -84,6 +97,115 @@ void test('canonical descriptors cannot be redefined by a matching auction row a
}
});
void test('selected CHOAM retains all original 35 faces alongside separate native Richese custody', () => {
const deck = treacheryDeck(['choam']);
const cache = richeseCards();
assert.equal(deck.length, 35);
assert.equal(cache.length, 10);
assert.deepEqual(choamDeck().map(card => card.id).sort(), ['ix-artillery', 'ix-poison-tooth']);
for (const card of deck) {
const ctx = purchaseContext(card, ['choam'], true);
ctx.physicalCards = [...deck, ...cache];
assert.deepEqual(source(ctx).card, card);
}
});
void test('base and Ix families remain selected by actual membership, including shared CHOAM identities', () => {
for (const card of baseDeck()) {
assert.deepEqual(source(purchaseContext(card, [])).card, card);
assert.deepEqual(source(purchaseContext(card, ['choam'])).card, card);
}
const shared = choamDeck();
for (const card of ixDeck()) {
assert.deepEqual(source(purchaseContext(card, ['ix'])).card, card);
assert.deepEqual(source(purchaseContext(card, ['ix', 'choam'])).card, card);
assert.throws(() => source(purchaseContext(card, [])), IxianReplacementError);
const choamOnly = purchaseContext(card, ['choam']);
if (shared.some(member => member.id === card.id))
assert.deepEqual(source(choamOnly).card, card);
else
assert.throws(() => source(choamOnly), IxianReplacementError);
}
});
void test('every original cache face requires both selected CHOAM and native Richese even after normal recycling', () => {
for (const card of richeseCards()) {
const ctx = purchaseContext(card, ['choam'], true);
assert.deepEqual(source(ctx).card, card);
for (const expansions of [[], ['ix'], ['choam'], ['ix', 'choam']]) {
for (const richese of [false, true]) {
if (expansions.includes('choam') && richese) continue;
assert.throws(() => source(purchaseContext(card, expansions, richese)), IxianReplacementError);
}
}
for (const origin of ['cache', 'blackMarket'] as const) {
const special = purchaseContext(card, ['choam'], true);
special.sale = { ...special.sale!, origin };
assert.throws(() => source(special), IxianReplacementError);
}
}
});
void test('an ordinary purchase cannot hide unsupported card families elsewhere in live custody', () => {
for (const card of [...ixDeck(), ...richeseCards()]) {
const ctx = context();
ctx.physicalCards = [...ctx.physicalCards, card];
assert.throws(() => source(ctx), IxianReplacementError);
}
for (const card of richeseCards()) {
for (const [expansions, richese] of [
[['choam'], false], [['ix'], true], [[], true],
] as const) {
const ctx = context();
ctx.expansions = expansions;
ctx.richese = richese;
ctx.players = [ctx.players[0], { ...ctx.players[1], hand: [card] }];
ctx.physicalCards = [...ctx.physicalCards, card];
assert.throws(() => source(ctx), IxianReplacementError);
}
}
});
void test('CHOAM and cache faces reject altered descriptors even with matching row, hand and custody', () => {
for (const card of [...treacheryDeck(['choam']), ...richeseCards()]) {
const altered: Card[] = [
{ ...card, name: `${card.name} altered` },
{ ...card, kind: card.kind === 'special' ? 'projectile' : 'special' },
{ ...card, effect: 'fabricated' },
{ ...card, id: `${card.id}-copy` },
{ ...card, extra: true } as Card,
];
if (card.effect !== undefined) {
const missingEffect = { ...card };
delete missingEffect.effect;
altered.push(missingEffect);
}
for (const corrupt of altered)
assert.throws(() => source(purchaseContext(corrupt, ['choam'], true)), IxianReplacementError);
}
});
void test('historical receipts admit the full canonical union without weakening live family gates', () => {
const [shared] = choamDeck();
const [cache] = richeseCards();
const original = source(purchaseContext(shared, ['choam'], true));
const first = closeIxianReplacementSource(original, 'use', cache, initialIxianReplacementCursor());
const later = purchaseContext(cache, ['choam'], true);
later.turn = 2;
later.sequence = first.cursor.sequence;
const cacheSource = source(later);
const second = closeIxianReplacementSource(cacheSource, 'use', cache, first.cursor);
const history = JSON.parse(JSON.stringify([first.receipt, second.receipt]));
validateIxianReplacementHistory(history, second.cursor, [...treacheryDeck(['ix']), ...richeseCards()]);
assert.equal(second.receipt.replacement?.id, cacheSource.card.id);
assert.throws(() => validateIxianReplacementSource(
{ ...later, richese: false }, cacheSource, parent,
), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory(history, second.cursor, [
...treacheryDeck(['ix']), ...richeseCards(), { ...cache },
]), IxianReplacementError);
});
void test('duplicate physical cards reject even when the purchased hand itself looks legal', () => {
for (const duplicate of [purchased, older, top, unsold]) {
const ctx = context();
+172
View File
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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,
type RicheseIxianReplacementFixture,
} from './fixture-richese-ixian-replacement';
const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, id: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
const BUILT: Record<string, RicheseIxianReplacementFixture> = {};
/** One genuine native Richese run per roster/mode; each test reloads its own copy. */
function fixtureFor(
advanced: boolean,
buyerFaction: 'atreides' | 'richese',
endingAuction = false,
): RicheseIxianReplacementFixture {
const key = `${advanced}:${buyerFaction}:${endingAuction}`;
const held = BUILT[key];
if (held) return held;
const created = createRicheseIxianReplacementFixture({ advanced, buyerFaction, endingAuction });
BUILT[key] = created;
return created;
}
for (const advanced of [false, true])
for (const buyerFaction of ['atreides', 'richese'] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} native ${buyerFaction} normal CHOAM purchase spends the Nexus for the real top replacement`, () => {
const f = fixtureFor(advanced, buyerFaction);
assert.equal(f.purchased.id, 'ix-poison-tooth');
assert.equal(f.nextCard.id, 'ix-artillery');
assert.equal(f.rowSize, 2);
// The cache stayed its own original collection: only the one genuine Once
// Around keep left it, and the normal purchase itself never touched it.
assert.equal(f.keptCacheCards.length, 1);
assert.ok(f.setup.richeseCache!.some(card => card.id === f.keptCacheCards[0]));
assert.equal(f.cacheAtPurchase.length, richeseCards().length - 1);
assert.equal(f.beforePayment.currentAuctionSale, null);
assert.equal(f.beforePayment.auction!.cards[f.auctionIndex].id, f.purchased.id);
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.equal(offer.canUse, true);
assert.equal(offer.canPass, true);
assert.deepEqual(offer.purchased, f.purchased);
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 2);
assert.equal(owner(f.game, f.emperor!).spice, f.beforeEmperorSpice);
const partners = f.beforeNexusDraw.players.filter(player => player.id !== f.buyer);
for (const partner of partners)
for (const type of ['nexusIxianReplacementUse', 'nexusIxianReplacementPass'] as const)
reject(f.game, partner.id, { type, event: offer.event });
const deckBefore = structuredClone(f.game.deck);
const unsold = structuredClone(f.game.auction!.cards.slice(f.auctionIndex + 1));
const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementUse', event: offer.event }));
assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]);
assert.deepEqual(use.deck, deckBefore.slice(1));
assert.deepEqual(use.discard, [...f.game.discard, f.purchased]);
assert.equal(use.nexusCards!.cards!.hands[f.buyer], null);
assert.deepEqual(use.nexusCards!.cards!.discard, [...f.game.nexusCards!.cards!.discard, 'ixians']);
assert.equal(owner(use, f.buyer).spice, f.paidSpice);
assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(use.currentAuctionSale, null);
assert.equal(use.pendingNexusIxianReplacement, null);
assert.deepEqual(use.richeseCache, f.cacheAtPurchase);
assert.equal(use.richeseBidding!.stage, 'normal');
assert.equal(use.auction!.index, f.auctionIndex + 1);
assert.deepEqual(use.auction!.cards.slice(use.auction!.index), unsold);
reject(use, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event });
});
for (const advanced of [false, true])
for (const buyerFaction of ['atreides', 'richese'] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} native ${buyerFaction} normal CHOAM purchase retains its card and Nexus card`, () => {
const f = fixtureFor(advanced, buyerFaction);
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const pass = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementPass', event: offer.event }));
assert.deepEqual(owner(pass, f.buyer).hand, owner(f.game, f.buyer).hand);
assert.ok(owner(pass, f.buyer).hand.some(card => card.id === f.purchased.id));
assert.deepEqual(pass.deck, f.game.deck);
assert.deepEqual(pass.discard, f.game.discard);
assert.deepEqual(pass.nexusCards!.cards, f.game.nexusCards!.cards);
assert.equal(pass.nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(owner(pass, f.buyer).spice, f.paidSpice);
assert.equal(owner(pass, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(pass.currentAuctionSale, null);
assert.equal(pass.pendingNexusIxianReplacement, null);
assert.deepEqual(pass.richeseCache, f.cacheAtPurchase);
assert.equal(pass.auction!.index, f.auctionIndex + 1);
});
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} native final-lot CHOAM purchase closes the pool into the untouched Richese cache offer`, () => {
const f = fixtureFor(advanced, 'atreides', true);
assert.equal(f.auctionIndex, f.rowSize - 1);
assert.equal(f.purchased.id, 'ix-poison-tooth');
assert.equal(f.nextCard.id, 'ix-artillery');
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.deepEqual(offer.purchased, f.purchased);
const richese = f.game.players.find(player => player.faction === 'richese')!.id;
const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementUse', event: offer.event }));
assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]);
assert.deepEqual(use.discard, [...f.game.discard, f.purchased]);
assert.equal(owner(use, f.buyer).spice, f.paidSpice);
assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(use.auction, null);
assert.deepEqual(use.decision, { kind: 'richeseCache', player: richese });
assert.equal(use.richeseBidding!.stage, 'cacheOffer');
assert.equal(use.richeseBidding!.cacheCanceled, false);
assert.deepEqual(use.richeseCache, f.cacheAtPurchase);
assert.equal(use.pendingNexusIxianReplacement, null);
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);
});