165 lines
12 KiB
TypeScript
165 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
import {
|
|
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
|
|
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory,
|
|
semutaRicheseCacheKaramaPlayer,
|
|
} from './fixture-semuta-richese-cache-karama';
|
|
|
|
export type SemutaAuctionInspectionStep = { actor: string; action: Action };
|
|
export const actSemutaAuctionInspection = actSemutaRicheseCacheKarama;
|
|
export const nextSemutaAuctionInspection = nextSemutaRicheseCacheKarama;
|
|
export const semutaAuctionInspectionClean = semutaRicheseCacheKaramaClean;
|
|
export const semutaAuctionInspectionInventory = semutaRicheseCacheKaramaInventory;
|
|
export const semutaAuctionInspectionPlayer = semutaRicheseCacheKaramaPlayer;
|
|
|
|
export function advanceSemutaAuctionInspection(game: Game, until: (state: Game) => boolean,
|
|
actions: SemutaAuctionInspectionStep[] = []): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The native inspection source must reach its next control');
|
|
const next = nextSemutaAuctionInspection(game);
|
|
assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
game = actSemutaAuctionInspection(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The original Semuta/inspection programme did not reach its finite boundary');
|
|
}
|
|
|
|
/** Real public Basic start or original undealt Advanced Semuta preview. A
|
|
* printed Karama is genuinely dealt to Emperor; CHOAM buys Semuta in an earlier
|
|
* cache1 auction. No played card, balance, force, clock or response is installed. */
|
|
export function createSemutaAuctionInspectionFixture(options: { advanced?: boolean; blackMarket?: boolean } = {}) {
|
|
const advanced = options.advanced ?? false, blackMarket = options.blackMarket ?? false;
|
|
assert.ok(!blackMarket || advanced, 'The original Black Market requires Advanced play');
|
|
const richese = 'richese', holder = 'choam', owner = 'atreides', spender = 'emperor';
|
|
let game = createGame('SEMUTAAUCTIONINSPECTION', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']);
|
|
for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction));
|
|
const initial = structuredClone(game), actions: SemutaAuctionInspectionStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actSemutaAuctionInspection(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAuctionInspection(game, until, actions); };
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const lobby = structuredClone(game);
|
|
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
|
else act(game.host, { type: 'start' });
|
|
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
|
entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' };
|
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
|
|
assert.ok(game.players.every(player => player.hand.length === 0));
|
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
|
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
|
const originalInventory = semutaAuctionInspectionInventory(game);
|
|
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
|
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
|
|
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3);
|
|
const selected = [...ordinary, printed];
|
|
const dealOrder = selected.map((card, position) => {
|
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
|
return { position, from, card: card.id };
|
|
});
|
|
const beforeStartingDeal = structuredClone(game);
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game), cost = printed.id;
|
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
|
assert.ok(semutaAuctionInspectionPlayer(game, spender).hand.some(card => card.id === cost));
|
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
|
const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
|
for (let position = 0; position < (advanced ? 4 : 2); position++) {
|
|
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
|
const card = game.spiceDeck.splice(from, 1)[0]; front.push(card);
|
|
spiceOrder.push({ position, from, card: structuredClone(card) });
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
|
let beforeBid: Game | undefined, bidStep: SemutaAuctionInspectionStep | undefined, bidAt = -1;
|
|
for (let count = 0; count < 900 && !semutaAuctionInspectionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
|
const next = nextSemutaAuctionInspection(game); assert.ok(next);
|
|
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
|
|
beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length;
|
|
}
|
|
act(next.actor, next.action);
|
|
}
|
|
assert.ok(beforeBid && bidStep && bidAt >= 0, 'The actual earlier cache auction must give CHOAM Semuta');
|
|
const acquired = structuredClone(game), acquisition = {
|
|
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
|
|
};
|
|
assert.equal(semutaAuctionInspectionPlayer(beforeBid, holder).spice - semutaAuctionInspectionPlayer(game, holder).spice, 1);
|
|
assert.equal(semutaAuctionInspectionPlayer(game, richese).spice - semutaAuctionInspectionPlayer(beforeBid, richese).spice, 1);
|
|
assert.equal(game.richeseCache!.length, 9);
|
|
let beforeBlackMarket: Game | null = null, blackMarketStep: SemutaAuctionInspectionStep | null = null;
|
|
if (blackMarket) {
|
|
reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening &&
|
|
state.decision?.kind === 'richeseBlackMarket');
|
|
beforeBlackMarket = structuredClone(game);
|
|
const selected = semutaAuctionInspectionPlayer(game, richese).hand.find(card => card.kind === 'projectile'); assert.ok(selected);
|
|
blackMarketStep = { actor: richese, action: { type: 'decision', event: game.richeseBidding!.event,
|
|
card: selected.id, method: 'normal', direction: 'clockwise' } };
|
|
act(blackMarketStep.actor, blackMarketStep.action);
|
|
}
|
|
reach(state => state.turn === 2 && state.phase === 3 && state.response?.kind === 'atreidesAuction' &&
|
|
(blackMarket ? state.richeseAuction?.source === 'blackMarket' : !!state.auction && !state.richeseAuction));
|
|
assert.equal(game.response?.owner, owner);
|
|
assert.ok(semutaAuctionInspectionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
assert.ok(semutaAuctionInspectionPlayer(game, spender).hand.some(card => card.id === cost));
|
|
const origin = blackMarket ? 'blackMarket' as const : 'normal' as const;
|
|
const originalLot = structuredClone(blackMarket ? game.richeseAuction! : game.auction!);
|
|
const cards = blackMarket ? [game.richeseAuction!.cardId] : game.auction!.cards.map(card => card.id);
|
|
const nextBidder = blackMarket ? game.richeseAuction!.active : game.auction!.active; assert.ok(nextBidder);
|
|
assert.equal(blackMarket ? game.richesePeekKnown : game.auction!.peekKnown, false);
|
|
assert.deepEqual(semutaAuctionInspectionInventory(game), originalInventory);
|
|
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
|
return {
|
|
game, source, gameBeforeCost: source, action, advanced, blackMarket, origin,
|
|
holder, spender, owner, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
|
originalLot, cards, count: cards.length, nextBidder, originalResponse: structuredClone(game.response),
|
|
cache: game.richeseCache!.map(card => card.id), originalRound: structuredClone(game.richeseBidding),
|
|
initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
|
beforeBlackMarket, blackMarketStep, acquired, acquisition,
|
|
originalInventory, inventory: originalInventory, originalSpice,
|
|
actions, sourceActions: actions, sourceActionCount: actions.length,
|
|
staging: [
|
|
advanced
|
|
? 'Existing Advanced Semuta initializer acts only on the ready undealt original R/C/A/E lobby; no new public entry or played preview flag is installed.'
|
|
: 'Actual public bare Basic R/C/A/E ready/start initializes its existing Semuta preview before original setup.',
|
|
'Select only four unopened original starting positions, including the printed main-deck Karama genuinely dealt to Emperor, and the first two Basic/four Advanced original land Spice positions. Scoped native scalar lotteries retain original physical components and all actual setup/action owners.',
|
|
'CHOAM buys the original Semuta from the first native Once Around cache auction for one spice paid once to Richese. Original first-turn normal lots, Revival, combined turns and Mentat finish through native controls; no played hand, wallet, force, clock, auction or response is assigned.',
|
|
blackMarket
|
|
? 'At the real second-turn Advanced Black Market, Richese offers its actual original dealt projectile using normal bidding. Stop at the real still-unseen Atreides inspection before bidding, payment, delivery or declaration. The offered card remains physically held by Richese.'
|
|
: 'The actual second-turn compulsory cache auction completes through native pass/remove controls. Stop at the first real normal-pool Atreides inspection before any bid, peek, payment or acquisition.',
|
|
],
|
|
};
|
|
}
|
|
export type SemutaAuctionInspectionFixture = ReturnType<typeof createSemutaAuctionInspectionFixture>;
|
|
|
|
export function passSemutaAuctionInspection(game: Game, actions: SemutaAuctionInspectionStep[] = []): Game {
|
|
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
|
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
|
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
|
|
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
|
|
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
|
game = actSemutaAuctionInspection(game, player.id, { type: 'semutaPass', event }, actions);
|
|
}
|
|
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
|
}
|
|
export function claimSemutaAuctionInspection(game: Game, fixture: Pick<SemutaAuctionInspectionFixture, 'holder' | 'cost'>,
|
|
actions: SemutaAuctionInspectionStep[] = []): Game {
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
|
|
const event = pending.batch.event;
|
|
assert.equal(pending.batch.entries[0].card.id, fixture.cost);
|
|
game = actSemutaAuctionInspection(game, fixture.holder, { type: 'semutaCommit', event }, actions);
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = actSemutaAuctionInspection(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions);
|
|
return game;
|
|
}
|
|
/** The used-cost callback completes the native prevention itself. Do not bid,
|
|
* reveal the card or consume a later response in this finish helper. */
|
|
export function finishSemutaAuctionInspection(game: Game): Game {
|
|
assert.ok(!game.pendingTreacheryDiscard, 'Resolve the original fresh cost first');
|
|
assert.notEqual(game.response?.kind, 'atreidesAuction', 'The original inspection prevention must complete once');
|
|
return game;
|
|
}
|