Enable early native-faction Truthtrance promises
This commit is contained in:
1 parent
8053e1fb26
commit
d5c5342d2d
22 files changed
+1371
-44
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user