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

223 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import type { Card } from '../game/cards';
import {
createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep,
type RicheseIxianReplacementFixture, type RicheseIxianReplacementStep,
} from './fixture-richese-ixian-replacement';
export type RicheseIxianDirectReplacementContext = 'quiet' | 'auctionPayment' | 'income' | 'revival';
export type RicheseIxianDirectReplacementOptions = {
context?: RicheseIxianDirectReplacementContext;
/** Defaults to true; false uses fresh native Richese/Atreides/Guild + CHOAM. */
emperor?: boolean;
/** Only a fresh undealt native Richese/classics CHOAM lobby or admitted setup. */
initial?: Game;
/** Original shared CHOAM normal lot used by the base native purchase. */
purchasedId?: string;
/** Original shared CHOAM replacement reserved in the still-undealt deck. */
replacementId?: string;
/** Original cache card kept by the genuine first-turn compulsory lot. */
cacheCardId?: string;
/** Still-present original cache face, excluding any card reserved by a live lot. */
targetId?: string;
};
export type RicheseIxianDirectReplacementFixture = {
context: RicheseIxianDirectReplacementContext;
setup: Game;
beforeNexusDraw: Game;
nexusDrawAction: Action;
/** The base recipe's actual original normal payment and its pre-payment state. */
base: RicheseIxianReplacementFixture;
beforePurchase: Game;
actor: string;
action: Action;
game: Game;
event: string | null;
buyer: string;
cost: Card;
target: Card;
nextCard: Card;
price: 3;
emperor: string | null;
beforeSpice: number;
paidSpice: number;
beforeEmperorSpice: number | null;
originalControls: Pick<Game, 'active' | 'response' | 'decision' | 'pendingKarama'>;
funding: { wallet: number; reservedNormalBid: number; uncommitted: number };
provenance: {
/** Actual actions from the base checkpoint, not a synthetic source reconstruction. */
checkpoint: 'beforeNexusDraw' | 'beforePayment' | 'normalOffer';
steps: RicheseIxianReplacementStep[];
/** Quiet replay alone orders these original undealt cards at declaration. */
undealtDeclarationOrder: string[] | null;
};
};
const owner = (game: Game, id: string) => {
const seat = game.players.find(player => player.id === id);
assert.ok(seat, 'The original Richese/classics seat must remain seated.');
return seat;
};
/** Original native suffix driver; a later NORMAL purchase may open a distinct
* purchased-card offer after the direct Keep. Keep that separate source rather
* than clearing it or pretending the original payment stalled. */
export function nextRicheseIxianDirectReplacementNativeStep(
game: Game,
cacheCardId?: string,
): RicheseIxianReplacementStep {
const source = game.pendingNexusIxianReplacement;
if (source) return { actor: source.buyer,
action: { type: 'nexusIxianReplacementPass', event: source.event } };
return nextRicheseIxianReplacementNativeStep(game, cacheCardId);
}
/** Settle only the purchase's original responses/payment and a possible fresh
* normal Keep offer. Stop before another bid or native cache decision. Never
* advance the row by hand, clear controls, replay a quote or pay a source twice. */
export function settleRicheseIxianDirectReplacementSuffix(state: Game): Game {
let game = state;
for (let count = 0; count < 100; count++) {
const source = game.pendingNexusIxianReplacement;
if (source) {
assert.equal(viewGame(game, source.buyer).nexusIxianReplacement?.origin, 'normal',
'Choose the direct purchased-card offer before settling its suffix.');
} else if (!game.pendingTreacheryDiscard && !game.response &&
game.decision?.kind !== 'auctionPayment') return game;
const next = nextRicheseIxianDirectReplacementNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
throw new Error('The original direct-purchase controls did not settle.');
}
/** Advanced native Richese, genuinely unallied and holding its naturally drawn
* Ixian Nexus and its printed starting Karama. The original base recipe supplies
* the complete first turn, worm, alliance, Nexus draw and normal purchase.
*
* Quiet replays its before-draw checkpoint and stops at bid zero, without ever
* buying that normal lot. AuctionPayment instead suspends the original funded
* bid 2 (wallet 5, three uncommitted). Income Keeps the normal purchase without
* settling Emperor income 2. Revival Keeps that purchase and follows the real
* remaining normal bids/cache lot into phase 4, where the same special purchase
* is legal but has no Bidding-only Ixian replacement.
*
* Only original undealt inventories are ordered. Every card delivery, discard,
* Nexus draw, phase transition, income and debit is an original engine action. */
export function createRicheseIxianDirectReplacementFixture(
options: RicheseIxianDirectReplacementOptions = {},
): RicheseIxianDirectReplacementFixture {
const context = options.context ?? 'quiet';
let initial = options.initial;
if (!initial && options.emperor === false) {
initial = createGame('RICDIRECT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
joinGame(initial, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(initial, newPlayer('guild', 'Guild', 'guild'));
}
const base = createRicheseIxianReplacementFixture({
initial, advanced: true, buyerFaction: 'richese',
purchasedId: options.purchasedId, replacementId: options.replacementId,
cacheCardId: options.cacheCardId,
});
const buyer = base.buyer;
const emperor = base.emperor;
if (options.emperor !== undefined) assert.equal(emperor !== null, options.emperor);
if (context === 'income') assert.ok(emperor, 'The original income-2 context requires seated Emperor.');
const steps: RicheseIxianReplacementStep[] = [];
let undealtDeclarationOrder: string[] | null = null;
let game = structuredClone(context === 'quiet' ? base.beforeNexusDraw :
context === 'auctionPayment' ? base.beforePayment : base.game);
const act = (actor: string, action: Action) => {
steps.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
const native = () => {
const next = nextRicheseIxianDirectReplacementNativeStep(game, options.cacheCardId);
act(next.actor, next.action);
};
if (context === 'quiet') {
act(buyer, base.nexusDrawAction);
for (const id of game.nexusCards!.phase!.eligible)
if (!game.nexusCards!.phase!.done.includes(id))
act(id, { type: 'nexusCardChoice', turn: game.turn, card: null,
choice: 'keep', ownRedraws: 0 });
for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 400; count++) native();
assert.equal(game.decision?.kind, 'richeseDeclaration');
// This is still the original undealt deck, before native row allocation.
const pool = [...game.deck];
const take = (id: string): Card => {
const at = pool.findIndex(card => card.id === id);
assert.ok(at >= 0, 'The conserved requested CHOAM face must remain undealt.');
return pool.splice(at, 1)[0];
};
const purchased = take(base.purchased.id);
const replacement = take(base.nextCard.id);
game.deck = [purchased, ...pool];
game.deck.splice(base.rowSize, 0, replacement);
undealtDeclarationOrder = game.deck.map(card => card.id);
for (let count = 0; !(game.phase === 3 && game.auction && !game.phaseOpening &&
!game.response && !game.decision) && count < 400; count++) native();
assert.equal(game.auction?.bid, 0);
assert.equal(game.response, null);
assert.equal(game.decision, null);
} else if (context === 'income' || context === 'revival') {
act(buyer, { type: 'nexusIxianReplacementPass', event: base.event });
if (context === 'income') {
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.amount, 2);
} else {
for (let count = 0; !(game.phase === 4 && !game.phaseOpening && !game.response &&
!game.decision) && count < 500; count++) native();
assert.equal(game.phase, 4, 'The actual native auction/cache suffix must reach Revival.');
}
}
assert.equal(owner(game, buyer).faction, 'richese');
assert.equal(owner(game, buyer).ally, null);
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
const cost = owner(game, buyer).hand.find(card => card.effect === 'karama');
assert.ok(cost, 'Richese must still hold its actual printed starting Karama.');
const target = game.richeseCache?.find(card =>
(options.targetId === undefined || card.id === options.targetId) &&
!(game.richeseAuction?.source === 'cache' && !game.currentAuctionSale &&
game.richeseAuction.cardId === card.id));
assert.ok(target, 'Choose a still-present original cache card not reserved by a live lot.');
const beforePurchase = structuredClone(game);
const beforeSpice = owner(game, buyer).spice;
const beforeEmperorSpice = emperor ? owner(game, emperor).spice : null;
const originalControls = structuredClone({ active: game.active, response: game.response,
decision: game.decision, pendingKarama: game.pendingKarama });
const reservedNormalBid = game.auction?.bidder === buyer && !game.currentAuctionSale
? game.auction.bid - (game.auction.allyPayment ?? 0) : 0;
const funding = { wallet: beforeSpice, reservedNormalBid, uncommitted: beforeSpice - reservedNormalBid };
assert.ok(funding.uncommitted >= 3);
const nextCard = structuredClone(game.deck[0]);
assert.ok(nextCard, 'A real Treachery Deck replacement must remain.');
const action: Action = { type: 'card', mode: 'special', card: cost.id, acquire: target.id };
game = applyAction(game, buyer, action);
assert.equal(owner(game, buyer).spice, beforeSpice - 3);
assert.equal(owner(game, buyer).specialKaramaUsed, true);
assert.ok(owner(game, buyer).hand.some(card => card.id === target.id));
assert.equal(game.discard.filter(card => card.id === cost.id).length, 1);
const offer = viewGame(game, buyer).nexusIxianReplacement;
if (context === 'revival') assert.equal(offer, null);
else {
assert.ok(offer, 'The paid Bidding-phase direct purchase must open its own Ixian offer.');
assert.equal(offer.origin, 'richeseSpecial');
assert.equal(offer.purchased?.id, target.id);
if (emperor) assert.equal(owner(game, emperor).spice, beforeEmperorSpice,
'The direct choice must precede the original three-spice income.');
}
return {
context, setup: base.setup, beforeNexusDraw: base.beforeNexusDraw,
nexusDrawAction: base.nexusDrawAction, base, beforePurchase, actor: buyer, action, game,
event: offer?.event ?? null, buyer, cost: structuredClone(cost), target: structuredClone(target),
nextCard, price: 3, emperor, beforeSpice, paidSpice: owner(game, buyer).spice,
beforeEmperorSpice, originalControls, funding,
provenance: { checkpoint: context === 'quiet' ? 'beforeNexusDraw' :
context === 'auctionPayment' ? 'beforePayment' : 'normalOffer', steps, undealtDeclarationOrder },
};
}