298 lines
17 KiB
TypeScript
298 lines
17 KiB
TypeScript
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 && !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;
|
|
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,
|
|
};
|
|
}
|