Connect native Richese normal-purchase Ixian Nexus replacement
This commit is contained in:
1 parent
7ef00e0a65
commit
634ed293af
13 files changed
+720
-33
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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!,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user