Files
Dune/tests/fixture-semuta-ixian-auction.ts

217 lines
16 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 { actTruthMovement } from './fixture-truth-movement';
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaIxianAuctionStep = { actor: string; action: Action };
export type SemutaIxianAuctionKind = 'ixAuction' | 'ixTechnology';
export const actSemutaIxianAuction = actTruthMovement;
export const semutaIxianAuctionPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const semutaIxianAuctionClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
/** Original physical zones only; inspected/known pool copies are receipts. */
export function semutaIxianAuctionInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(p => p.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 main/cache card has one physical zone'); return ids;
}
/** The original source owns no automatic Semuta decision. Native pool return,
* declined optional Technology, initial setup and cache bids remain real acts. */
export function nextSemutaIxianAuctionStep(game: Game, buyer = 'choam'): SemutaIxianAuctionStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.response && !game.phaseOpening) {
const d = game.decision;
if (d?.kind === 'ixSetup') {
const card = game.ixSetupCards?.find(card => card.kind === 'shield'); assert.ok(card);
return { actor: d.player, action: { type: 'decision', card: card.id } };
}
if (d?.kind === 'ixAuction') {
const card = game.ixAuction?.cards[0]; assert.ok(card);
return { actor: d.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
}
if (d?.kind === 'ixTechnology') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'choamMarket' || d?.kind === 'choamMentat')
return { actor: d.player, action: { type: 'decision', done: true } };
if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } };
if (!d && game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
}
const lot = game.richeseAuction;
if (semutaIxianAuctionClean(game) && lot?.cardId === SEMUTA_DRUG_ID && !lot.outcome &&
lot.active === buyer && lot.bid === 0)
return { actor: buyer, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: game.richeseCache?.find(card => card.id === SEMUTA_DRUG_ID)?.id ?? game.richeseCache?.[0]?.id,
});
}
export function advanceSemutaIxianAuction(game: Game, until: (state: Game) => boolean,
actions: SemutaIxianAuctionStep[] = [], buyer = 'choam'): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaIxianAuctionStep(game, buyer);
assert.ok(next, 'Choose the actual original fresh-discard reaction before continuing');
game = actSemutaIxianAuction(game, next.actor, next.action, actions);
}
throw Error('The native Ixian auction programme did not reach its finite boundary');
}
/** Original response acknowledgements only, one named opportunity at a time. */
export function allowSemutaIxianAuction(game: Game, kind: string,
actions: SemutaIxianAuctionStep[] = []): Game {
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaIxianAuction(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind); return game;
}
export function passSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): 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 actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
game = actSemutaIxianAuction(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaIxianAuction(game: Game, holder: string,
actions: SemutaIxianAuctionStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaIxianAuction(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaIxianAuction(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** After prevention, decline only a fresh optional Technology offer and allow
* the original Atreides peek. Stop before any normal bid; never replay Ix use. */
export function finishSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): Game {
assert.ok(!game.pendingTreacheryDiscard, 'Answer the original cost reaction first');
assert.ok(!['ixAuction', 'ixTechnology'].includes(game.response?.kind ?? ''), 'The original Ixian prevention must finish first');
if (!game.response && game.decision?.kind === 'ixTechnology')
game = actSemutaIxianAuction(game, game.decision.player, { type: 'decision', decline: true }, actions);
if (game.response?.kind === 'atreidesAuction') game = allowSemutaIxianAuction(game, 'atreidesAuction', actions);
assert.ok(semutaIxianAuctionClean(game)); return game;
}
/** Native public Basic start, or original Advanced Semuta initializer, then
* actual Ix setup/Storm/HMS and paid first cache. No played custody, force,
* wallet, phase, queue, response, pool or Technology receipt is installed. */
export function createSemutaIxianAuctionFixture(options: {
advanced?: boolean; kind?: SemutaIxianAuctionKind; holder?: 'choam' | 'ixians';
} = {}) {
const kind = options.kind ?? 'ixAuction', advanced = options.advanced ?? kind === 'ixTechnology';
assert.ok(kind !== 'ixTechnology' || advanced, 'Native Technology is Advanced only');
const owner = 'ixians', holder = options.holder ?? 'choam', spender = 'atreides', seller = 'richese', observer = 'emperor';
let game = createGame('SEMUTAIXIANAUCTION', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
for (const faction of ['choam', 'ixians', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaIxianAuctionStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaIxianAuction(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxianAuction(game, until, actions, holder); };
for (const p of game.players) act(p.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 ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => !p.hand.length));
const originalInventory = semutaIxianAuctionInventory(game); assert.equal(originalInventory.length, 57);
const karamas = game.deck.filter(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
assert.equal(karamas.length, 2);
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2);
const shield = game.deck.find(card => card.kind === 'shield'); assert.equal(ordinary.length, 2); assert.ok(shield);
// Ix actually selects its inspected Shield. The original max scalar lottery
// leaves the four remaining inspected cards in their existing native order.
const inspected = [ordinary[0].id, ordinary[1].id, shield.id, karamas[0].id, karamas[1].id];
const dealOrder = inspected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, position, from };
});
const beforeStartingDeal = structuredClone(game), cost = karamas[0].id, observerKarama = karamas[1].id;
let ixSetup: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 150; count++) {
const next = nextSemutaIxianAuctionStep(game, holder); assert.ok(next);
const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null;
act(next.actor, next.action);
if (before) ixSetup = { before, step: structuredClone(next), after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(ixSetup);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), inspected);
assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), front: Game['spiceDeck'] = [];
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (advanced ? 2 : 1); 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, bid: SemutaIxianAuctionStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaIxianAuctionStep(game, holder); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.ok(semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.equal(semutaIxianAuctionPlayer(beforeBid, holder).spice - semutaIxianAuctionPlayer(game, holder).spice, 1);
assert.equal(semutaIxianAuctionPlayer(game, seller).spice - semutaIxianAuctionPlayer(beforeBid, seller).spice, 1);
reach(state => state.response?.kind === 'ixAuction' && !state.phaseOpening && !state.pendingTreacheryDiscard);
const beforePool = structuredClone(game), poolCount = game.ixAuction!.count;
assert.equal(game.turn, 1); assert.deepEqual(game.ixAuction!.cards, []); assert.ok(poolCount > 0);
assert.ok(!game.auction); assert.ok(game.mobileStronghold?.location);
let poolSelection: { before: Game; steps: SemutaIxianAuctionStep[]; after: Game; card: string } | null = null;
let technology: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null;
if (kind === 'ixTechnology') {
const before = structuredClone(game), start = actions.length;
game = allowSemutaIxianAuction(game, 'ixAuction', actions);
assert.equal(game.decision?.kind, 'ixAuction');
const returned = game.ixAuction!.cards[0]; assert.ok(returned);
act(owner, { type: 'decision', card: returned.id, position: 'bottom' });
assert.equal(game.decision?.kind, 'ixTechnology');
poolSelection = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card: returned.id };
const selected = semutaIxianAuctionPlayer(game, owner).hand.find(card =>
card.id === (holder === owner ? SEMUTA_DRUG_ID : shield.id)); assert.ok(selected);
const declaration: SemutaIxianAuctionStep = { actor: owner, action: { type: 'decision', card: selected.id } };
const beforeTechnology = structuredClone(game); act(declaration.actor, declaration.action);
technology = { before: beforeTechnology, step: declaration, after: structuredClone(game) };
assert.equal(game.response?.kind, 'ixTechnology'); assert.equal(game.ixTechnologyTurn, game.turn);
assert.equal(game.pendingIxTechnology?.card, selected.id);
}
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost));
assert.ok(semutaIxianAuctionPlayer(game, observer).hand.some(card => card.id === observerKarama));
assert.deepEqual(semutaIxianAuctionInventory(game), originalInventory);
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, ixians: owner, atreides: spender,
observer, observerKarama, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action,
lobby, setup, initialization, beforeStartingDeal, afterSetup, inspected, dealOrder, ixSetup, spiceOrder,
acquired, acquisition, beforePool, poolCount, poolSelection, technology,
declaredCard: game.pendingIxTechnology?.card ?? null,
selectedSemuta: kind === 'ixTechnology' && holder === owner,
lotCard: game.auction?.cards[game.auction.index]?.id ?? null,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual Richese/CHOAM/Ixians/Atreides/Emperor setup uses the public bare Basic start or existing explicit Advanced Semuta initializer. Original ix+choam main47/cache10 and native setup forces/HMS remain intact; no Basic game is converted after start.',
`Only original unopened setup-pool positions, land Spice positions and scalar lotteries are selected. Ix actually chooses the inspected Shield; its real remainder shuffle deals both original printed Karama cards to Atreides/Emperor. ${holder} really pays cache1 for Semuta before the native first ordinary Ixian pool opens.`,
kind === 'ixAuction' ? 'Stop at the native undrawn pool response: frozen count, zero drawn cards, no auction and no private pool choice. The cancellation cost and subsequent ordinary draw remain undeclared.' : `Actually allow the original Ixian pool response, return one actual inspected card to the deck bottom, then declare the ${holder === owner ? 'genuinely acquired Semuta itself' : 'selected original Shield'} exchange through the native Technology chooser. Stop after its once-use is spent but before cancellation, exchange or Atreides inspection.`,
'No played hand, wallet, army, phase, queue, receipt, pool result or once-use is assigned. Claim/pass must resolve the actual used printed cost before the original native prevention continues.',
] };
}
export type SemutaIxianAuctionFixture = ReturnType<typeof createSemutaIxianAuctionFixture>;