209 lines
13 KiB
TypeScript
209 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, handLimit, joinGame, newPlayer, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
|
|
|
|
export const DISTRANS_PREBID_CARD = 'richese-distrans';
|
|
export type DistransPrebidStep = { actor: string; action: Action };
|
|
export type DistransPrebidCapacity = 'ordinary' | 'donorFull' | 'recipientLastSlot' | 'recipientFull';
|
|
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity;
|
|
truthFor?: readonly ('r' | 'a' | 'e')[] };
|
|
|
|
export function distransPrebidPlayer(game: Game, id: string) {
|
|
const player = game.players.find(player => player.id === id);
|
|
assert.ok(player, `Original Distrans seat ${id} must remain present`);
|
|
return player;
|
|
}
|
|
export const distransPrebidClean = (game: Game): boolean =>
|
|
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
|
!game.pendingTreacheryDiscard && !game.pendingNullentropy;
|
|
export function distransPrebidUntouched(game: Game): boolean {
|
|
const lot = game.auction;
|
|
return game.status === 'playing' && game.phase === 3 && distransPrebidClean(game) &&
|
|
!game.richeseAuction && !game.currentAuctionSale && !!lot &&
|
|
!!lot.cards[lot.index] && lot.bid === 0 && lot.bidder === null &&
|
|
lot.passed.length === 0 && (lot.allyPayment ?? 0) === 0;
|
|
}
|
|
/** Sold ordinary prefixes alias the delivered card; only unpaid pool entries
|
|
* are physical custody. Cache offers remain in their original cache. */
|
|
export function distransPrebidInventory(game: Game): string[] {
|
|
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
|
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand),
|
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
|
.map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card occupies one physical zone');
|
|
return ids;
|
|
}
|
|
/** The native table, lot and physical resources excluding the two changed hands
|
|
* and the used card's discard. No declaration, price or opening is replayed. */
|
|
export function distransPrebidPosition(game: Game) {
|
|
return {
|
|
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
|
order: game.order, ready: game.ready, auction: game.auction,
|
|
richeseBidding: game.richeseBidding, richeseAuction: game.richeseAuction,
|
|
currentAuctionSale: game.currentAuctionSale, richeseFunding: game.richeseFunding,
|
|
deck: game.deck, richeseCache: game.richeseCache, richeseRemoved: game.richeseRemoved,
|
|
response: game.response, decision: game.decision, phaseOpening: game.phaseOpening,
|
|
players: game.players.map(player => ({ ...player, hand: undefined })),
|
|
};
|
|
}
|
|
export function actDistransPrebid(game: Game, actor: string, action: Action,
|
|
actions: DistransPrebidStep[] = []): Game {
|
|
actions.push({ actor, action: structuredClone(action) });
|
|
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
|
}
|
|
/** Real cache selection and one-spice Guild bid. Every other setup, response,
|
|
* auction and phase action retains the existing neutral native policy. */
|
|
export function nextDistransPrebidStep(game: Game): DistransPrebidStep {
|
|
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
|
// The real Advanced opening has one current owner at a time. Finish that
|
|
// owner's optional Charity/Revival opportunity using the public action;
|
|
// never stage or reorder the persisted phase frame.
|
|
if (!game.decision && !game.truthtrance && !game.pendingNullentropy) {
|
|
if (game.phase === 2 && game.charityOrder?.remaining[0])
|
|
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
|
|
if (game.phase === 4 && game.revivalOrder?.remaining[0])
|
|
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
|
|
}
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'richeseCache' &&
|
|
game.richeseCache?.some(card => card.id === DISTRANS_PREBID_CARD))
|
|
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
|
card: DISTRANS_PREBID_CARD, method: 'onceAround', direction: 'counterclockwise' } };
|
|
if (!decision && game.richeseAuction?.cardId === DISTRANS_PREBID_CARD &&
|
|
!game.richeseAuction.outcome && game.richeseAuction.active === 'g' && game.richeseAuction.bid === 0)
|
|
return { actor: 'g', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
|
}
|
|
return nextSemutaGuildIncomeNativeStep(game);
|
|
}
|
|
export function advanceDistransPrebid(game: Game, until: (game: Game) => boolean,
|
|
actions: DistransPrebidStep[] = []): Game {
|
|
for (let count = 0; count < 1500; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The original Distrans table ended before its requested boundary');
|
|
const next = nextDistransPrebidStep(game);
|
|
game = actDistransPrebid(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The native Distrans programme did not reach its finite boundary');
|
|
}
|
|
/** Buy the actual current ordinary card for one spice, passing only live owners.
|
|
* This may close a preceding lot; it never installs a hand, price or opener. */
|
|
export function buyDistransPrebidLot(game: Game, winner: string,
|
|
actions: DistransPrebidStep[] = []) {
|
|
assert.ok(distransPrebidUntouched(game));
|
|
const before = structuredClone(game), start = actions.length, card = game.auction!.cards[game.auction!.index].id;
|
|
assert.ok(distransPrebidPlayer(game, winner).hand.length < handLimit(distransPrebidPlayer(game, winner)));
|
|
for (let count = 0; count < 200; count++) {
|
|
if (distransPrebidPlayer(game, winner).hand.some(held => held.id === card) &&
|
|
distransPrebidClean(game) && !game.currentAuctionSale)
|
|
return { game, purchase: { before, card, winner, amount: 1,
|
|
actions: structuredClone(actions.slice(start)), after: structuredClone(game) } };
|
|
let next: DistransPrebidStep;
|
|
if (distransPrebidClean(game) && game.auction && !game.currentAuctionSale) {
|
|
const actor = game.auction.active;
|
|
next = { actor, action: actor === winner && game.auction.bid === 0
|
|
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
|
|
} else next = nextDistransPrebidStep(game);
|
|
game = actDistransPrebid(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The actual one-spice ordinary purchase did not finish');
|
|
}
|
|
/** Retire only the original now-bid lot and its real payment/response. Stop
|
|
* at the next untouched lot or actual next phase, before another declaration. */
|
|
export function finishDistransPrebidLot(game: Game, turn: number, index: number,
|
|
actions: DistransPrebidStep[] = []): Game {
|
|
return advanceDistransPrebid(game, state => distransPrebidClean(state) && !state.currentAuctionSale &&
|
|
(state.turn !== turn || state.phase !== 3 || !state.auction || state.auction.index !== index), actions);
|
|
}
|
|
|
|
/** Fresh public R/G/A/E start in Basic or explicit Advanced preview. Only the
|
|
* unopened four-card starting deal, four unopened non-worm Spice positions and
|
|
* scalar lotteries are selected. Guild really pays1 for canonical Distrans.
|
|
* Optional capacities use actual one-spice ordinary purchases; the full target
|
|
* retains its earned hand through native no-spend play into turn2. No played
|
|
* hand, wallet, alliance, board, clock, lot, payment or bonus is installed. */
|
|
export function createDistransPrebidFixture(options: DistransPrebidOptions = {}) {
|
|
const advanced = options.advanced ?? false, capacity = options.capacity ?? 'ordinary';
|
|
let game = createGame('DISTRANSPREBID', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
|
for (const [id, name, faction] of [['g', 'Guild', 'guild'], ['a', 'Atreides', 'atreides'],
|
|
['e', 'Emperor', 'emperor']] as const) joinGame(game, newPlayer(id, name, faction));
|
|
const actions: DistransPrebidStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actDistransPrebid(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceDistransPrebid(game, until, actions); };
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const initial = structuredClone(game);
|
|
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
|
const setup = structuredClone(game);
|
|
assert.equal(game.status, 'setup');
|
|
assert.ok(game.players.every(player => player.hand.length === 0));
|
|
const truthFor = options.truthFor ?? [];
|
|
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
|
|
'Select at most the two original unopened Truthtrance cards');
|
|
const startingDealSelection: { actor: string; position: number; from: number; card: string }[] = [];
|
|
for (const [actor, kind] of [['r', 'projectile'], ['g', 'shield'], ['a', 'poison'], ['e', 'snooper']] as const) {
|
|
const position = game.players.findIndex(player => player.id === actor);
|
|
const truth = truthFor.some(id => id === actor);
|
|
const from = game.deck.findIndex((card, index) => index >= position && (truth ? card.effect === 'truthtrance' : card.kind === kind));
|
|
assert.ok(from >= position, `The original ${actor} starting card remains unopened`);
|
|
const card = game.deck[from].id;
|
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
|
startingDealSelection.push({ actor, position, from, card });
|
|
}
|
|
const beforeStartingDeal = structuredClone(game);
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game), originalInventory = distransPrebidInventory(game);
|
|
assert.equal(originalInventory.length, 45);
|
|
for (const { actor, card } of startingDealSelection)
|
|
assert.ok(distransPrebidPlayer(game, actor).hand.some(held => held.id === card));
|
|
const land: Game['spiceDeck'] = [];
|
|
for (let count = 0; count < 4; count++) {
|
|
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
|
assert.ok(index >= 0); land.push(game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...land);
|
|
const beforeStorm = structuredClone(game);
|
|
reach(state => state.decision?.kind === 'richeseCache');
|
|
const beforePurchase = structuredClone(game), acquisitionStart = actions.length;
|
|
reach(state => distransPrebidPlayer(state, 'g').hand.some(card => card.id === DISTRANS_PREBID_CARD) &&
|
|
distransPrebidUntouched(state));
|
|
const acquired = structuredClone(game), acquisition = { before: beforePurchase,
|
|
actions: structuredClone(actions.slice(acquisitionStart)), after: acquired };
|
|
assert.ok(acquisition.actions.some(step => step.actor === 'g' && step.action.type === 'richeseBid' && step.action.amount === 1));
|
|
assert.equal(distransPrebidPlayer(game, 'g').spice, distransPrebidPlayer(beforePurchase, 'g').spice - 1);
|
|
const firstLot = structuredClone(game);
|
|
assert.equal(game.auction!.cards.length, 3);
|
|
const thirdOpener = game.order[(game.auction!.opener + 2) % game.order.length];
|
|
assert.notEqual(thirdOpener, 'g', 'The native third opener is a distinct transfer recipient');
|
|
const recipient = capacity.startsWith('recipient') ? thirdOpener : 'a';
|
|
const purchases: ReturnType<typeof buyDistransPrebidLot>['purchase'][] = [];
|
|
const purchaseCount = capacity === 'recipientFull' ? 3 : capacity === 'ordinary' ? 0 : 2;
|
|
for (let count = 0; count < purchaseCount; count++) {
|
|
const result = buyDistransPrebidLot(game, capacity === 'donorFull' ? 'g' : recipient, actions);
|
|
game = result.game; purchases.push(result.purchase);
|
|
}
|
|
if (capacity === 'recipientFull') reach(state => state.turn === 2 && distransPrebidUntouched(state));
|
|
assert.ok(distransPrebidUntouched(game));
|
|
if (capacity === 'donorFull') assert.equal(distransPrebidPlayer(game, 'g').hand.length, handLimit(distransPrebidPlayer(game, 'g')));
|
|
if (capacity === 'recipientLastSlot') {
|
|
assert.equal(game.auction!.active, recipient);
|
|
assert.equal(game.active, recipient);
|
|
assert.equal(distransPrebidPlayer(game, recipient).hand.length, handLimit(distransPrebidPlayer(game, recipient)) - 1);
|
|
}
|
|
if (capacity === 'recipientFull') assert.equal(distransPrebidPlayer(game, recipient).hand.length, handLimit(distransPrebidPlayer(game, recipient)));
|
|
assert.deepEqual(distransPrebidInventory(game), originalInventory);
|
|
const give = startingDealSelection.find(row => row.actor === 'g')!.card;
|
|
const action: Action = { type: 'card', card: DISTRANS_PREBID_CARD, target: recipient, give };
|
|
return { game, advanced, capacity, holder: 'g', donor: 'g', recipient, card: DISTRANS_PREBID_CARD, give, action,
|
|
startingTruthSelection: startingDealSelection.filter(row => truthFor.some(id => id === row.actor)),
|
|
initial, setup, beforeStartingDeal, startingDealSelection, afterSetup, beforeStorm,
|
|
acquisition, acquired, firstLot, purchases, originalInventory, actions, sourceActionCount: actions.length,
|
|
staging: ['Only unopened starting Treachery/Spice positions and scalar lotteries are selected. All cache/ordinary purchases, hands, prices, clocks and continuations are native public actions.'] };
|
|
}
|
|
export type DistransPrebidFixture = ReturnType<typeof createDistransPrebidFixture>;
|
|
export function playDistransPrebid(fixture: DistransPrebidFixture, game = structuredClone(fixture.game),
|
|
actions: DistransPrebidStep[] = []): Game {
|
|
return actDistransPrebid(game, fixture.holder, fixture.action, actions);
|
|
}
|