203 lines
15 KiB
TypeScript
203 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
|
import type { TruthQuestion } from '../game/truthtrance';
|
|
import {
|
|
actFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
|
|
type FutureShipmentStep,
|
|
} from './fixture-truthtrance-future-shipment';
|
|
|
|
export type TruthtranceChoamPromiseKind = 'shipment' | 'movement';
|
|
export type TruthtranceChoamPromisesStep = FutureShipmentStep;
|
|
export const actTruthtranceChoamPromises = actFutureShipment;
|
|
export const truthtranceChoamPromisesPlayer = futureShipmentPlayer;
|
|
export const truthtranceChoamPromisesClean = futureShipmentClean;
|
|
export const truthtranceChoamPromisesPhysical = (game: Game) => ({
|
|
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
|
|
ready: game.ready, movementRemaining: game.movementRemaining,
|
|
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
|
|
elites: player.elites, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved })),
|
|
});
|
|
export function truthtranceChoamPromisesInventory(game: Game) {
|
|
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
|
.map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length, 'Each original exact-CHOAM Treachery Card has one physical slot'); return ids;
|
|
}
|
|
/** Never infer allowance from an early Yes. The real CHOAM opening response
|
|
* stays caller-owned until an explicit native pass or cancellation. */
|
|
export function nextTruthtranceChoamPromisesStep(game: Game): TruthtranceChoamPromisesStep | null {
|
|
if (game.response?.kind === 'choamCharity') return null;
|
|
if (!game.truthtrance && !game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
|
if (game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat')
|
|
return { actor: game.decision.player, action: { type: 'decision', done: true } };
|
|
if (game.decision?.kind === 'choamMovement')
|
|
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
|
}
|
|
return nextFutureShipmentStep(game);
|
|
}
|
|
export const nextTruthtranceChoamPromises = nextTruthtranceChoamPromisesStep;
|
|
export function advanceTruthtranceChoamPromises(game: Game, until: (state: Game) => boolean,
|
|
actions: TruthtranceChoamPromisesStep[] = []): Game {
|
|
for (let count = 0; count < 1600; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
const step = nextTruthtranceChoamPromisesStep(game);
|
|
assert.ok(step, 'Choose the real CHOAM income response or structured Truth answer before continuing');
|
|
game = actTruthtranceChoamPromises(game, step.actor, step.action, actions);
|
|
}
|
|
throw Error('The native CHOAM promise programme did not reach its finite boundary');
|
|
}
|
|
export function allowTruthtranceChoamPromisesIncome(game: Game, actions: TruthtranceChoamPromisesStep[] = []): Game {
|
|
assert.equal(game.response?.kind, 'choamCharity');
|
|
for (let count = 0; game.response?.kind === 'choamCharity' && count < game.players.length; count++) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
|
game = actTruthtranceChoamPromises(game, actor.id, { type: 'passResponse' }, actions);
|
|
}
|
|
assert.notEqual(game.response?.kind, 'choamCharity');
|
|
return advanceTruthtranceChoamPromises(game, state => state.phase === 2 && futureShipmentClean(state), actions);
|
|
}
|
|
|
|
/** Fresh public native A/C/E/H source before the first CHOAM opening income.
|
|
* Only original unopened deal/first-lot/Spice positions and native scalar
|
|
* lotteries are selected. Raw cash remains two until the real response ends. */
|
|
export function createTruthtranceChoamPromisesFixture(options: {
|
|
advanced?: boolean; kind?: TruthtranceChoamPromiseKind;
|
|
} = {}) {
|
|
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
|
|
const asker = 'atreides', target = 'choam', counterHolder = 'emperor', harkonnen = 'harkonnen';
|
|
let game = createGame('TRUTHCHOAMPROMISES', newPlayer(asker, 'Atreides', 'atreides'), advanced, ['choam']);
|
|
for (const faction of [target, counterHolder, harkonnen] as const)
|
|
joinGame(game, newPlayer(faction, faction, faction));
|
|
const initial = structuredClone(game), actions: TruthtranceChoamPromisesStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actTruthtranceChoamPromises(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceChoamPromises(game, until, actions); };
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const lobby = structuredClone(game);
|
|
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
|
const setup = structuredClone(game);
|
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
|
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
|
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
|
assert.ok(game.players.every(player => !player.hand.length));
|
|
const originalInventory = truthtranceChoamPromisesInventory(game); assert.equal(originalInventory.length, 35);
|
|
assert.equal(game.richeseCache ?? null, null);
|
|
const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
|
|
const ordinary = game.deck.find(card => card.kind === 'projectile' && !card.effect),
|
|
shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
|
|
assert.ok(truth && karama && ordinary && shield && snooper);
|
|
const deal = [truth.id, ordinary.id, karama.id, shield.id, snooper.id];
|
|
const dealOrder = deal.map((id, position) => {
|
|
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
|
return { position, from, card: id };
|
|
});
|
|
const auctionCard = game.deck.find(card => !deal.includes(card.id) && card.kind === 'poison' && !card.effect); assert.ok(auctionCard);
|
|
const auctionFrom = game.deck.findIndex(card => card.id === auctionCard.id); assert.ok(auctionFrom >= deal.length);
|
|
[game.deck[deal.length], game.deck[auctionFrom]] = [game.deck[auctionFrom], game.deck[deal.length]];
|
|
const auctionOrder = { position: deal.length, from: auctionFrom, card: auctionCard.id };
|
|
const beforeStartingDeal = structuredClone(game);
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
|
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
|
const rawCash = futureShipmentPlayer(game, target).spice, originalReserves = futureShipmentPlayer(game, target).reserves;
|
|
assert.equal(rawCash, 2); assert.equal(originalReserves, 20);
|
|
assert.deepEqual(futureShipmentPlayer(game, target).forces, {});
|
|
assert.ok(futureShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
|
|
assert.ok(futureShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
|
|
assert.ok(futureShipmentPlayer(game, target).hand.every(card => !card.effect && card.kind === 'projectile'));
|
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(game);
|
|
const spiceOrder = Array.from({ length: advanced ? 2 : 1 }, (_, position) => {
|
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
|
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
|
});
|
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
|
reach(state => state.phase === 0 && futureShipmentClean(state));
|
|
assert.equal(game.turn, 1); assert.equal(futureShipmentPlayer(game, target).spice, rawCash);
|
|
assert.equal(game.choamCharity, undefined); assert.equal(game.truthtrance ?? null, null);
|
|
assert.equal(game.shipmentPromises, undefined); assert.equal(game.movementPromises, undefined);
|
|
const incomeAmount = game.players.length * 2, minimum = kind === 'shipment' ? 10 : 6;
|
|
assert.equal(incomeAmount, 8);
|
|
const question: TruthQuestion = kind === 'shipment'
|
|
? { kind: 'shipment', target, territory: 'carthag', minimum }
|
|
: { kind: 'movement', target, territory: 'carthag', minimum };
|
|
const declarationStep: TruthtranceChoamPromisesStep = { actor: asker, action: { type: 'card', card: truth.id } };
|
|
const askStep: TruthtranceChoamPromisesStep = { actor: asker, action: { type: 'truthAsk', question } };
|
|
const shipment: Action = { type: 'ship', territory: kind === 'shipment' ? 'carthag' : 'arrakeen',
|
|
sector: kind === 'shipment' ? 11 : 10, amount: minimum, elite: 0, allyPayment: 0 };
|
|
const move: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 6, elite: 0 };
|
|
return { game, source: structuredClone(game), advanced, kind, asker, target, choam: target, counterHolder,
|
|
spender: counterHolder, emperor: counterHolder, harkonnen, card: truth.id, truth: truth.id, karama: karama.id,
|
|
question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum,
|
|
rawCash, incomeAmount, allowedCash: rawCash + incomeAmount, originalReserves,
|
|
shipment, move, shipmentLocation: kind === 'shipment' ? 'carthag:11' : 'arrakeen:10',
|
|
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action, purchaseCard: auctionCard.id,
|
|
purchaseCost: kind === 'movement' ? 2 : 0,
|
|
blockedBid: { type: 'bid', amount: kind === 'shipment' ? 1 : 5, allyPayment: 0 } satisfies Action,
|
|
blockedBribe: { type: 'bribe', target: asker, amount: kind === 'shipment' ? 1 : 5 } satisfies Action,
|
|
cancelIncome: { type: 'card', card: karama.id, mode: 'cancel' } satisfies Action,
|
|
initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
|
|
beforeSpiceOrder, spiceOrder, originalSpice, deal, dealOrder, auctionOrder,
|
|
originalInventory, originalTreachery: originalInventory,
|
|
originalPhysical: structuredClone(truthtranceChoamPromisesPhysical(game)),
|
|
actions, sourceActions: actions, sourceActionCount: actions.length,
|
|
staging: [
|
|
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} native A/C/E/H ready/start, exact CHOAM with all35 original main-deck Treachery Cards, no Richese cache and no optional modules. No Basic game is converted or new profile gate opened.`,
|
|
'Select only five unopened starting positions: A Truthtrance, C ordinary projectile with no Karama/Worthless effect, E printed Karama, H Shield/Snooper. Select a distinct ordinary first-auction poison card and original ordinary first-Spice positions; no played hand or future draw is installed.',
|
|
'Actually finish original private Traitor/setup controls and stop at clean first Storm. C still has its native two spice, twenty reserves, no board forces and no opening-income stamp. Truth declaration and question remain unplayed.',
|
|
kind === 'shipment'
|
|
? 'The proposed ten-force Carthag shipment needs the printed eight-spice four-seat opening. Actual allowance later yields ten; no ordinary auction cost can consume that reserved shipment budget.'
|
|
: 'The proposed six-force ground move needs a real six-force city shipment first. Actual opening allowance later funds a genuine normal purchase2, shipment6 to Arrakeen and ornithopter-range movement6 to Carthag. The preceding shipment alone does not fulfill the ground promise.',
|
|
'The native CHOAM opening response remains explicitly caller-owned. An early answer creates no credit, waiver or invented allowance. Native printed Karama cancellation, native income allowance and all later controls use their original actions; no wallet, force, clock, promise or response is installed.',
|
|
],
|
|
};
|
|
}
|
|
export type TruthtranceChoamPromisesFixture = ReturnType<typeof createTruthtranceChoamPromisesFixture>;
|
|
|
|
/** Continue only after the caller has allowed the actual opening. No hidden
|
|
* auction windfall, foreign gift or scripted income is supplied to the route. */
|
|
export function finishTruthtranceChoamPromises(f: TruthtranceChoamPromisesFixture, source: Game,
|
|
options: { answer?: 'yes' | 'no' } = {}) {
|
|
const answer = options.answer ?? 'yes'; let game = source;
|
|
const actions: TruthtranceChoamPromisesStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actTruthtranceChoamPromises(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceChoamPromises(game, until, actions); };
|
|
const transition = (run: () => void) => {
|
|
const before = structuredClone(game), start = actions.length; run();
|
|
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
|
};
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
|
assert.equal(futureShipmentPlayer(game, f.target).spice, f.allowedCash);
|
|
reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
|
|
const beforeBid = structuredClone(game);
|
|
let purchase: ReturnType<typeof transition> | null = null;
|
|
if (f.kind === 'movement') {
|
|
assert.equal(game.auction?.index, 0); assert.equal(game.auction.cards[0].id, f.purchaseCard);
|
|
purchase = transition(() => {
|
|
act(f.target, f.bid); reach(state => state.phase === 4 && futureShipmentClean(state));
|
|
});
|
|
assert.equal(futureShipmentPlayer(game, f.target).spice, f.allowedCash - 2);
|
|
assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
|
|
}
|
|
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
|
|
const beforeShipment = structuredClone(game);
|
|
const shipmentAction: Action = f.kind === 'shipment' && answer === 'no' ? { ...f.shipment, amount: 9 } : f.shipment;
|
|
const shipment = transition(() => { act(f.target, shipmentAction); reach(state => futureShipmentClean(state) && !state.pendingShipment); });
|
|
const afterShipment = structuredClone(game);
|
|
let move: ReturnType<typeof transition> | null = null;
|
|
const moveAction: Action = f.kind === 'movement' && answer === 'no' ? { ...f.move, amount: 5 } : f.move;
|
|
if (f.kind === 'movement') move = transition(() => {
|
|
act(f.target, moveAction); reach(futureShipmentClean);
|
|
});
|
|
const delivered = structuredClone(game);
|
|
let turnEnd: ReturnType<typeof transition> | null = null;
|
|
if (f.kind === 'movement' && answer === 'no') turnEnd = transition(() => {
|
|
act(f.target, { type: 'endMovement' });
|
|
assert.ok(!game.movementRemaining?.includes(f.target));
|
|
});
|
|
const finished = structuredClone(game);
|
|
assert.deepEqual(truthtranceChoamPromisesInventory(game), f.originalInventory);
|
|
return { game, beforeBid, purchase, beforeShipment, shipmentAction, shipment, afterShipment,
|
|
moveAction, move, delivered, turnEnd, finished, answer, actions };
|
|
}
|