Offer Semuta on auction advantage cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 00:21:13 +11:00
1 parent 0d868a3838
commit 70c95f2686
10 files changed
+1063 -9

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
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;
}
+216
View File
@@ -0,0 +1,216 @@
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>;
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaAuctionInspectionFixture as fixture, actSemutaAuctionInspection as act,
passSemutaAuctionInspection as pass, claimSemutaAuctionInspection as claim,
finishSemutaAuctionInspection as finish, semutaAuctionInspectionInventory as inventory,
semutaAuctionInspectionPlayer as player, type SemutaAuctionInspectionFixture,
} from './fixture-semuta-auction-inspection';
const resources = (game: Game) => ({
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })),
turn: game.turn, phase: game.phase, order: game.order, active: game.active, ready: game.ready,
});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function nativeSource(f: SemutaAuctionInspectionFixture) {
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 3);
assert.equal(f.game.response?.kind, 'atreidesAuction'); assert.equal(f.game.response?.owner, f.owner);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
assert.equal(f.blackMarket ? f.game.richesePeekKnown : f.game.auction!.peekKnown, false);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function sameOriginalLot(game: Game, f: SemutaAuctionInspectionFixture) {
assert.deepEqual(f.blackMarket ? game.richeseAuction : game.auction, f.originalLot);
assert.deepEqual(game.richeseBidding, f.originalRound);
assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache);
assert.equal(game.active, f.nextBidder);
assert.equal(f.blackMarket ? game.richeseAuction!.active : game.auction!.active, f.nextBidder);
assert.equal(f.blackMarket ? game.richesePeekKnown : game.auction!.peekKnown, false);
assert.deepEqual(f.blackMarket ? [game.richeseAuction!.cardId] : game.auction!.cards.map(card => card.id), f.cards);
assert.equal(f.blackMarket ? game.richeseAuction!.bid : game.auction!.bid, 0);
assert.equal(f.blackMarket ? game.richeseAuction!.bidder : game.auction!.bidder, null);
}
function open(f: SemutaAuctionInspectionFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'karamaAuctionAdvantageDiscard');
assert.equal(pending.batch.cause, 'karama:atreidesAuction');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
[{ card: f.cost, by: f.spender, public: true }]);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.response, null, 'The original inspection is parked until its used-cost reaction finishes');
if (pending.continuation.kind !== 'karamaAuctionAdvantageDiscard') throw Error('Use the native auction-advantage cost continuation');
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.equal(pending.continuation.use.kind, 'cancel');
assert.equal(pending.continuation.use.response.kind, 'atreidesAuction');
assert.equal(pending.continuation.use.response.owner, f.owner);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
sameOriginalLot(game, f);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
function prevented(state: Game, f: SemutaAuctionInspectionFixture, claimed: boolean) {
const game = finish(state);
assert.equal(game.response, null); assert.equal(game.decision, null);
assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingKarama);
sameOriginalLot(game, f);
assert.deepEqual(resources(game), resources(f.game));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} actual paid Semuta ${claimed ? 'claims' : 'passes'} printed Karama before normal Atreides inspection prevention resumes`, () => {
const f = fixture({ advanced }); nativeSource(f);
assert.equal(f.origin, 'normal'); assert.equal(f.game.auction!.index, 0);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
});
for (const claimed of [false, true]) void test(
`Advanced unseen native Black Market inspection preserves its exact lot when the printed cost is ${claimed ? 'claimed' : 'passed'}`, () => {
const f = fixture({ advanced: true, blackMarket: true }); nativeSource(f);
assert.ok(f.beforeBlackMarket && f.blackMarketStep);
assert.equal(f.game.richeseAuction!.source, 'blackMarket');
assert.equal(f.game.richeseAuction!.method, 'normal');
assert.ok(player(f.game, f.richese).hand.some(card => card.id === f.cards[0]));
const pending = open(f), game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed);
assert.ok(player(game, f.richese).hand.some(card => card.id === f.cards[0]));
assert.equal(game.richeseBidding!.blackMarketSold, false);
assert.equal(game.richeseBidding!.normalCount, null);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native allowance control actually grants the original normal-lot peek without discarding Karama`, () => {
const f = fixture({ advanced }); nativeSource(f);
let game = structuredClone(f.game);
for (let count = 0; game.response?.kind === 'atreidesAuction' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null); assert.equal(game.auction!.peekKnown, true);
assert.ok(!game.pendingTreacheryDiscard);
assert.deepEqual({ ...game.auction!, peekKnown: false }, f.originalLot);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const blackMarket of [false, true]) void test(`${blackMarket ? 'Black Market' : 'normal'} suspended inspection rejects underlying bids and stale cost reactions`, () => {
const f = fixture({ advanced: blackMarket, blackMarket }); nativeSource(f);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
reject(pending, f.spender, f.action);
reject(pending, f.owner, { type: 'passResponse' });
reject(pending, f.nextBidder, blackMarket
? { type: 'richeseBid', event: f.game.richeseAuction!.event, amount: 1 }
: { type: 'bid', amount: 1 });
const game = prevented(claim(pending, f), f, true);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
});
void test('all four minimal policies resolve the genuine auction-inspection cost without revealing or advancing its lot', () => {
for (const difficulty of DIFFICULTIES) {
const f = fixture(), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < 32; count++) {
let proposal: { actor: string; action: Action } | undefined;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(player => player.id === seat.id)!.bot = difficulty;
const before = structuredClone(view), action = botActions(view)[0];
assert.deepEqual(view, before);
if (action) { proposal = { actor: seat.id, action }; break; }
}
assert.ok(proposal);
assert.ok(['semutaPass', 'semutaCommit', 'semutaSelect'].includes(proposal.action.type));
game = act(game, proposal.actor, proposal.action);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
const claimed = !player(game, f.holder).hand.some(card => card.id === f.semuta);
prevented(game, f, claimed);
}
});
+174
View File
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaIxianAuctionFixture as fixture, actSemutaIxianAuction as act,
passSemutaIxianAuction as pass, claimSemutaIxianAuction as claim,
finishSemutaIxianAuction as finish, semutaIxianAuctionPlayer as player,
semutaIxianAuctionInventory as inventory, type SemutaIxianAuctionFixture,
} from './fixture-semuta-ixian-auction';
const physical = (game: Game) => ({ turn: game.turn, phase: game.phase, order: game.order,
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks,
forces: p.forces, elites: p.elites, shipped: p.shipped, moved: p.moved, revived: p.revived })) });
function reject(game: Game, actor: string, proposed: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaIxianAuctionFixture) {
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.ok(f.ixSetup); assert.equal(f.ixSetup.step.actor, f.owner);
assert.ok(f.ixSetup.before.ixSetupCards!.some(card => card.id === f.cost));
assert.equal(player(f.afterSetup, f.owner).hand[0].kind, 'shield');
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(f.afterSetup, f.observer).hand.some(card => card.id === f.observerKarama));
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(f.beforePool.turn, 1); assert.equal(f.beforePool.response?.kind, 'ixAuction');
assert.deepEqual(f.beforePool.ixAuction?.cards, []); assert.equal(f.beforePool.ixAuction?.count, f.poolCount);
assert.ok(f.beforePool.mobileStronghold?.location); assert.ok(!f.beforePool.auction);
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaIxianAuctionFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'karamaAuctionAdvantageDiscard');
if (pending.continuation.kind !== 'karamaAuctionAdvantageDiscard') throw Error('Expected the exact printed auction-cost continuation');
assert.equal(pending.continuation.use.kind, 'cancel');
if (pending.continuation.use.kind !== 'cancel') throw Error('Expected the original cancellation');
assert.equal(pending.continuation.use.response.kind, f.kind);
assert.equal(pending.continuation.use.response.owner, f.owner);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.auction, f.game.auction);
assert.deepEqual(game.ixAuction, f.game.ixAuction); assert.deepEqual(game.pendingIxTechnology, f.game.pendingIxTechnology);
assert.equal(game.ixTechnologyTurn, f.game.ixTechnologyTurn);
assert.deepEqual(physical(game), physical(f.game)); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
return game;
}
function physicalCost(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) {
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(physical(game), physical(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.ok(player(game, f.observer).hand.some(card => card.id === f.observerKarama));
}
function poolDenied(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) {
physicalCost(game, f, claimed);
assert.ok(!game.ixAuction); assert.ok(!game.ixAuctionKnown);
assert.notEqual(game.decision?.kind, 'ixAuction');
const expected = f.game.deck.slice(0, f.poolCount).map(card => card.id);
assert.deepEqual(game.auction!.cards.map(card => card.id), expected);
assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0); assert.equal(game.auction!.bidder, null);
assert.deepEqual(game.deck, f.game.deck.slice(f.poolCount));
assert.equal(game.ixTechnologyTurn, f.game.ixTechnologyTurn);
const beforePeekDeck = structuredClone(game.deck), done = finish(game);
assert.deepEqual(done.deck, beforePeekDeck); assert.deepEqual(done.auction!.cards.map(card => card.id), expected);
assert.equal(done.auction!.peekKnown, true);
assert.equal(viewGame(done, f.spender).auction?.card?.id, expected[0]);
assert.equal(done.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.deepEqual(inventory(done), f.originalInventory); return done;
}
function technologyDenied(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) {
physicalCost(game, f, claimed);
assert.ok(f.technology && f.declaredCard && f.lotCard);
assert.equal(game.pendingIxTechnology, null); assert.equal(game.ixTechnologyTurn, game.turn);
assert.deepEqual(game.auction!.cards, f.game.auction!.cards); assert.equal(game.auction!.index, f.game.auction!.index);
assert.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.ixAuctionKnown, f.game.ixAuctionKnown);
const expectedHand = player(f.game, f.owner).hand.filter(card => !(f.selectedSemuta && claimed && card.id === f.semuta));
if (f.selectedSemuta && claimed) expectedHand.push(player(f.game, f.spender).hand.find(card => card.id === f.cost)!);
assert.deepEqual(player(game, f.owner).hand.map(card => card.id).sort(), expectedHand.map(card => card.id).sort());
assert.equal(game.auction!.peekKnown, false); assert.equal(game.response?.kind, 'atreidesAuction');
assert.equal(game.response?.owner, f.spender);
assert.equal(viewGame(game, f.spender).auction?.card, null);
reject(game, f.owner, f.technology.step.action);
const done = finish(game);
assert.equal(done.auction!.cards[done.auction!.index].id, f.lotCard);
assert.equal(viewGame(done, f.spender).auction?.card?.id, f.lotCard);
assert.equal(done.ixTechnologyTurn, done.turn); assert.equal(done.pendingIxTechnology, null);
assert.deepEqual(done.deck, f.game.deck); assert.deepEqual(inventory(done), f.originalInventory);
reject(done, f.owner, f.technology.step.action); return done;
}
for (const advanced of [false, true]) for (const claimed of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native Ixian undrawn-pool cost Semuta ${claimed ? 'claim' : 'pass'} draws only the original ordinary count after prevention`, () => {
const f = fixture({ advanced }); nativeSource(f);
const offered = open(f);
assert.deepEqual(offered.ixAuction!.cards, []); assert.equal(offered.ixAuction!.count, f.poolCount);
assert.ok(!offered.auction); assert.equal(offered.deck.length, f.game.deck.length);
const done = poolDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
assert.equal(done.decision, null); assert.equal(done.response, null);
},
);
for (const claimed of [false, true]) void test(
`Advanced native Technology outgoing-Shield cost Semuta ${claimed ? 'claim' : 'pass'} preserves the exact unseen lot and spent attempt`, () => {
const f = fixture({ kind: 'ixTechnology' }); nativeSource(f);
assert.ok(f.poolSelection); assert.equal(f.selectedSemuta, false);
assert.equal(f.technology!.step.action.card, f.declaredCard);
const offered = open(f);
const done = technologyDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
assert.ok(player(done, f.owner).hand.some(card => card.id === f.declaredCard));
assert.equal(player(done, f.owner).hand.some(card => card.id === f.lotCard), false);
},
);
for (const claimed of [false, true]) void test(
`Advanced native Technology selected Semuta itself ${claimed ? 'claims the counter cost' : 'passes'} after its real paid acquisition`, () => {
const f = fixture({ kind: 'ixTechnology', holder: 'ixians' }); nativeSource(f);
assert.equal(f.holder, f.owner); assert.equal(f.selectedSemuta, true);
assert.equal(f.declaredCard, f.semuta); assert.equal(f.technology!.step.action.card, f.semuta);
const offered = open(f);
assert.equal(offered.pendingIxTechnology!.card, f.semuta);
assert.ok(player(offered, f.owner).hand.some(card => card.id === f.semuta));
technologyDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native Ixian counter-cost windows reject stale, foreign and underlying replies without a premature draw or exchange', () => {
for (const kind of ['ixAuction', 'ixTechnology'] as const) {
const f = fixture({ advanced: true, kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
reject(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(offered, f.spender, { type: 'semutaCommit', event });
reject(offered, f.spender, f.action); reject(offered, f.owner, { type: 'passResponse' });
reject(offered, f.owner, kind === 'ixTechnology' ? f.technology!.step.action
: { type: 'decision', card: f.game.deck[0].id, position: 'bottom' });
const done = kind === 'ixAuction' ? poolDenied(pass(offered), f, false) : technologyDenied(pass(offered), f, false);
reject(done, f.holder, { type: 'semutaCommit', event });
reject(done, f.spender, f.action);
}
});
void test('all four minimal profiles legally answer native pool and Technology costs including the outgoing Semuta singleton', () => {
const options = [{ advanced: false }, { advanced: true },
{ kind: 'ixTechnology' as const }, { kind: 'ixTechnology' as const, holder: 'ixians' as const }];
for (const option of options) {
const f = fixture(option), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed, difficulty);
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event);
let done = act(structuredClone(offered), f.holder, proposed);
const claimed = proposed.type === 'semutaCommit';
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(done, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
done = act(done, f.holder, selected);
} else if (done.pendingTreacheryDiscard) done = pass(done);
if (f.kind === 'ixAuction') poolDenied(done, f, claimed); else technologyDenied(done, f, claimed);
}
}
});
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaRicheseGiftKaramaFixture, actSemutaRicheseGiftKarama as act,
claimSemutaRicheseGiftKarama as claim, passSemutaRicheseGiftKarama as pass,
finishSemutaRicheseGiftKarama as finish, advanceSemutaRicheseGiftKarama as advance,
semutaRicheseGiftKaramaClean as clean, semutaRicheseGiftKaramaInventory as inventory,
semutaRicheseGiftKaramaPlayer as player,
} from './fixture-semuta-richese-gift-karama';
const resources = (game: Game) => ({
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })),
order: game.order, active: game.active, ready: game.ready,
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining,
});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Fork the inherited fixture's actual beforeGift snapshot. Its original paid
* Semuta1/Distrans1 acquisitions and native alliance are retained; only the
* final Distrans gift action is replaced by a real Semuta gift declaration.
* No played hand, resource, clock, parent or pending receipt is installed. */
function source(options: { advanced?: boolean; parent?: 'quiet' | 'emperorIncome' } = {}) {
const inherited = createSemutaRicheseGiftKaramaFixture(options);
const prefixLength = inherited.actions.length - inherited.giftDeclaration.steps.length;
assert.equal(inherited.giftDeclaration.steps.length, 1);
assert.deepEqual(inherited.actions.slice(prefixLength), inherited.giftDeclaration.steps);
assert.deepEqual(inherited.giftDeclaration.before, inherited.beforeGift);
const actions = structuredClone(inherited.actions.slice(0, prefixLength));
const beforeGift = structuredClone(inherited.beforeGift);
assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.semuta));
assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.gift));
assert.equal(player(beforeGift, inherited.donor).ally, inherited.recipient);
assert.equal(player(beforeGift, inherited.recipient).ally, inherited.donor);
assert.ok(viewGame(beforeGift, inherited.donor).richeseGift?.cards.some(card => card.id === inherited.semuta));
const giftAction: Action = { type: 'richeseGift', card: inherited.semuta };
const game = act(beforeGift, inherited.donor, giftAction, actions);
assert.deepEqual(actions.at(-1), { actor: inherited.donor, action: giftAction });
assert.equal(actions.length, prefixLength + 1);
assert.equal(game.response?.kind, 'richeseGift');
assert.equal(game.pendingRicheseGift?.intent.cardId, inherited.semuta);
assert.deepEqual(game.pendingRicheseGift!.resume, inherited.originalParent);
assert.deepEqual(inventory(game), inherited.originalInventory);
return { ...inherited, game, beforeGift, actions, sourceActionCount: actions.length,
giftAction, selectedGift: inherited.semuta, unusedDistrans: inherited.gift };
}
type Source = ReturnType<typeof source>;
function open(f: Source) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseGiftDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
[{ id: f.cost, by: f.spender, public: true }]);
assert.equal(game.pendingRicheseGift?.intent.cardId, f.semuta);
assert.deepEqual(game.pendingRicheseGift!.resume, f.originalParent);
assert.ok(player(game, f.donor).hand.some(card => card.id === f.semuta));
assert.ok(player(game, f.donor).hand.some(card => card.id === f.unusedDistrans));
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true,
'Only this already prevented gift reservation must release the actual Semuta role');
assert.equal(game.richeseGiftBlocked?.cards.includes(f.semuta) ?? false, false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
function settled(state: Game, f: Source, claimed: boolean) {
const game = finish(state);
assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingRicheseGift);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.recipient).hand.some(card => card.id === f.semuta), false);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.unusedDistrans).length, 1);
assert.equal(player(game, f.recipient).hand.some(card => card.id === f.unusedDistrans), false);
assert.deepEqual(game.richeseGiftBlocked, { turn: f.game.turn, phase: f.game.phase, cards: [f.semuta] });
assert.deepEqual(game.response, f.originalParent.response);
assert.deepEqual(game.decision, f.originalParent.decision);
assert.deepEqual(game.phaseOpening, f.originalParent.phaseOpening);
assert.deepEqual(game.pendingKarama, f.originalParent.pendingKarama);
assert.ok(player(game, f.observer).hand.some(card => card.id === f.parentCounter));
reject(game, f.donor, f.giftAction);
return game;
}
for (const advanced of [false, true]) for (const parent of ['quiet', 'emperorIncome'] as const) for (const claimed of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} prevented Semuta gift ${claimed ? 'spends that reserved Semuta to claim' : 'passes'} its cost with ${parent} parent`, () => {
const f = source({ advanced, parent });
assert.equal(f.semutaPurchase.price, 1); assert.equal(f.giftPurchase.price, 1);
assert.ok(f.semutaPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(f.giftPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.holder, f.donor);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
const game = settled(claimed ? claim(pending, f.holder) : pass(pending), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
if (parent === 'emperorIncome') {
assert.equal(game.response?.kind, 'emperorIncome');
const before = player(game, f.recipient).spice;
const remaining = advance(game, clean);
assert.equal(player(remaining, f.recipient).spice, before + 1,
'The one original cache-purchase income resumes once after gift prevention and Semuta settlement');
assert.ok(!remaining.pendingRicheseGift); assert.ok(!remaining.pendingTreacheryDiscard);
assert.deepEqual(remaining.richeseGiftBlocked, game.richeseGiftBlocked);
assert.equal(player(remaining, f.recipient).hand.some(card => card.id === f.semuta), false);
assert.ok(player(remaining, f.donor).hand.some(card => card.id === f.unusedDistrans));
assert.deepEqual(inventory(remaining), f.originalInventory);
}
});
}
void test('the prevented selected-Semuta reservation permits only its actual cost reaction and rejects stale replay', () => {
const f = source({ advanced: true, parent: 'emperorIncome' });
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
reject(pending, f.spender, f.action);
reject(pending, f.donor, f.giftAction);
reject(pending, f.recipient, { type: 'passResponse' });
reject(pending, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
const game = settled(claim(pending, f.holder), f, true);
reject(game, f.holder, { type: 'semutaCommit', event });
assert.deepEqual(game.richeseGiftBlocked!.cards, [f.semuta]);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.cost).length, 1);
});