Enable early native-faction Truthtrance promises

This commit is contained in:
Quintine committed 2026-10-26 06:21:34 +11:00
1 parent 8053e1fb26
commit d5c5342d2d
22 files changed
+1371 -44

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
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 };
}
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TruthtranceNativeFamilyPromisesKind = 'shipment' | 'movement';
export type TruthtranceNativeFamilyPromisesStep = TruthMovementStep;
export const actTruthtranceNativeFamilyPromises = actTruthMovement;
export const truthtranceNativeFamilyPromisesPlayer = truthMovementPlayer;
export const truthtranceNativeFamilyPromisesInventory = truthMovementInventory;
export const truthtranceNativeFamilyPromisesClean = truthMovementClean;
export const truthtranceNativeFamilyPromisesPhysical = (game: Game) => game.players.map(p => ({
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, leaders: p.leaders, ally: p.ally, moved: p.moved, shipped: p.shipped,
}));
/** Native quiet controls only. The original question and Ecaz's actual
* shipment/movement remain caller-owned, including choosing to skip them. */
export function nextTruthtranceNativeFamilyPromises(game: Game): TruthMovementStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'moritaniSetup')
return { actor: decision.player, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
if (decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
truthMovementPlayer(game, 'ecaz').reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
}
if (game.status === 'playing' && game.phase === 5 && game.active === 'ecaz' && truthMovementClean(game)) return null;
return nextStrongholdFactionsNativeStep(game);
}
export const nextTruthtranceNativeFamilyPromisesStep = nextTruthtranceNativeFamilyPromises;
export function advanceTruthtranceNativeFamilyPromises(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextTruthtranceNativeFamilyPromises(game);
assert.ok(next, `Supply the original Truthtrance or Ecaz action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The original E3 promise programme did not reach its finite boundary');
}
export function finishTruthtranceNativeFamilyPromises(game: Game, actions: TruthMovementStep[] = []): Game {
return advanceTruthtranceNativeFamilyPromises(game, truthMovementClean, actions);
}
/** Actual public Basic/Advanced native E3 setup, original undealt card
* positions and an unopened land Spice selection. No played custody is set. */
export function createTruthtranceNativeFamilyPromisesFixture(options: {
advanced?: boolean; kind?: TruthtranceNativeFamilyPromisesKind;
} = {}) {
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
const asker = 'atreides', target = 'ecaz', moritani = 'moritani';
let game = createGame(`TRUTHNATIVEFAMILYPROMISES${advanced ? 'ADV' : 'BASIC'}${kind.toUpperCase()}`,
newPlayer(asker, 'Atreides', 'atreides'), advanced, ['ecaz']);
joinGame(game, newPlayer(target, 'Ecaz', 'ecaz'));
joinGame(game, newPlayer(moritani, 'Moritani', 'moritani'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceNativeFamilyPromises(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
const start: TruthMovementStep = { actor: game.host, action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
act(start.actor, start.action);
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
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 === 0));
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 2);
assert.ok(truth); assert.equal(ordinary.length, 2);
const selected = [truth, ...ordinary], deal = selected.map(card => card.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 { card: id, position, from };
});
const beforeStartingDeal = structuredClone(game), setupAt = actions.length;
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupSteps = structuredClone(actions.slice(setupAt));
const ecazSetup = setupSteps.find(step => step.action.type === 'ecazSetup'); assert.ok(ecazSetup);
const moritaniSetup = setupSteps.find(step => step.action.type === 'decision' && step.action.territory === 'oh_gap'); assert.ok(moritaniSetup);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.deepEqual(truthMovementPlayer(game, target).forces, { 'imperial_basin:10': 6 });
assert.equal(truthMovementPlayer(game, target).reserves, 14);
assert.equal(truthMovementPlayer(game, target).spice, 12);
assert.equal(truthMovementPlayer(game, asker).forces['arrakeen:10'], 10);
assert.deepEqual(truthMovementPlayer(game, moritani).forces, { 'oh_gap:9': 6 });
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const beforeSpiceOrder = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land);
const spiceOrder = [{ position: 0, from, card: structuredClone(land) }], phaseAt = actions.length;
reach(state => state.turn === 1 && state.phase === 2 && truthMovementClean(state));
const nativePhases = { before: beforeSpiceOrder, steps: structuredClone(actions.slice(phaseAt)), after: structuredClone(game) };
assert.equal(game.advanced, advanced); assert.deepEqual(game.ready, []);
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined); assert.equal(game.movementPromises, undefined);
assert.equal(truthMovementPlayer(game, target).spice, 12);
assert.equal(truthMovementPlayer(game, target).reserves, 14);
assert.equal(truthMovementPlayer(game, target).forces['imperial_basin:10'], 6);
assert.deepEqual(truthMovementInventory(game), originalCards);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
const question = { kind, target, territory: kind === 'shipment' ? 'carthag' : 'arrakeen', minimum: kind === 'shipment' ? 5 : 6 };
const declarationStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question } };
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 0, allyPayment: 0 };
const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 6 };
return { game, source: structuredClone(game), advanced, kind, asker, target, owner: target, moritani,
truth: truth.id, card: truth.id, question, claim: question, declarationStep, askStep, shipment, move,
eventAction: kind === 'shipment' ? shipment : move, endMovement: { type: 'endMovement' } as Action,
initial, lobby, start, setup, beforeStartingDeal, afterSetup, setupSteps, ecazSetup, moritaniSetup,
beforeSpiceOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards, deal, dealOrder, nativePhases,
physical: structuredClone(truthtranceNativeFamilyPromisesPhysical(game)), actions, sourceActionCount: actions.length,
staging: [
`Original public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Ecaz/Moritani setup selects exact Ecaz and no optional module or independent Treachery variant. Original unopened starting positions deal Truthtrance to Atreides and ordinary cards to Ecaz/Moritani. Ecaz actually chooses six in Basin10, Moritani six in Oh Gap9, and Atreides retains its original Arrakeen10 garrison.`,
'Actual Storm/Spice and readiness reach first Charity before declaration. Ecaz retains printed12 cash,14 reserves and its native six-counter group. A future reserve-city5 shipment pays real5, or a future adjacent ground move sends the original six to Arrakeen beside original Atreides10. No hand, army, wallet, phase clock, promise, parent or completed event is installed.',
] };
}
export type TruthtranceNativeFamilyPromisesFixture = ReturnType<typeof createTruthtranceNativeFamilyPromisesFixture>;
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actTruthMovement, truthMovementClean, truthMovementPlayer, type TruthMovementStep,
} from './fixture-truth-movement';
import {
nextTruthMovementNativeEarlyStep, type TruthMovementNativeEarlyAuction,
} from './fixture-truth-movement-native-early';
export type TruthtranceRichesePromisesKind = 'shipment' | 'movement';
export type TruthtranceRichesePromisesStep = TruthMovementStep;
export type TruthtranceRichesePromisesTransition = {
before: Game; steps: TruthtranceRichesePromisesStep[]; after: Game;
};
export const actTruthtranceRichesePromises = actTruthMovement;
export const truthtranceRichesePromisesClean = truthMovementClean;
export const truthtranceRichesePromisesPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor);
export function truthtranceRichesePromisesInventory(game: Game) {
const start = (game.auction?.index ?? 0) + Number(game.currentAuctionSale?.origin === 'normal');
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(start) ?? []),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical card has one current location');
return ids;
}
export const truthtranceRichesePromisesPhysical = (game: Game) => game.players.map(p => ({
player: p.id, reserves: p.reserves, tanks: p.tanks, forces: structuredClone(p.forces),
elites: structuredClone(p.elites), noField: structuredClone(p.noField),
shipped: p.shipped, moved: p.moved,
}));
/** Only original controls. Richese owns its real shipment/move and questions;
* native cache defaults are first / Once Around / all pass / remove unbid. */
export function nextTruthtranceRichesePromises(game: Game,
auction: TruthMovementNativeEarlyAuction = {}): TruthtranceRichesePromisesStep | null {
if (game.truthtrance) {
if (game.truthtrance.stage !== 'priority') return null;
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'truthPass' } };
}
if (truthtranceRichesePromisesClean(game) && game.status === 'playing' && game.phase === 5) {
if (game.active === 'richese') return null;
if (game.active === 'emperor') return { actor: 'emperor', action: { type: 'endMovement' } };
}
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: 'richese-stone-burner', ...auction,
});
}
export const nextTruthtranceRichesePromisesStep = nextTruthtranceRichesePromises;
export function advanceTruthtranceRichesePromises(game: Game, until: (state: Game) => boolean,
actions: TruthtranceRichesePromisesStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Richese source ended before its requested control');
const step = nextTruthtranceRichesePromises(game, auction);
assert.ok(step, `Choose the original Richese question, bid, shipment or move at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthtranceRichesePromises(game, step.actor, step.action, actions);
}
throw Error('The native Richese promise source did not reach its finite boundary');
}
export function finishTruthtranceRichesePromises(game: Game, actions: TruthtranceRichesePromisesStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (game.phase === 7 && truthtranceRichesePromisesClean(game)) return game;
const step = truthtranceRichesePromisesClean(game) && game.phase === 5 && game.active === 'richese'
? { actor: 'richese', action: { type: 'endMovement' } as Action }
: nextTruthtranceRichesePromises(game);
assert.ok(step, 'Complete the accepted original Richese promise before finishing movement');
game = actTruthtranceRichesePromises(game, step.actor, step.action, actions);
}
throw Error('The native Richese movement did not reach original Collection');
}
/** Genuine public R/A/E, exact E2 main35/cache10, no modules. Select original
* unopened deal/normal-lot/Spice positions only; native actions earn all state. */
export function createTruthtranceRichesePromisesFixture(options: {
advanced?: boolean; kind?: TruthtranceRichesePromisesKind;
} = {}) {
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
const target = 'richese', asker = 'atreides', observer = 'emperor';
let game = createGame('TRUTHRICHESENATIVEPROMISES', newPlayer(target, 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer(asker, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthtranceRichesePromisesStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceRichesePromises(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceRichesePromises(game, until, actions); };
const transition = (run: () => void): TruthtranceRichesePromisesTransition => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
const rules = transition(() => { if (advanced) act(game.host, { type: 'rules', advanced: true }); });
for (const p of game.players) act(p.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.players.every(p => !p.hand.length));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = truthtranceRichesePromisesInventory(game);
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalInventory.length, 45); assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
const ordinaryLot = game.deck.find(card => card.name === 'Baliset'); assert.ok(truth && shield && snooper && ordinaryLot);
const deal = [shield.id, truth.id, snooper.id];
const dealOrder = [...deal, ordinaryLot.id].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 beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
for (const [index, p] of game.players.entries()) assert.deepEqual(p.hand.map(card => card.id), [deal[index]]);
const originalNoField = structuredClone(truthtranceRichesePromisesPlayer(game).noField);
assert.ok(originalNoField); assert.equal(originalNoField.deployed, null);
assert.deepEqual(truthtranceRichesePromisesPlayer(game).forces, {});
assert.equal(truthtranceRichesePromisesPlayer(game).reserves, 20);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(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) };
});
reach(state => state.turn === 1 && state.phase === 2 && truthtranceRichesePromisesClean(state));
const earlySource = structuredClone(game), earlyActionCount = actions.length;
assert.equal(truthtranceRichesePromisesPlayer(game).spice, 5);
const beforeBidding = structuredClone(game);
const shipmentAction: Action = { type: 'ship', territory: kind === 'shipment' ? 'carthag' : 'arrakeen',
sector: kind === 'shipment' ? 11 : 10,
amount: kind === 'shipment' ? 5 : 1, elite: 0, allyPayment: 0 };
const source = structuredClone(game), territory = 'carthag';
const minimum = kind === 'shipment' ? 5 : 1, sector = 11;
const question: TruthQuestion = { kind, target, territory, minimum };
const moveAction: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 };
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length ?? 0, 0);
assert.equal(game.movementPromises?.length ?? 0, 0);
assert.deepEqual(truthtranceRichesePromisesInventory(game), originalInventory);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
return { game, source, advanced, kind, asker, target, richese: target, observer, emperor: observer,
card: truth.id, truth: truth.id, truthtrance: truth.id, targetCard: shield.id, ordinaryLot: ordinaryLot.id,
question, claim: question, minimum, impossibleMinimum: minimum + 1, territory, sector, location: `${territory}:${sector}`,
declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthtranceRichesePromisesStep,
askStep: { actor: asker, action: { type: 'truthAsk', question } } satisfies TruthtranceRichesePromisesStep,
shipmentAction, shipmentStep: { actor: target, action: shipmentAction },
moveAction, moveStep: { actor: target, action: moveAction }, action: kind === 'shipment' ? shipmentAction : moveAction,
cacheCard: 'richese-stone-burner', cacheBid: { type: 'richeseBid', amount: 1, allyPayment: 0 } satisfies Action,
normalBid: { type: 'bid', amount: 1, allyPayment: 0 } satisfies Action,
initial, rules, lobby, setup, beforeStartingDeal, afterSetup, deal, dealOrder,
originalMainDeck, originalCache, originalCards: originalInventory, originalInventory, originalSpice, spiceOrder, originalNoField,
earlySource, beforeBidding, budget: truthtranceRichesePromisesPlayer(game).spice,
originalRound: structuredClone(game.richeseBidding), originalFunding: structuredClone(game.richeseFunding),
originalPhysical: truthtranceRichesePromisesPhysical(game),
actions, steps: actions, sourceActionCount: actions.length, sourceCounts: { early: earlyActionCount, source: actions.length },
staging: [
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor, exact CHOAM main35/cache10 and no modules. Only original unopened deal positions (R Shield, A Truthtrance, E Snooper, first ordinary lot Baliset), two unopened land Spice positions and original scalar lotteries are selected. No played hand, wallet, army, phase, No-Field, promise or receipt is installed.`,
kind === 'shipment'
? 'Stop at actual turn1 Charity with native Richese five spice, twenty reserves, no board forces and unused shipment/movement. Original Black Market (Advanced), owned cache position/card/terms, native bids/unbid removal and ordinary invoices remain real future controls. Baseline asks for five actual ordinary city counters, financed solely by current Richese cash. Native one-spice own purchases leave four; no later cache-sale income is assumed.'
: 'Stop at actual turn1 Charity with native Richese five spice, twenty reserves, no board forces and unused shipment/movement. The question asks for a later real one-counter ground move to Carthag. The native target-private completion guide must find an affordable original route on the later turn and may prefer an original No-Field. The ordinary physical case explicitly ships1 to Arrakeen, then uses the actual ground completion; minimal profiles retain their native chosen guide. No future landing, force, payment, route or outcome is preinstalled. Original cache and ordinary auctions remain caller-visible native controls.',
],
};
}
export type TruthtranceRichesePromisesFixture = ReturnType<typeof createTruthtranceRichesePromisesFixture>;
export function askTruthtranceRichesePromises(game: Game, fixture: TruthtranceRichesePromisesFixture,
question: TruthQuestion = fixture.question, actions: TruthtranceRichesePromisesStep[] = []): Game {
game = actTruthtranceRichesePromises(game, fixture.asker, fixture.declarationStep.action, actions);
game = advanceTruthtranceRichesePromises(game, state => state.truthtrance?.stage === 'ask', actions);
return actTruthtranceRichesePromises(game, fixture.asker, { type: 'truthAsk', question }, actions);
}
+15 -2
View File
@@ -32,6 +32,19 @@ void test('Advanced shipment scope excludes separately enabled optional modules
true,
);
});
void test('native faction decks admit early Basic and Advanced shipment scope while retaining module fences', () => {
const families = [
['ix', 'ixians'], ['ix', 'tleilaxu'], ['choam', 'choam'],
['choam', 'richese'], ['ecaz', 'ecaz'], ['ecaz', 'moritani'],
];
for (const advanced of [false, true]) for (const phase of [0, 2, 5]) for (const [family, faction] of families) {
const native = { advanced, phase, expansions: [family], players: [{ faction: 'atreides' }, { faction }] };
assert.equal(shipmentPromiseModeSupported(native), true, `${faction}/${advanced}/${phase}`);
assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [family, family] }), false);
assert.equal(shipmentPromiseModeSupported({ ...native, ecazTreachery: true }), false);
if (phase < 5) assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [] }), false);
}
});
import {
applyAction,
createGame,
@@ -528,14 +541,14 @@ void test('malformed questions and unsupported public scopes reject without disc
},
});
for (const change of [
'expansion',
'duplicateDeck',
'phase',
'spent',
'inactive',
'territory',
]) {
const bad = structuredClone(g);
if (change === 'expansion') bad.expansions = ['ix'];
if (change === 'duplicateDeck') bad.expansions = ['ix', 'ix'];
if (change === 'phase') bad.phase = 6;
if (change === 'spent') bad.players[1].shipped = true;
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
@@ -121,6 +121,11 @@ void test('controlled original Karama prevents a native No-Field preparation bef
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
// The original Karama disposal must finish its native Semuta offer before
// the deferred prevention and impossibility release actually settle.
if (g.pendingTreacheryDiscard) assert.equal(g.movementPromises![0].released, undefined);
g = advance(g, clean);
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards);
});
+2 -2
View File
@@ -568,9 +568,9 @@ void test('unrelated opponent cards and wealth cannot change Advanced answer or
}
});
void test('selected expansions still reject unsupported questions immutably before binding or discard', () => {
void test('duplicate expansion declarations reject unsupported questions immutably before binding or discard', () => {
const g = fixture('emperor');
g.expansions = ['ix'];
g.expansions = ['ix', 'ix'];
const pending = openQuestion(g);
reject(pending, 'a', {
type: 'truthAsk',
@@ -0,0 +1,181 @@
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 {
createTruthtranceChoamPromisesFixture as fixture, actTruthtranceChoamPromises as act,
advanceTruthtranceChoamPromises as advance, allowTruthtranceChoamPromisesIncome as allowIncome,
finishTruthtranceChoamPromises as finish, truthtranceChoamPromisesPlayer as player,
truthtranceChoamPromisesInventory as inventory, truthtranceChoamPromisesPhysical as physical,
type TruthtranceChoamPromisesFixture,
} from './fixture-truthtrance-choam-promises';
const variants = [
{ advanced: false, kind: 'shipment' as const }, { advanced: true, kind: 'shipment' as const },
{ advanced: false, kind: 'movement' as const }, { advanced: true, kind: 'movement' as const },
];
const promise = (game: Game, f: TruthtranceChoamPromisesFixture) =>
f.kind === 'shipment' ? game.shipmentPromises?.[0] : game.movementPromises?.[0];
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: TruthtranceChoamPromisesFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.setup.expansions, ['choam']);
assert.deepEqual(f.game.players.map(player => player.faction), ['atreides', 'choam', 'emperor', 'harkonnen']);
assert.ok(!f.setup.homeworlds && !f.setup.nexusCards && !f.setup.leaderSkills && !f.setup.techTokens &&
!f.setup.strongholdCards && !f.setup.discoveryEnabled && !f.setup.ecazTreachery);
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.afterSetup.players.flatMap(player => player.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.target).hand.length, 1);
assert.ok(player(f.afterSetup, f.target).hand.every(card => card.kind === 'projectile' && !card.effect));
assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 1); assert.equal(f.game.truthtrance ?? null, null);
assert.equal(player(f.game, f.target).spice, 2); assert.equal(player(f.game, f.target).reserves, 20);
assert.deepEqual(player(f.game, f.target).forces, {}); assert.equal(player(f.game, f.target).tanks, 0);
assert.equal(f.game.choamCharity, undefined); assert.equal(f.incomeAmount, 8); assert.equal(f.allowedCash, 10);
assert.equal(f.originalInventory.length, 35); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
}
function asked(f: TruthtranceChoamPromisesFixture) {
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action);
game = advance(game, state => state.truthtrance?.stage === 'ask');
game = act(game, f.askStep.actor, f.askStep.action);
const answers = f.kind === 'shipment' ? viewGame(game, f.target).truthShipmentAnswers : viewGame(game, f.target).truthMovementAnswers;
assert.ok(answers?.includes('yes'), 'Native owned opening income supplies an allowed-outcome route');
assert.ok(answers?.includes('no')); return game;
}
function bound(f: TruthtranceChoamPromisesFixture, answer: 'yes' | 'no' = 'yes') {
const game = act(asked(f), f.target, { type: 'truthAnswer', answer });
assert.equal(game.truthtrance ?? null, null); assert.equal(promise(game, f)?.answer, answer === 'yes');
assert.equal(promise(game, f)?.released, undefined); assert.equal(promise(game, f)?.fulfilled, undefined);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.choamCharity, undefined);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(inventory(game), f.originalInventory);
const view = viewGame(game, f.target); assert.equal(view.shipmentCompletion, null);
assert.ok(!(view.movementPromisePolicy ?? []).some(action => action.type === 'ship' || action.type === 'move'));
reject(game, f.target, f.shipment); reject(game, f.target, f.move); return game;
}
function incomePending(game: Game, f: TruthtranceChoamPromisesFixture) {
const actions: { actor: string; action: Action }[] = [];
const before = structuredClone(game);
game = advance(game, state => state.phase === 2 && state.response?.kind === 'choamCharity', actions);
assert.equal(game.response?.owner, f.target); assert.equal(player(game, f.target).spice, 2);
assert.equal(game.choamCharity, undefined); assert.equal(promise(game, f)?.released, undefined);
assert.ok(viewGame(game, f.counterHolder).responseControls?.cancelCards.includes(f.karama));
reject(game, f.target, { type: 'ready' }); reject(game, f.target, { type: 'charity' });
reject(game, f.target, f.shipment); return { game, before, actions };
}
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} promise awaits real opening income then completes its funded native route`, () => {
const f = fixture(variant); nativeSource(f);
const pending = incomePending(bound(f), f), originalResponse = structuredClone(pending.game.response);
assert.equal(promise(pending.game, f)?.fulfilled, undefined);
const incomeActions: { actor: string; action: Action }[] = [];
const allowed = allowIncome(pending.game, incomeActions);
assert.equal(player(allowed, f.target).spice, 10);
assert.deepEqual(allowed.choamCharity, { turn: 1, canceled: false });
assert.equal(originalResponse?.kind, 'choamCharity'); assert.ok(incomeActions.some(step => step.action.type === 'passResponse'));
assert.equal(promise(allowed, f)?.released, undefined);
reject(allowed, f.target, f.blockedBribe);
const completed = finish(f, allowed);
reject(completed.beforeBid, f.target, f.blockedBid);
assert.equal(completed.afterShipment.choamCharity?.canceled, false);
assert.equal(player(completed.delivered, f.target).forces[f.location], f.minimum);
assert.equal(promise(completed.delivered, f)?.fulfilled, true);
assert.equal(promise(completed.delivered, f)?.released, undefined);
if (f.kind === 'shipment') {
assert.equal(completed.purchase, null); assert.equal(completed.move, null);
assert.equal(player(completed.delivered, f.target).spice, 0);
assert.equal(player(completed.delivered, f.target).reserves, 10);
assert.equal(player(completed.delivered, f.target).moved, 0);
} else {
assert.ok(completed.purchase && completed.move);
assert.equal(player(completed.purchase.after, f.target).spice, 8);
assert.ok(player(completed.purchase.after, f.target).hand.some(card => card.id === f.purchaseCard));
assert.equal(player(completed.afterShipment, f.target).forces['arrakeen:10'], 6);
assert.equal(promise(completed.afterShipment, f)?.fulfilled, undefined, 'The reserve shipment is not the promised ground event');
assert.equal(player(completed.delivered, f.target).forces['arrakeen:10'] ?? 0, 0);
assert.equal(player(completed.delivered, f.target).reserves, 14);
assert.equal(player(completed.delivered, f.target).spice, 2);
assert.equal(player(completed.delivered, f.target).moved, 1);
}
assert.equal(player(completed.delivered, f.target).shipped, true);
assert.ok(player(completed.delivered, f.counterHolder).hand.some(card => card.id === f.karama));
assert.equal(completed.delivered.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} opening cancellation spends the real Karama and releases only the unfunded obligation`, () => {
const f = fixture(variant); nativeSource(f); const pending = incomePending(bound(f), f);
const game = act(pending.game, f.counterHolder, f.cancelIncome);
assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: true });
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).reserves, 20);
assert.deepEqual(player(game, f.target).forces, {});
assert.equal(promise(game, f)?.released, true); assert.equal(promise(game, f)?.fulfilled, undefined);
assert.equal(player(game, f.counterHolder).hand.some(card => card.id === f.karama), false);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
reject(game, f.counterHolder, f.cancelIncome); reject(game, f.target, { type: 'passResponse' });
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} No retains a real smaller alternative without reserving Yes funds`, () => {
const f = fixture(variant), pending = incomePending(bound(f, 'no'), f), allowed = allowIncome(pending.game);
const spent = act(structuredClone(allowed), f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(spent, f.target).spice, 9); assert.equal(promise(spent, f)?.released, undefined);
const completed = finish(f, allowed, { answer: 'no' });
assert.equal(player(completed.delivered, f.target).forces[f.location], f.minimum - 1);
assert.equal(promise(completed.delivered, f)?.released, undefined);
if (f.kind === 'shipment') {
assert.equal(promise(completed.delivered, f)?.fulfilled, true);
assert.equal(player(completed.delivered, f.target).spice, 1); assert.equal(completed.turnEnd, null);
} else {
assert.equal(promise(completed.delivered, f)?.fulfilled, undefined, 'A later move could still match until this actual turn ends');
assert.equal(player(completed.delivered, f.target).forces['arrakeen:10'], 1);
assert.ok(completed.turnEnd);
assert.deepEqual(completed.turnEnd.steps, [{ actor: f.target, action: { type: 'endMovement' } }]);
assert.equal(promise(completed.finished, f)?.fulfilled, true);
assert.ok(!completed.finished.movementRemaining?.includes(f.target));
assert.deepEqual(player(completed.finished, f.target).forces, player(completed.delivered, f.target).forces);
assert.equal(player(completed.finished, f.target).spice, player(completed.delivered, f.target).spice);
assert.equal(player(completed.finished, f.target).reserves, player(completed.delivered, f.target).reserves);
}
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
void test('native CHOAM promise questions reject foreign and underlying replies while raw cash stays uncredited', () => {
for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced: true, kind }), game = asked(f);
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.shipment);
reject(game, f.asker, f.askStep.action);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.choamCharity, undefined);
const answered = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(answered, f.target, { type: 'truthAnswer', answer: 'yes' });
const pending = incomePending(answered, f);
assert.equal(player(pending.game, f.target).spice, 2); assert.equal(promise(pending.game, f)?.released, undefined);
}
});
void test('all four minimal policies preserve native CHOAM promise funding during actual Bidding', () => {
for (const variant of variants) {
const f = fixture(variant), pending = incomePending(bound(f), f), allowed = allowIncome(pending.game);
const game = advance(allowed, state => state.phase === 3 && !state.response && !state.phaseOpening &&
!state.decision && state.auction?.active === f.target);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
const before = structuredClone(view), action = botActions(view)[0]; assert.ok(action, difficulty);
assert.deepEqual(view, before);
const done = act(structuredClone(game), f.target, action);
assert.equal(promise(done, f)?.released, undefined);
assert.ok(player(done, f.target).spice >= (f.kind === 'shipment' ? 10 : 6));
assert.deepEqual(inventory(done), f.originalInventory);
}
}
});
@@ -0,0 +1,167 @@
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 {
createTruthtranceNativeFamilyPromisesFixture as fixture,
actTruthtranceNativeFamilyPromises as act, advanceTruthtranceNativeFamilyPromises as advance,
finishTruthtranceNativeFamilyPromises as finish, truthtranceNativeFamilyPromisesClean as clean,
truthtranceNativeFamilyPromisesPlayer as player, truthtranceNativeFamilyPromisesInventory as inventory,
truthtranceNativeFamilyPromisesPhysical as physical,
type TruthtranceNativeFamilyPromisesFixture as Fixture, type TruthtranceNativeFamilyPromisesStep as Step,
} from './fixture-truthtrance-native-family-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.game.expansions, ['ecaz']);
assert.deepEqual(f.game.players.map(p => p.faction), ['atreides', 'ecaz', 'moritani']);
assert.equal(f.lobby.advanced, f.advanced);
assert.deepEqual(f.start.action, f.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.leaderSkills && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(f.beforeStartingDeal.players.every(p => !p.hand.length));
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.deepEqual(f.ecazSetup, { actor: f.target, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } });
assert.deepEqual(f.moritaniSetup, { actor: f.moritani, action: { type: 'decision', territory: 'oh_gap', sector: 9 } });
assert.equal(player(f.game, f.target).spice, 12); assert.equal(player(f.game, f.target).reserves, 14);
assert.deepEqual(player(f.game, f.target).forces, { 'imperial_basin:10': 6 });
assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 10);
assert.deepEqual(player(f.game, f.moritani).forces, { 'oh_gap:9': 6 });
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2);
assert.ok(clean(f.game)); assert.deepEqual(f.game.ready, []);
assert.equal(f.game.shipmentPromises, undefined); assert.equal(f.game.movementPromises, undefined);
assert.equal(f.originalCards.length, 33); assert.deepEqual(inventory(f.game), f.originalCards);
assert.deepEqual(physical(f.game), f.physical); assert.equal(f.sourceActionCount, f.actions.length);
}
function asking(f: Fixture, actions: Step[] = []) {
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
const view = viewGame(game, f.asker);
assert.ok((f.kind === 'shipment' ? view.truthShipmentTargets : view.truthMovementTargets).includes(f.target));
return game;
}
function asked(f: Fixture, actions: Step[] = []) {
const game = act(asking(f, actions), f.asker, f.askStep.action, actions);
const own = viewGame(game, f.target), answers = f.kind === 'shipment' ? own.truthShipmentAnswers : own.truthMovementAnswers;
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
assert.deepEqual(physical(game), f.physical); return game;
}
function promises(game: Game, f: Fixture) {
return f.kind === 'shipment' ? game.shipmentPromises : game.movementPromises;
}
function bind(f: Fixture, answer: 'yes' | 'no' = 'yes', actions: Step[] = []) {
const game = finish(act(asked(f, actions), f.target, { type: 'truthAnswer', answer }, actions), actions);
assert.equal(game.phase, 2); assert.equal(game.turn, 1); assert.deepEqual(physical(game), f.physical);
const entries = promises(game, f); assert.equal(entries?.length, 1);
assert.equal(entries![0].answer, answer === 'yes'); assert.equal(entries![0].player, f.target);
assert.equal(entries![0].fulfilled, undefined); assert.equal(entries![0].released, undefined);
assert.equal(inventory(game).length, 33); assert.deepEqual(inventory(game), f.originalCards);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(viewGame(game, f.target).shipmentCompletion, null);
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
reject(game, f.asker, f.askStep.action); reject(game, f.asker, f.declarationStep.action);
return game;
}
function atMovement(game: Game, f: Fixture, actions: Step[] = []) {
const next = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
assert.equal(player(next, f.target).spice, 12); assert.equal(player(next, f.target).reserves, 14);
assert.equal(player(next, f.target).forces['imperial_basin:10'], 6);
assert.equal(player(next, f.target).shipped, false); assert.equal(player(next, f.target).moved, 0);
assert.equal(promises(next, f)![0].fulfilled, undefined); assert.equal(promises(next, f)![0].released, undefined);
assert.ok(actions.every(step => !['ship', 'move'].includes(step.action.type)), 'The native phase path installs no preparatory army');
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.decline === true),
'The real optional Ecaz placement is declined rather than charging the promised shipment');
assert.deepEqual(inventory(next), f.originalCards); return next;
}
function complete(game: Game, f: Fixture, action: Action = f.eventAction) {
const before = structuredClone(game), actions: Step[] = [];
game = finish(act(game, f.target, action, actions), actions);
assert.equal(promises(game, f)!.length, 1); assert.equal(promises(game, f)![0].fulfilled, true);
assert.equal(promises(game, f)![0].released, undefined); assert.equal(game.active, f.target); assert.equal(game.phase, 5);
if (f.kind === 'shipment') {
const amount = Number(action.amount), key = `${String(action.territory)}:${Number(action.sector)}`;
assert.ok(amount >= 5); assert.equal(action.territory, 'carthag');
assert.equal(player(game, f.target).forces[key], amount);
assert.equal(player(before, f.target).reserves - player(game, f.target).reserves, amount);
assert.equal(player(before, f.target).spice - player(game, f.target).spice, amount);
assert.equal(player(game, f.target).forces['imperial_basin:10'], 6);
assert.equal(player(game, f.target).shipped, true); assert.equal(player(game, f.target).moved, 0);
} else {
assert.equal(action.territory, 'arrakeen');
assert.equal(player(game, f.target).forces['arrakeen:10'], 6);
assert.equal(player(game, f.target).forces['imperial_basin:10'] ?? 0, 0);
assert.equal(player(game, f.target).reserves, 14); assert.equal(player(game, f.target).spice, 12);
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.asker).forces['arrakeen:10'], 10);
}
assert.deepEqual(player(game, f.moritani).forces, player(before, f.moritani).forces);
assert.equal(actions.filter(step => step.actor === f.target && ['ship', 'move'].includes(step.action.type)).length, 1);
reject(game, f.target, f.eventAction); assert.deepEqual(inventory(game), f.originalCards);
return game;
}
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} native E3 ${kind}`;
void test(`${label} promise binds at real Charity and fulfills only at its original physical event`, () => {
const f = fixture({ advanced, kind }); source(f);
const actions: Step[] = [], game = atMovement(bind(f), f, actions);
reject(game, f.target, f.endMovement);
const done = complete(game, f);
assert.equal(game.shipmentPromises?.[0]?.fulfilled ?? game.movementPromises?.[0]?.fulfilled, undefined,
'The original pre-event game remains unchanged');
assert.equal(promises(done, f)![0].fulfilled, true);
});
void test(`${label} No stays bound through native phase controls and fulfills by actually ending the unused turn`, () => {
const f = fixture({ advanced, kind }), actions: Step[] = [];
const game = atMovement(bind(f, 'no'), f, actions);
reject(game, f.target, f.eventAction);
const done = act(game, f.target, f.endMovement);
assert.equal(promises(done, f)![0].fulfilled, true); assert.equal(promises(done, f)![0].released, undefined);
assert.deepEqual(player(done, f.target).forces, { 'imperial_basin:10': 6 });
assert.equal(player(done, f.target).reserves, 14); assert.deepEqual(inventory(done), f.originalCards);
});
void test(`${label} rejects phony questions and early underlying actions without installing or spending their sources`, () => {
const f = fixture({ advanced, kind }), window = asking(f);
for (const question of [
{ ...f.question, target: 'unseated' }, { ...f.question, territory: 'invented_destination' },
{ ...f.question, minimum: 21 },
...(kind === 'movement' ? [{ ...f.question, futureIncome: 20 }] : []),
]) reject(window, f.asker, { type: 'truthAsk', question });
reject(window, f.target, { type: 'truthAsk', question: f.question });
reject(window, f.target, f.eventAction);
assert.ok(player(window, f.asker).hand.some(card => card.id === f.truth));
assert.equal(window.shipmentPromises, undefined); assert.equal(window.movementPromises, undefined);
const answer = act(window, f.asker, f.askStep.action);
reject(answer, f.asker, { type: 'truthAnswer', answer: 'yes' }); reject(answer, f.target, f.eventAction);
assert.deepEqual(physical(answer), f.physical); assert.deepEqual(inventory(answer), f.originalCards);
});
void test(`all four minimal profiles legally answer and complete ${label} through the original native controls`, () => {
const f = fixture({ advanced, kind }), answer = asked(f);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(answer, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
const before = structuredClone(own), selected = botActions(own)[0]; assert.ok(selected, difficulty);
assert.deepEqual(own, before); assert.deepEqual(selected, { type: 'truthAnswer', answer: 'yes' });
let game = finish(act(structuredClone(answer), f.target, selected));
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
const bidding = viewGame(game, f.target); bidding.players.find(p => p.id === f.target)!.bot = difficulty;
const pass = botActions(bidding)[0]; assert.deepEqual(pass, { type: 'passBid' });
game = act(game, f.target, pass);
const phases: Step[] = [];
game = atMovement(game, f, phases);
const moving = viewGame(game, f.target); moving.players.find(p => p.id === f.target)!.bot = difficulty;
const event = botActions(moving)[0]; assert.ok(event, difficulty); assert.equal(event.type, kind === 'shipment' ? 'ship' : 'move');
complete(game, f, event);
}
});
}
@@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createTruthtranceRichesePromisesFixture as fixture,
actTruthtranceRichesePromises as act, askTruthtranceRichesePromises as ask,
advanceTruthtranceRichesePromises as advance, finishTruthtranceRichesePromises as finish,
truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesPlayer as player,
truthtranceRichesePromisesInventory as inventory, truthtranceRichesePromisesPhysical as physical,
type TruthtranceRichesePromisesFixture as Fixture, type TruthtranceRichesePromisesStep as Step,
} from './fixture-truthtrance-richese-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.initial.advanced, false); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.rules.steps, f.advanced ? [{ actor: f.target, action: { type: 'rules', advanced: true } }] : []);
assert.deepEqual(f.game.expansions, ['choam']); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.ok(clean(f.game));
assert.equal(f.game.auction ?? null, null); assert.equal(f.game.richeseAuction ?? null, null);
assert.equal(f.game.richeseBidding ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 3).map(card => card.id), f.deal);
assert.equal(f.beforeStartingDeal.deck[3].id, f.ordinaryLot);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.equal(player(f.game, f.asker).hand[0].id, f.truthtrance);
assert.equal(player(f.game).spice, 5); assert.equal(player(f.game).reserves, 20); assert.equal(player(f.game).tanks, 0);
assert.deepEqual(player(f.game).forces, {}); assert.equal(player(f.game).shipped, false); assert.equal(player(f.game).moved, 0);
assert.deepEqual(player(f.game).noField, f.originalNoField); assert.equal(player(f.game).noField!.deployed, null);
assert.equal(player(f.game).hand.some(card => ['karama', 'hajr'].includes(card.effect ?? '')), false);
assert.equal(f.originalMainDeck.length, 35); assert.equal(f.originalCache.length, 10);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.action.type === 'stormDial'));
}
function bound(f: Fixture, game = structuredClone(f.game), question: TruthQuestion = f.question) {
const before = physical(game); game = ask(game, f, question);
const view = viewGame(game, f.target);
assert.ok((question.kind === 'shipment' ? view.truthShipmentAnswers : view.truthMovementAnswers)?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = advance(game, clean);
assert.deepEqual(physical(game), before); assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
return game;
}
function reachTurn(game: Game, actions: Step[] = []) {
return advance(game, state => state.phase === 5 && state.active === 'richese' && clean(state), actions);
}
function ship(game: Game, f: Fixture, amount: number, actions: Step[] = []) {
game = reachTurn(game, actions); const before = structuredClone(game);
const action: Action = { type: 'ship', territory: 'carthag', sector: 11, amount, elite: 0, allyPayment: 0 };
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
assert.equal(player(before).spice - player(game).spice, amount);
assert.equal(player(before).reserves - player(game).reserves, amount);
assert.equal(player(game).forces['carthag:11'], amount); assert.equal(player(game).shipped, true);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.shipmentPromises![0].released, undefined);
assert.deepEqual(player(game).noField, f.originalNoField); assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.target, action); return game;
}
function nativeBidding(actions: Step[], advanced: boolean) {
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.position === 'first'));
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === 'richese-stone-burner'));
assert.ok(actions.some(step => step.action.type === 'richeseBid'));
assert.ok(actions.some(step => step.action.type === 'passBid'));
if (advanced) assert.ok(actions.some(step => step.action.type === 'decision' && step.action.decline === true));
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`native ${band} Richese early shipment pays five current spice after actual cache and normal auctions`, () => {
const f = fixture({ advanced }); source(f); const actions: Step[] = [];
let game = bound(f);
assert.ok(viewGame(f.game, f.asker).truthShipmentTargets.includes(f.target));
assert.equal(game.shipmentPromises![0].fulfilled, undefined); reject(game, f.target, f.shipmentAction);
game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(player(game).spice, 5); assert.deepEqual(player(game).forces, {});
assert.deepEqual(player(game).hand.map(card => card.id), [f.targetCard]);
assert.ok(game.richeseRemoved?.some(card => card.id === f.cacheCard));
const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
assert.ok(completion.actions.some(action => action.type === 'ship' && action.amount === 5 && action.territory === 'carthag'));
reject(game, f.target, { type: 'endMovement' }); reject(game, f.target, { ...f.shipmentAction, amount: 4 });
game = ship(game, f, 5, actions); assert.equal(player(game).spice, 0); assert.equal(player(game).reserves, 15);
game = finish(game, actions); assert.equal(game.phase, 7); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
});
void test(`native ${band} Richese early No forbids five but permits the actual four-counter city shipment`, () => {
const f = fixture({ advanced }); let game = ask(structuredClone(f.game), f);
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
reject(game, f.target, f.shipmentAction); game = ship(game, f, 4);
assert.equal(player(game).spice, 1); assert.equal(game.shipmentPromises![0].answer, false);
});
void test(`native ${band} Richese current cash and physical stock cannot promise six while original Emperor can`, () => {
const f = fixture({ advanced }); source(f);
let game = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); assert.deepEqual(inventory(game), f.originalInventory);
const other = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.observer, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(other, f.observer).truthShipmentAnswers, ['yes', 'no']);
assert.equal(player(other, f.observer).spice, 10); assert.equal(player(other).spice, 5);
});
void test(`native ${band} Richese Silent own invoice reserves one before a truthful four-counter shipment`, () => {
const f = fixture({ advanced }), auction = { method: 'silent' as const }, actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, actions, auction);
const event = game.richeseAuction!.event, card = game.richeseAuction!.cardId;
game = act(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 }, actions);
assert.equal(player(game).spice, 5); assert.equal(game.richeseFunding?.[f.target].amount, 1);
const unpaid = structuredClone(game);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
reject(impossible, f.target, { type: 'truthAnswer', answer: 'yes' });
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions, auction);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, player(unpaid, f.observer).spice + 1);
assert.ok(player(game).hand.some(candidate => candidate.id === card)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
reject(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length, 1);
});
void test(`native ${band} Richese ordinary standing invoice pays once and leaves four for the accepted city shipment`, () => {
const f = fixture({ advanced }), actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) && !!state.auction &&
state.auction.active === f.target && state.auction.bid === 0, actions);
const lot = game.auction!.cards[game.auction!.index].id; assert.equal(lot, f.ordinaryLot);
game = act(game, f.target, f.normalBid, actions);
assert.equal(player(game).spice, 5); assert.equal(game.auction!.bidder, f.target); assert.equal(game.auction!.bid, 1);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, 11);
assert.ok(player(game).hand.some(card => card.id === lot)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
game = finish(game, actions); assert.equal(game.phase, 7); assert.equal(player(game, f.observer).spice, 11);
});
void test(`native ${band} Richese empty-board early movement earns a paid landing then the actual ground event`, () => {
const f = fixture({ advanced, kind: 'movement' }); source(f); const actions: Step[] = [];
let game = bound(f); assert.ok(viewGame(f.game, f.asker).truthMovementTargets.includes(f.target));
assert.deepEqual(player(game).forces, {}); assert.equal(player(game).reserves, 20);
reject(game, f.target, f.moveAction); game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(game.movementPromises![0].fulfilled, undefined); reject(game, f.target, { type: 'endMovement' });
const offered = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(offered);
assert.equal(offered.type, 'ship');
// The native witness may prefer an original No-Field. This case takes the
// independent ordinary physical route; profiles below keep their own guide.
const shipment = f.shipmentAction;
assert.equal(shipment.noField, undefined);
const before = structuredClone(game);
game = act(game, f.target, shipment, actions); game = advance(game, clean, actions);
assert.equal(player(game).shipped, true); assert.equal(player(game).moved, 0);
assert.ok(player(before).spice > player(game).spice); assert.ok(player(game).spice >= 0);
assert.equal(player(before).reserves - player(game).reserves, shipment.amount);
assert.equal(game.movementPromises![0].fulfilled, undefined, 'A reserve shipment is not the promised ground event');
const movement = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(movement);
assert.equal(movement.type, 'move'); assert.equal(movement.territory, 'carthag');
const moved = movement.forces && typeof movement.forces === 'object'
? Object.values(movement.forces as Record<string, number>).reduce((total, amount) => total + amount, 0)
: Number(movement.amount);
assert.ok(moved >= 1);
game = act(game, f.target, movement, actions); game = advance(game, clean, actions);
assert.equal(game.movementPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= 1); assert.deepEqual(player(game).noField, f.originalNoField);
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, movement);
game = finish(game, actions); assert.equal(game.phase, 7);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
});
}
void test('native Richese early No on movement permits skipping and rejects the actual later matching ground route', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, kind: 'movement' }); let game = ask(f.game, f);
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
reject(game, f.target, f.moveAction);
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('native Richese early questions retain actor, pending-answer and stale-action boundaries', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), game = ask(f.game, f);
reject(game, f.observer, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.action); reject(game, f.asker, f.askStep.action);
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' }); reject(done, f.asker, f.declarationStep.action);
assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
}
});
void test('all four minimal profiles use the original Richese early answer, owned auction controls and actual completion', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), asked = ask(f.game, f), promised = bound(f);
// Once Around skips its owner after every other seat passes. A separate
// actual Silent lot supplies R's event-bound zero submission; it creates no
// acquired cache card and is not the completion branch's auction prefix.
const silent = advance(promised, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, [], { method: 'silent' });
const expectedPass: Action = { type: 'richeseBid', event: silent.richeseAuction!.event, amount: 0, allyPayment: 0 };
const prefix: Step[] = [], turn = reachTurn(promised, prefix);
nativeBidding(prefix, advanced);
assert.equal(player(turn).spice, 5); assert.deepEqual(player(turn).hand.map(card => card.id), [f.targetCard]);
assert.ok(turn.richeseRemoved?.some(card => card.id === f.cacheCard));
assert.ok(prefix.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.keep === false));
for (const difficulty of DIFFICULTIES) {
const answering = viewGame(asked, f.target); answering.players.find(p => p.id === f.target)!.bot = difficulty;
const answer = botActions(answering)[0]; assert.ok(answer); assert.equal(answer.type, 'truthAnswer');
assert.doesNotThrow(() => act(asked, f.target, answer));
const bidding = viewGame(silent, f.target);
assert.deepEqual(kind === 'shipment' ? bidding.shipmentPromisePolicy : bidding.movementPromisePolicy, [expectedPass]);
bidding.players.find(p => p.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bidding), [expectedPass]);
const submitted = act(silent, f.target, expectedPass);
assert.equal(submitted.richeseAuction!.sealed[f.target], 0); assert.equal(player(submitted).spice, 5);
assert.deepEqual(physical(submitted), physical(silent)); assert.deepEqual(inventory(submitted), f.originalInventory);
let game = structuredClone(turn), count = 0;
const actions: Step[] = [];
while (!(kind === 'shipment' ? game.shipmentPromises![0].fulfilled : game.movementPromises![0].fulfilled) && count++ < 12) {
assert.equal(game.active, f.target); assert.ok(clean(game));
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action, `The ${difficulty} profile needs the actual ${kind} completion`);
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
}
assert.ok(count < 12); assert.equal(kind === 'shipment' ? game.shipmentPromises![0].released : game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= f.minimum);
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
if (kind === 'movement') assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
}
}
});