Support earlier classic Basic movement promises

Allow current-round classic Basic questions before Movement, with owned Charity, ordinary auction payment and Revival preparation. Reopen readiness, retain combined shipment obligations, reset stale allowances and release forced storm impossibility. Current-phase guidance and minimal bots preserve the route; held Karama now prepares its actual Guild shipment rate.

Validation: affected rules/control tests, types/lint, production build and four gameplay/control/table-rendering programmes. Native early invoices, wider preparations/modules, full rules and deployed acceptance remain open. No saved-game, database, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 15:49:35 +11:00
1 parent 49586efd2f
commit f995b6f4cd
18 files changed
+708 -46

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import type { Action } from '../game/engine';
import {
createTruthtranceEarlyShipmentFixture, actTruthtranceEarlyShipment,
advanceTruthtranceEarlyShipment, nextTruthtranceEarlyShipmentStep,
truthtranceEarlyShipmentClean, truthtranceEarlyShipmentPlayer,
type TruthtranceEarlyShipmentStep,
} from './fixture-truthtrance-early-shipment';
import { truthMovementInventory } from './fixture-truth-movement';
export type TruthMovementEarlyStep = TruthtranceEarlyShipmentStep;
export const actTruthMovementEarly = actTruthtranceEarlyShipment;
export const advanceTruthMovementEarly = advanceTruthtranceEarlyShipment;
export const nextTruthMovementEarlyStep = nextTruthtranceEarlyShipmentStep;
export const truthMovementEarlyClean = truthtranceEarlyShipmentClean;
export const truthMovementEarlyPlayer = truthtranceEarlyShipmentPlayer;
export const truthMovementEarlyInventory = truthMovementInventory;
/** Genuine original Basic setup, before the selected early movement question.
* Phase0 uses only the real setup prefix and unopened ordinary Spice selection;
* phase2 retains the original Storm/Spice continuation through clean Charity.
* The caller owns the question, subsequent auction and later shipment/move. */
export function createTruthMovementEarlyFixture(options: { phase?: 0 | 2 } = {}) {
const base = createTruthtranceEarlyShipmentFixture(), phase = options.phase ?? 2;
const game = structuredClone(phase === 0 ? base.afterSetup : base.game);
const count = phase === 0 ? base.afterSetupActionCount : base.sourceActionCount;
const actions: TruthMovementEarlyStep[] = structuredClone(base.actions.slice(0, count));
const spiceOrder = phase === 0 ? base.spiceOrder.map(({ position, card }) => {
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
JSON.stringify(candidate) === JSON.stringify(card));
assert.ok(from >= position, 'Select the same original unopened ordinary Spice card');
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
return { position, from, card: structuredClone(selected) };
}) : structuredClone(base.spiceOrder);
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.equal(game.turn, 1); assert.equal(game.phase, phase);
assert.deepEqual(game.expansions, []); assert.ok(truthMovementEarlyClean(game));
const owner = truthMovementEarlyPlayer(game, base.target);
assert.equal(owner.spice, 10); assert.equal(owner.reserves, 20); assert.equal(owner.tanks, 0);
assert.deepEqual(owner.forces, {}); assert.equal(owner.shipped, false); assert.equal(owner.moved, 0);
assert.ok(truthMovementEarlyPlayer(game, base.asker).hand.some(card => card.id === base.card));
assert.ok(truthMovementEarlyPlayer(game, base.counterHolder).hand.some(card => card.id === base.karama));
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
assert.deepEqual(truthMovementInventory(game), base.originalTreachery);
const claim = { kind: 'movement' as const, target: base.target, territory: 'carthag', minimum: 8 };
return {
game, source: structuredClone(game), base, phase,
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
afterSetup: base.afterSetup, afterSetupActionCount: base.afterSetupActionCount,
beforeSpiceOrder: base.beforeSpiceOrder, spiceOrder, deal: base.deal, dealOrder: base.dealOrder,
originalCards: base.originalTreachery, originalTreachery: base.originalTreachery, originalSpice: base.originalSpice,
asker: base.asker, target: base.target, counterHolder: base.counterHolder, guild: base.guild,
truth: base.card, card: base.card, karama: base.karama, claim, question: claim,
actions, sourceActionCount: actions.length,
sourceCounts: { storm: base.afterSetupActionCount, charity: base.sourceActionCount },
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
declarationStep: { actor: base.asker, action: { type: 'card', card: base.card } } satisfies TruthMovementEarlyStep,
askStep: { actor: base.asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementEarlyStep,
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
payment: { type: 'decision', karama: false } satisfies Action,
purchaseCost: 2,
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 } satisfies Action,
shipmentCost: 8,
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
staging: [
`Original public Basic Atreides/Emperor/Harkonnen/Guild setup selects only the original five unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Atreides genuinely holds Truthtrance, Emperor Baliset, Harkonnen Karama/Shield and Guild Snooper. The phase${phase} source uses exactly its original ${actions.length}-action prefix: ${phase === 0 ? 'the Charity factory suffix is excluded, and the same still-unopened ordinary Spice position is selected before any Storm/Spice continuation' : 'actual Storm/Spice controls reach clean Charity before any question or Bidding'}. Emperor retains its printed ten spice, twenty reserves, zero board forces and no Tanks. The suggested future normal purchase2, city shipment8 and eight-force ground movement remain caller actions; no purchase, question, answer, dealt hand, wallet, army, phase clock, queue or receipt is installed. The movement predicate concerns the later ground event, not the preceding shipment.`,
],
};
}
export type TruthMovementEarlyFixture = ReturnType<typeof createTruthMovementEarlyFixture>;
+2 -2
View File
@@ -54,7 +54,7 @@ export function createTruthtranceEarlyShipmentFixture() {
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
assert.ok(truthtranceEarlyShipmentPlayer(game, target).hand.some(card => card.id === filler[0]!.id));
@@ -80,7 +80,7 @@ export function createTruthtranceEarlyShipmentFixture() {
const declarationStep: TruthtranceEarlyShipmentStep = { actor: asker,
action: { type: 'card', card: truth.id } };
const askStep: TruthtranceEarlyShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
return { game, lobby, setup, beforeStartingDeal, afterSetup, beforeSpiceOrder, spiceOrder,
return { game, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount, beforeSpiceOrder, spiceOrder,
deal, dealOrder, originalTreachery, originalSpice, asker, target, counterHolder, guild: 'guild',
card: truth.id, karama: karama.id, question, declarationStep, askStep,
territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
+14
View File
@@ -129,3 +129,17 @@ void test('Basic native selected families qualify, while optional modules and un
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
}
});
void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => {
for (const phase of [0, 1, 2, 3, 4]) {
const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = [];
assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
}
for (const phase of [-1, 6, 8]) {
const g = movementBoundary(); g.phase = phase;
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
}
});
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { baseDeck } from '../game/cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { createTruthMovementEarlyFixture, actTruthMovementEarly as act } from './fixture-truth-movement-early';
type Props = Parameters<typeof MovementPromises>[0];
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
function controls(node: unknown): Control[] {
if (Array.isArray(node)) return node.flatMap(controls);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
}
const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props));
/** Controlled personalized UI projections for label/dispatch boundaries only.
* They do not establish earned Charity, Tanks, bids, cards or early feasibility.
* The separate original-source case below binds a real early answer. */
function projectedView(phase: number, policy: Action[] | null = null, active: string | null = 'a'): GameView {
const source = createGame('EARLYMOVEMENTCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 1; game.phase = phase; game.active = active;
game.movementPromises = [{ player: 'p', asker: 'a', turn: 1,
territory: 'carthag', minimum: 8, answer: true }];
game.movementPromisePolicy = policy;
return game;
}
function assertCurrentControl(game: GameView, action: Action, label: string) {
const sent: Action[] = [], props = { game, busy: false, act: (value: Action) => { sent.push(value); } };
const html = markup(props);
assert.match(html, /Your Movement turn is still ahead/);
assert.match(html, /Complete any current preparation below before confirming readiness/);
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.match(html, /a shipment can prepare that move but does not fulfill it/);
assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/);
const available = controls(MovementPromises(props));
assert.equal(available.length, 1); assert.equal(available[0].children, label);
assert.equal(available[0].disabled, false);
available[0].onClick(); assert.equal(sent.length, 1); assert.equal(sent[0], action);
const busy = controls(MovementPromises({ ...props, busy: true }));
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
busy[0].onClick(); assert.equal(sent.length, 1, 'A stale busy handler cannot submit again');
assert.match(markup({ ...props, busy: true }), /Your ground-movement promises/);
return props;
}
void test('real early Charity binding exposes only current readiness and keeps its obligation readable after submission', () => {
const f = createTruthMovementEarlyFixture();
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
for (const player of game.players)
if (!game.truthtrance!.passed.includes(player.id)) game = act(game, player.id, { type: 'truthPass' });
game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
if (game.pendingTreacheryDiscard?.reaction) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const player of game.players) game = act(game, player.id, { type: 'semutaPass', event });
}
assert.equal(game.phase, 2);
const view = viewGame(game, f.target), next = view.movementPromisePolicy?.[0];
assert.deepEqual(next, { type: 'ready' });
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
const before = markup(props);
assert.match(before, /Your Movement turn is still ahead/);
assert.match(before, /Finish this preparation phase while preserving your promises/);
assert.equal(controls(MovementPromises(props)).length, 1);
assert.doesNotMatch(before, />Ship \d|>Move \d|>Finish movement while honoring No/);
controls(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(sent.length, 0);
controls(MovementPromises(props))[0].onClick(); assert.deepEqual(sent, [next]);
game = act(game, f.target, sent[0]); assert.ok(game.ready.includes(f.target));
const waiting = { ...props, game: viewGame(game, f.target) };
assert.equal(controls(MovementPromises(waiting)).length, 0);
const after = markup(waiting);
assert.match(after, /Your Movement turn is still ahead/);
assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.match(after, /needed forces, spice and cards available/);
});
void test('projected Charity and Revival controls forward only their current preparation with busy protection', () => {
const charity: Action = { type: 'charity' };
assertCurrentControl(projectedView(2, [charity], null), charity, 'Claim Charity for your later movement');
const revival: Action = { type: 'revive', amount: 3, elite: 1 };
assertCurrentControl(projectedView(4, [revival], null), revival, 'Revive 3 forces for your later movement');
});
void test('projected Charity and Revival readiness remains a current control while later movement stays deferred', () => {
for (const phase of [2, 4]) {
const ready: Action = { type: 'ready' };
assertCurrentControl(projectedView(phase, [ready], null), ready,
'Finish this preparation phase while preserving your promises');
}
});
void test('the current bid-pass control preserves its exact action without offering a future shipment', () => {
const pass: Action = { type: 'passBid' };
assertCurrentControl(projectedView(3, [pass], 'p'), pass, 'Pass this bid and preserve later movement');
});
void test('current auction guidance distinguishes original cash and physical Karama payment decisions', () => {
const cash: Action = { type: 'decision', karama: false }, cashView = projectedView(3, [cash], 'p');
cashView.decision = { kind: 'auctionPayment', player: cashView.me };
assertCurrentControl(cashView, cash, 'Pay the current auction price');
const card = baseDeck().find(card => card.effect === 'karama'); assert.ok(card);
const payment: Action = { type: 'decision', karama: true, card: card.id };
const cardView = projectedView(3, [payment], 'p');
cardView.decision = { kind: 'auctionPayment', player: cardView.me };
cardView.players.find(player => player.id === cardView.me)!.hand = [card];
assertCurrentControl(cardView, payment, `Use ${card.name} for the current auction payment`);
});
void test('early notices persist without a current control and cannot turn future movement policy into an early action', () => {
const future: Action[] = [
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 },
{ type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 },
{ type: 'endMovement' },
{ type: 'card', card: 'future-hajr' },
];
for (const phase of [0, 1, 2, 3, 4]) for (const policy of [null, ...future.map(action => [action])]) {
const props = { game: projectedView(phase, policy), busy: false,
act() { assert.fail('An early notice cannot execute a later shipment or move'); } };
assert.equal(controls(MovementPromises(props)).length, 0);
const html = markup(props);
assert.match(html, /Your Movement turn is still ahead/);
assert.match(html, /movement controls appear on your turn/);
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.doesNotMatch(html, /<button/);
}
});
void test('an out-of-phase preparation or foreign auction payment never becomes an owned early control', () => {
for (const [phase, action] of [[2, { type: 'revive', amount: 1 }], [4, { type: 'charity' }],
[3, { type: 'ready' }]] satisfies [number, Action][]) {
const props = { game: projectedView(phase, [action]), busy: false, act() { assert.fail('Wrong preparation phase'); } };
assert.equal(controls(MovementPromises(props)).length, 0);
}
const foreign = projectedView(3, [{ type: 'decision', karama: false }]);
foreign.decision = { kind: 'auctionPayment', player: 'a' };
assert.equal(controls(MovementPromises({ game: foreign, busy: false,
act() { assert.fail('A foreign invoice cannot become an owned control'); } })).length, 0);
});
+234
View File
@@ -0,0 +1,234 @@
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 { createTruthMovementEarlyFixture as fixture, actTruthMovementEarly as act,
advanceTruthMovementEarly as advance, truthMovementEarlyClean as clean,
truthMovementEarlyPlayer as player, truthMovementEarlyInventory as inventory } from './fixture-truth-movement-early';
function asked(options: { phase?: 0 | 1 | 2 | 3 | 4; minimum?: number; territory?: string; control?: (g: Game) => void } = {}) {
const f = fixture({ phase: options.phase === 0 || options.phase === 1 ? 0 : 2 });
let game = options.phase !== undefined && ![0, 2].includes(options.phase)
? advance(f.game, g => g.phase === options.phase && clean(g)) : f.game;
options.control?.(game);
game = act(game, f.asker, f.declarationStep.action);
game = advance(game, g => g.truthtrance?.stage === 'ask');
assert.ok(viewGame(game, f.asker).truthMovementTargets.includes(f.target));
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim,
territory: options.territory ?? f.claim.territory, minimum: options.minimum ?? 8 } });
return { f, game };
}
function bound(options: Parameters<typeof asked>[0] = {}, answer = 'yes') {
const { f, game } = asked(options);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes(answer as 'yes' | 'no'));
return { f, game: act(game, f.target, { type: 'truthAnswer', answer }) };
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function giveKarama(game: Game, count = 1) {
for (let n = 0; n < count; n++) {
const holder = game.players.find(p => p.id !== 'emperor' && p.hand.some(c => c.effect === 'karama'));
const cards = holder?.hand ?? game.deck, index = cards.findIndex(c => c.effect === 'karama');
assert.ok(index >= 0); player(game).hand.push(cards.splice(index, 1)[0]);
}
}
function allies(game: Game) { player(game).ally = 'harkonnen'; player(game, 'harkonnen').ally = 'emperor'; }
void test('an original Charity movement answer retains its physical source without granting early shipment or movement', () => {
const { f, game } = bound();
assert.equal(game.phase, 2); assert.equal(player(game).spice, 10);
assert.equal(player(game).reserves, 20); assert.deepEqual(player(game).forces, {});
assert.deepEqual(inventory(game), f.originalCards);
assert.equal(game.movementPromises![0].released, undefined);
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
});
void test('the actual winning invoice reserves spice for shipment and only the later ground event fulfills Yes', () => {
const { f, game: initial } = bound();
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
const purchased = game.auction!.cards[game.auction!.index].id;
reject(game, f.target, { type: 'bid', amount: 3 });
game = act(game, f.target, f.bid);
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(player(game).spice, 8); assert.ok(player(game).hand.some(c => c.id === purchased));
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
game = advance(act(game, f.target, f.shipment), clean);
assert.equal(player(game).spice, 0); assert.equal(player(game).forces['arrakeen:10'], 8);
assert.equal(game.movementPromises![0].fulfilled, undefined);
reject(game, f.target, f.endMovement);
game = advance(act(game, f.target, f.move), clean);
assert.equal(player(game).forces['carthag:11'], 8); assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, f.endMovement); assert.notEqual(game.active, f.target);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('original Storm, Spice, Bidding and Revival questions retain the same current-round future movement', () => {
for (const phase of [0, 1, 3, 4] as const) {
const { f, game: initial } = bound({ phase });
assert.equal(initial.phase, phase);
assert.ok(!viewGame(initial, f.target).movementPromisePolicy?.some(a => ['ship', 'move', 'endMovement'].includes(a.type)));
let game = advance(initial, g => g.phase === 5 && clean(g) && g.active === f.target);
assert.equal(game.movementPromises![0].released, undefined);
game = advance(act(game, f.target, f.shipment), clean);
assert.equal(game.movementPromises![0].fulfilled, undefined);
game = advance(act(game, f.target, f.move), clean);
assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
});
const needPreparation = (g: Game) => {
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
g.ready = [p.id];
};
void test('controlled Charity and Revival needs reopen readiness and cannot be skipped or spent on an excess revival', () => {
const { f, game: initial } = bound({ control: needPreparation, minimum: 1 });
let game = initial;
assert.equal(game.ready.includes(f.target), false);
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'charity' }]);
reject(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'charity' });
game = advance(game, g => g.phase === 4 && clean(g));
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
reject(game, f.target, { type: 'ready' }); reject(game, f.target, { type: 'revive', amount: 2 });
game = act(game, f.target, { type: 'revive', amount: 1 });
assert.equal(player(game).reserves, 1); assert.equal(game.movementPromises![0].released, undefined);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
for (let count = 0; !game.movementPromises![0].fulfilled && count < 4; count++) {
const action = viewGame(game, f.target).movementPromisePolicy![0]; assert.ok(action);
game = advance(act(game, f.target, action), clean);
}
assert.equal(game.movementPromises![0].fulfilled, true);
});
void test('a controlled already-Ready Revival respondent can answer and use only its current remaining preparation', () => {
const { f, game: initial } = bound({ phase: 4, minimum: 1, control: g => {
needPreparation(g); player(g).spice = 1;
} });
assert.equal(initial.ready.includes(f.target), false);
assert.deepEqual(viewGame(initial, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
reject(initial, f.target, { type: 'ready' });
const game = act(initial, f.target, { type: 'revive', amount: 1 });
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
});
void test('controlled physical Karamas price the actual invoice separately from later shipment', () => {
for (const count of [1, 2]) {
const { f, game: initial } = bound({ control: g => { player(g).spice = 4; giveKarama(g, count); } });
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
if (count === 1) { reject(game, f.target, { type: 'bid', amount: 1 }); continue; }
game = act(game, f.target, { type: 'bid', amount: 1 });
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
reject(game, f.target, { type: 'decision', karama: false });
const payment = viewGame(game, f.target).movementPromisePolicy![0];
assert.equal(payment.karama, true);
game = act(game, f.target, payment);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
const price = viewGame(game, f.target).movementPromisePolicy![0];
assert.equal(price.type, 'card'); assert.notEqual(price.card, payment.card);
assert.equal(player(game).hand.filter(c => c.effect === 'karama').length, 1);
game = advance(act(game, f.target, price), clean);
assert.equal(game.karamaShipping?.player, f.target);
game = advance(act(game, f.target, f.shipment), clean);
assert.equal(player(game).spice, 0); assert.equal(game.karamaShipping, null);
assert.equal(game.movementPromises![0].fulfilled, undefined);
game = advance(act(game, f.target, f.move), clean);
assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('controlled ally escrow waits for actual income or cancellation before releasing a movement shortfall', () => {
for (const canceled of [false, true]) {
const { f, game: initial } = bound({ control: allies, territory: 'imperial_basin' });
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
assert.equal(game.movementPromises![0].released, undefined);
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === 'harkonnen');
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
assert.equal(game.movementPromises![0].released, undefined);
game = advance(game, g => g.response?.kind === 'emperorIncome');
assert.equal(game.movementPromises![0].released, undefined);
if (canceled) game = act(game, 'harkonnen', { type: 'card',
card: player(game, 'harkonnen').hand.find(c => c.effect === 'karama')!.id, mode: 'cancel' });
game = advance(game, g => g.phase === 4 && clean(g));
assert.equal(player(game).spice, canceled ? 7 : 10);
assert.equal(game.movementPromises![0].released, canceled ? true : undefined);
}
});
void test('controlled all-No answers leave native Karama overbid payment available to every existing profile', () => {
const { f, game: initial } = bound({ control: g => giveKarama(g) }, 'no');
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
game = act(game, f.target, { type: 'bid', amount: 11 });
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.equal(action.karama, true);
assert.doesNotThrow(() => applyAction(game, f.target, action));
}
});
void test('a controlled last Storm Ready resolves forced losses instead of rejecting its owner or preserving a false route', () => {
const { f, game: initial } = bound({ phase: 0, territory: 'arrakeen', control: g => {
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 12; p.forces = { 'oh_gap:11': 8 };
g.storm = 9;
} });
let game = initial;
for (const [index, actor] of game.stormDialers.entries()) game = act(game, actor, { type: 'stormDial', amount: index === 0 ? 2 : 0 });
assert.equal(game.stormPending, 2);
for (const p of game.players) if (p.id !== f.target) game = act(game, p.id, { type: 'ready' });
assert.equal(game.movementPromises![0].released, undefined);
game = act(game, f.target, { type: 'ready' });
assert.equal(player(game).forces['oh_gap:11'] ?? 0, 0); assert.equal(player(game).tanks, 20);
assert.equal(game.movementPromises![0].released, true);
});
function secondQuestion(game: Game, question: Record<string, unknown>): Game {
// Controlled acquisition of another conserved original Truthtrance, without
// staging an answer, future card, board, balance or movement allowance.
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0]; player(game, 'atreides').hand.push(card);
game = act(game, 'atreides', { type: 'card', card: card.id });
game = advance(game, g => g.truthtrance?.stage === 'ask');
return act(game, 'atreides', { type: 'truthAsk', question });
}
void test('controlled prior Ornithopter credit expires at the real movement boundary and cannot supply a second promised move', () => {
const { f, game: initial } = bound({ phase: 4, minimum: 2, control: g => {
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 18;
p.forces = { 'arrakeen:10': 2 }; p.ornithopterExtraMoves = 1;
} });
let game = secondQuestion(initial, { kind: 'movement', target: f.target, territory: 'arrakeen', minimum: 2 });
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
assert.equal(player(game).ornithopterExtraMoves, undefined);
game = advance(act(game, f.target, { ...f.move, amount: 2 }), clean);
game = act(game, f.target, f.endMovement);
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
});
void test('a controlled second original question keeps an early shipment Yes in the combined movement witness', () => {
const f = fixture();
let game = act(f.game, f.asker, f.declarationStep.action);
game = advance(game, g => g.truthtrance?.stage === 'ask');
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim, kind: 'shipment' } });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = secondQuestion(game, f.claim);
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('no'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
assert.equal(game.shipmentPromises![0].released, undefined);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
const policy = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(policy.type, 'ship');
assert.equal(policy.territory, 'carthag');
game = advance(act(game, f.target, policy), clean);
assert.equal(game.shipmentPromises![0].fulfilled, true);
game = act(game, f.target, f.endMovement);
assert.equal(game.movementPromises![0].fulfilled, true);
});