208 lines
16 KiB
TypeScript
208 lines
16 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import {
|
||
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame,
|
||
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 { richeseNormalBetrayalInventory } from './fixture-richese-normal-betrayal';
|
||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||
|
||
export type SemutaRicheseGiftKaramaStep = { actor: string; action: Action };
|
||
export const actSemutaRicheseGiftKarama = actTruthMovement;
|
||
export const semutaRicheseGiftKaramaInventory = richeseNormalBetrayalInventory;
|
||
export const semutaRicheseGiftKaramaPlayer = (game: Game, id: string) => {
|
||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||
};
|
||
export const semutaRicheseGiftKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRicheseGift;
|
||
const GIFT = 'richese-distrans', DONOR = 'richese';
|
||
|
||
/** Original bidding and readiness controls. Fresh disposal, the gift itself
|
||
* and the selected Karama cancellation remain explicit caller actions. */
|
||
export function nextSemutaRicheseGiftKaramaStep(game: Game, fullHand = false): SemutaRicheseGiftKaramaStep | null {
|
||
if (game.pendingTreacheryDiscard || game.pendingRicheseGift || game.truthtrance) return null;
|
||
if (!game.phaseOpening && !game.response) {
|
||
const d = game.decision;
|
||
if (d?.kind === 'richeseCache') {
|
||
const selected = game.turn === 1 ? SEMUTA_DRUG_ID : GIFT;
|
||
const card = game.richeseCache!.find(card => card.id === selected);
|
||
assert.ok(card, `Original gift source needs ${selected}: ${JSON.stringify({ turn: game.turn, phase: game.phase,
|
||
decision: d.kind, cache: game.richeseCache!.map(card => card.id),
|
||
holders: game.players.map(p => ({ id: p.id, cards: p.hand.map(card => card.id) })) })}`);
|
||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||
card: card.id, method: 'silent' } };
|
||
}
|
||
if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } };
|
||
if (!d && game.status === 'playing' && game.phase === 5)
|
||
return { actor: game.active!, action: { type: 'endMovement' } };
|
||
}
|
||
if (semutaRicheseGiftKaramaClean(game)) {
|
||
const lot = game.richeseAuction;
|
||
if (lot && !lot.outcome) {
|
||
// Once Around closes unbid before its owner acts after every rival passes.
|
||
// Silent gives Richese an actual paid1 self-purchase while all rivals
|
||
// submit their original zero bids, without a fictitious prior rival bid.
|
||
assert.equal(lot.method, 'silent');
|
||
const actor = lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)); assert.ok(actor);
|
||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: actor === DONOR ? 1 : 0, allyPayment: 0 } };
|
||
}
|
||
if (fullHand && game.turn === 1 && game.auction?.active === DONOR && game.auction.bid === 0 &&
|
||
semutaRicheseGiftKaramaPlayer(game, DONOR).hand.length < 3)
|
||
return { actor: DONOR, action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||
}
|
||
return nextTruthMovementNativeEarlyStep(game, {
|
||
position: 'first', method: 'silent', keepUnbid: false,
|
||
card: game.richeseCache?.find(card => card.id === (game.turn === 1 ? SEMUTA_DRUG_ID : GIFT))?.id,
|
||
});
|
||
}
|
||
export function advanceSemutaRicheseGiftKarama(game: Game, until: (state: Game) => boolean,
|
||
actions: SemutaRicheseGiftKaramaStep[] = [], fullHand = false): Game {
|
||
for (let count = 0; count < 1800; count++) {
|
||
if (until(game)) return game;
|
||
assert.notEqual(game.status, 'finished');
|
||
const step = nextSemutaRicheseGiftKaramaStep(game, fullHand);
|
||
assert.ok(step, 'Resolve the actual fresh discard or gift before continuing');
|
||
game = actSemutaRicheseGiftKarama(game, step.actor, step.action, actions);
|
||
}
|
||
throw Error('The original Semuta/Richese programme did not reach its finite boundary');
|
||
}
|
||
|
||
/** Pass only this current fresh batch; preserve any restored response parent. */
|
||
export function passSemutaRicheseGiftKarama(game: Game, actions: SemutaRicheseGiftKaramaStep[] = []): 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 = actSemutaRicheseGiftKarama(game, actor.id, { type: 'semutaPass', event }, actions);
|
||
}
|
||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
|
||
}
|
||
export function claimSemutaRicheseGiftKarama(game: Game, holder: string,
|
||
actions: SemutaRicheseGiftKaramaStep[] = []): 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 = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaCommit', event }, actions);
|
||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||
game = actSemutaRicheseGiftKarama(game, holder, { type: 'semutaSelect', event, card }, actions);
|
||
return game;
|
||
}
|
||
/** The used-cost continuation completes the prevention itself. Do not allow
|
||
* the gift or spend a restored parent response in this helper. */
|
||
export function finishSemutaRicheseGiftKarama(game: Game): Game {
|
||
assert.ok(!game.pendingTreacheryDiscard, 'Choose the original fresh-discard reaction first');
|
||
assert.ok(!game.pendingRicheseGift, 'The original gift prevention must finish before its parent resumes');
|
||
return game;
|
||
}
|
||
|
||
/** Genuine native acquisitions/alliance. The only controls over physical stock
|
||
* are unopened starting-card/main-deck/Spice positions and original lotteries.
|
||
* No played hand, balance, army, alliance, phase or paused receipt is assigned. */
|
||
export function createSemutaRicheseGiftKaramaFixture(options: {
|
||
advanced?: boolean; parent?: 'quiet' | 'emperorIncome'; fullHand?: boolean;
|
||
} = {}) {
|
||
const advanced = options.advanced ?? false, parent = options.parent ?? 'quiet', fullHand = options.fullHand ?? false;
|
||
const donor = DONOR, holder = donor, recipient = 'emperor', spender = 'atreides', observer = 'choam';
|
||
let game = createGame('SEMUTARICHESEGIFTKARAMA', newPlayer(donor, 'Richese', 'richese'), advanced, ['choam']);
|
||
for (const faction of ['choam', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||
const actions: SemutaRicheseGiftKaramaStep[] = [];
|
||
const act = (actor: string, action: Action) => { game = actSemutaRicheseGiftKarama(game, actor, action, actions); };
|
||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRicheseGiftKarama(game, until, actions, fullHand); };
|
||
const transition = (run: () => void) => {
|
||
const before = structuredClone(game), start = actions.length; run();
|
||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||
};
|
||
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 = semutaRicheseGiftKaramaInventory(game); assert.equal(originalInventory.length, 45);
|
||
const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2);
|
||
const ordinary = game.deck.find(card => card.kind === 'projectile'), shield = game.deck.find(card => card.kind === 'shield');
|
||
assert.ok(ordinary && shield);
|
||
const deal = [ordinary.id, karamas[0].id, karamas[1].id, shield.id];
|
||
const dealOrder = deal.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 { position, from, card };
|
||
});
|
||
const beforeStartingDeal = structuredClone(game), cost = karamas[1].id, parentCounter = karamas[0].id;
|
||
reach(state => state.status === 'playing');
|
||
const afterSetup = structuredClone(game);
|
||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
|
||
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||
const order = (worm: boolean, position: number) => {
|
||
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
|
||
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
|
||
: 'territory' in card && !card.discovery)); assert.ok(from >= position);
|
||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||
};
|
||
order(false, 0); if (advanced) order(false, 1);
|
||
reach(state => state.turn === 1 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
|
||
let normalOrder: { card: string; from: number; before: Game; after: Game } | null = null;
|
||
if (fullHand) {
|
||
const before = structuredClone(game), from = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(from >= 0);
|
||
const card = game.deck.splice(from, 1)[0]; game.deck.unshift(card);
|
||
normalOrder = { card: card.id, from, before, after: structuredClone(game) };
|
||
}
|
||
const buy = (card: string) => {
|
||
const before = structuredClone(game), start = actions.length;
|
||
reach(state => semutaRicheseGiftKaramaPlayer(state, donor).hand.some(candidate => candidate.id === card));
|
||
assert.ok(actions.slice(start).some(step => step.actor === donor && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||
assert.equal(semutaRicheseGiftKaramaPlayer(before, donor).spice - semutaRicheseGiftKaramaPlayer(game, donor).spice, 1);
|
||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card, price: 1 };
|
||
};
|
||
const semutaPurchase = buy(SEMUTA_DRUG_ID);
|
||
reach(state => state.turn === 1 && state.phase === 4 && semutaRicheseGiftKaramaClean(state));
|
||
const afterFirstBidding = structuredClone(game);
|
||
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).hand.length, fullHand ? 3 : 2);
|
||
if (normalOrder) assert.ok(semutaRicheseGiftKaramaPlayer(game, donor).hand.some(card => card.id === normalOrder.card));
|
||
reach(state => state.turn === 2 && state.phase === 1 && semutaRicheseGiftKaramaClean(state) &&
|
||
!state.nexus && !state.spiceWindow && !state.spiceResolution && !state.spiceSequence && !state.ready.length);
|
||
const beforeWorm = structuredClone(game); order(true, 0); order(false, 1); if (advanced) order(false, 2);
|
||
reach(state => state.nexus && semutaRicheseGiftKaramaClean(state) && !state.spiceWindow && !state.spiceResolution);
|
||
const alliance = transition(() => {
|
||
act(donor, { type: 'alliance', target: recipient }); act(recipient, { type: 'alliance', target: donor });
|
||
});
|
||
assert.equal(semutaRicheseGiftKaramaPlayer(game, donor).ally, recipient);
|
||
assert.equal(semutaRicheseGiftKaramaPlayer(game, recipient).ally, donor);
|
||
reach(state => state.turn === 2 && state.phase === 2 && semutaRicheseGiftKaramaClean(state));
|
||
const giftPurchase = buy(GIFT);
|
||
assert.equal(game.response?.kind, 'emperorIncome');
|
||
assert.equal(game.currentAuctionSale?.amount ?? game.auction?.bid, 1);
|
||
if (parent === 'quiet') reach(semutaRicheseGiftKaramaClean);
|
||
assert.equal(semutaRicheseGiftKaramaPlayer(game, holder).hand.length, fullHand ? 4 : 3);
|
||
assert.ok(semutaRicheseGiftKaramaPlayer(game, spender).hand.some(card => card.id === cost));
|
||
assert.ok(semutaRicheseGiftKaramaPlayer(game, observer).hand.some(card => card.id === parentCounter));
|
||
assert.ok(viewGame(game, donor).richeseGift?.cards.some(card => card.id === GIFT));
|
||
const beforeGift = structuredClone(game), originalParent = { response: structuredClone(game.response),
|
||
decision: structuredClone(game.decision), phaseOpening: structuredClone(game.phaseOpening), pendingKarama: structuredClone(game.pendingKarama) };
|
||
const giftAction: Action = { type: 'richeseGift', card: GIFT }, giftDeclaration = transition(() => act(donor, giftAction));
|
||
assert.equal(game.response?.kind, 'richeseGift'); assert.equal(game.response?.recipient, recipient);
|
||
assert.equal(game.pendingRicheseGift?.intent.cardId, GIFT);
|
||
assert.deepEqual(game.pendingRicheseGift?.resume, originalParent);
|
||
assert.deepEqual(semutaRicheseGiftKaramaInventory(game), originalInventory);
|
||
const action: Action = { type: 'card', card: cost, mode: 'cancel' }, source = structuredClone(game);
|
||
return { game, source, beforeCost: source, beforeGift, advanced, parent, fullHand,
|
||
holder, donor, owner: donor, recipient, spender, observer, parentCounter,
|
||
semuta: SEMUTA_DRUG_ID, cost, karama: cost, gift: GIFT, card: cost, action, giftAction,
|
||
lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||
semutaPurchase, normalOrder, afterFirstBidding, beforeWorm, alliance, giftPurchase, giftDeclaration, originalParent,
|
||
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
|
||
staging: [
|
||
'Basic uses the real public bare Richese/CHOAM/Atreides/Emperor start. Advanced uses the existing explicit Semuta initializer on the original ready undealt lobby. Both retain original main35/cache10; no mode is converted after start.',
|
||
'Only unopened original starting-card positions (one actual printed Karama each to Atreides/CHOAM), original land/Worm Spice positions and scalar lotteries are selected. Each actual Silent cache auction has a real Richese bid1 and original zero submissions from all other eligible seats: Richese pays1 for Semuta on turn1, forms a mutual Emperor alliance at the actual turn2 Worm, and separately pays1 for Distrans before declaring its original allied gift. This avoids native Once Around’s all-rivals-pass free-retention boundary.',
|
||
fullHand ? 'The full4 branch selects one still-unopened normal Shield before turn1 Bidding and really bids/pays normal1 for it. Starting ordinary + actual Semuta + actual Shield + actual Distrans fill the native hand before the fresh cost; Semuta exchange frees its own slot.' : 'The ordinary source retains exactly its original starting card plus the two actual paid cache purchases.',
|
||
parent === 'emperorIncome' ? 'The original paid-cache EmperorIncome1 remains pending when Richese declares the gift. The distinct originally dealt CHOAM Karama keeps that native parent cancellable after Atreides spends its own original printed Karama.' : 'The quiet branch actually passes the prior original cache-purchase income response before declaring the gift.',
|
||
'Stop before the actual Atreides cancellation. No played card custody, wallet, force, alliance, clock, response, receipt or prevention outcome is installed; caller owns the printed cost and neutral fresh-discard claim/pass.',
|
||
] };
|
||
}
|
||
export type SemutaRicheseGiftKaramaFixture = ReturnType<typeof createSemutaRicheseGiftKaramaFixture>;
|