Support classic Advanced movement Truthtrance promises

This commit is contained in:
Quintine committed 2026-10-25 18:38:02 +11:00
1 parent 0d8481e821
commit 2ae2b984c0
15 files changed
+634 -23

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { distance, location, TERRITORIES } from '../game/board';
import { botGroundMoveAllowed } from '../game/bot-mobility';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer };
export type TruthMovementAdvancedStep = TruthMovementStep;
export type TruthMovementAdvancedTransition = { before: Game; steps: TruthMovementStep[]; after: Game };
export const actTruthMovementAdvanced = actTruthMovement;
export const truthMovementAdvancedClean = truthMovementClean;
export const truthMovementAdvancedInventory = truthMovementInventory;
export const truthMovementAdvancedPlayer = (game: Game, actor = game.players[0].id) => truthMovementPlayer(game, actor);
/** Native original setup and phase controls only. Truthtrance and the shipped
* respondent's movement remain caller-owned; no promise is silently closed. */
export function nextTruthMovementAdvancedNativeStep(game: Game): TruthMovementStep | null {
if (game.truthtrance) return null;
const target = game.players[0].id;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard &&
game.status === 'setup' && game.setupStage === 'forces' &&
truthMovementPlayer(game, target).faction === 'fremen' && truthMovementPlayer(game, target).reserves === 20)
return { actor: target, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
elitePlacements: { 'sietch_tabr:14': 2 } } };
if (game.phase === 5 && game.active === target && truthMovementPlayer(game, target).shipped && truthMovementClean(game)) return null;
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthMovementAdvanced(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Advanced movement source ended before its requested control');
const next = nextTruthMovementAdvancedNativeStep(game);
assert.ok(next, `Supply the original Truthtrance or ground movement at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The original Advanced Truthtrance movement source did not reach its finite boundary');
}
export { advanceTruthMovementAdvanced as advanceTruthMovement };
/** Public rules selection and original Advanced preview start. Original Hajr
* and Truthtrance are actually dealt; all later clocks/counters/payment arise
* from native controls. The optional Fremen route earns a real auction Karama. */
export function createTruthMovementAdvancedFixture(options: {
hajr?: boolean; targetFaction?: 'atreides' | 'fremen';
} = {}) {
const targetFaction = options.targetFaction ?? 'atreides', target = targetFaction, asker = 'emperor';
const withHajr = options.hajr ?? true;
let game = createGame('TRUTHMOVEMENTADVANCED', newPlayer(target, targetFaction === 'atreides' ? 'Atreides' : 'Fremen', targetFaction), false);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
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 = advanceTruthMovementAdvanced(game, until, actions); };
const transition = (run: () => void): TruthMovementAdvancedTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
assert.deepEqual(game.expansions, []);
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 originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
const targetCard = game.deck.find(card => withHajr ? card.effect === 'hajr' : card.kind === 'projectile');
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
const karama = targetFaction === 'fremen' ? game.deck.find(card => card.effect === 'karama') : null;
assert.ok(targetCard && truthtrance); assert.ok(targetFaction !== 'fremen' || karama);
const selected = [targetCard, truthtrance, ...(karama ? [karama] : [])];
const deal = selected.slice(0, 2).map(card => card.id);
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
let fremenSetup: TruthMovementAdvancedTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextTruthMovementAdvancedNativeStep(game); assert.ok(next);
const before = next.action.type === 'fremenSetup' ? structuredClone(game) : null;
act(next.actor, next.action);
if (before) fremenSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
if (targetFaction === 'atreides') assert.deepEqual(truthMovementPlayer(game, target).forces, { 'arrakeen:10': 10 });
else {
assert.ok(fremenSetup); assert.deepEqual(truthMovementPlayer(game, target).forces, { 'sietch_tabr:14': 10 });
assert.equal(truthMovementPlayer(game, target).elites?.forces['sietch_tabr:14'], 2);
assert.equal(truthMovementPlayer(game, target).elites?.reserves, 1);
}
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
elites: structuredClone(player.elites),
}]));
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) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let karamaAcquisition: TruthMovementAdvancedTransition | null = null;
if (karama) {
reach(state => state.phase === 3 && truthMovementClean(state) && state.auction?.active === asker &&
state.auction.cards[state.auction.index]?.id === karama.id);
karamaAcquisition = transition(() => {
act(asker, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => truthMovementClean(state) && truthMovementPlayer(state, asker).hand.some(card => card.id === karama.id));
});
}
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
const beforeShipment = structuredClone(game);
assert.equal(truthMovementPlayer(game, target).shipped, false);
const shipmentStep: TruthMovementStep = { actor: target, action: {
type: 'ship', amount: 1, territory: 'polar_sink', sector: 0,
elite: targetFaction === 'fremen' ? 1 : 0, allyPayment: 0,
} };
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
const player = truthMovementPlayer(game, target), shipmentCost = truthMovementPlayer(beforeShipment, target).spice - player.spice;
assert.equal(game.phase, 5); assert.equal(game.turn, 1); assert.equal(game.active, target);
assert.equal(player.shipped, true); assert.equal(player.moved, 0); assert.equal(player.forces['polar_sink:0'], 1);
assert.equal(player.reserves, 9); assert.equal(shipmentCost, targetFaction === 'fremen' ? 0 : 2);
if (targetFaction === 'atreides') assert.equal(player.forces['arrakeen:10'], 10);
else {
assert.equal(player.elites?.forces['polar_sink:0'], 1); assert.equal(player.elites?.forces['sietch_tabr:14'], 2);
assert.equal(player.elites?.reserves, 0); assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === karama!.id));
}
assert.ok(player.hand.some(card => card.id === targetCard.id));
assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
assert.deepEqual(truthMovementInventory(game), originalCards);
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
const matchingMove: Action = targetFaction === 'atreides'
? { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 6 }
: { type: 'move', from: 'polar_sink:0', territory: 'imperial_basin', sector: 9, amount: 1, elite: 1 };
const questionAction: Action = { type: 'truthAsk', question: { kind: 'movement', target,
territory: targetFaction === 'atreides' ? 'carthag' : 'imperial_basin', minimum: targetFaction === 'atreides' ? 6 : 1 } };
let twoStepMove: Action | null = null, twoStepDestination: string | null = null;
if (targetFaction === 'fremen') {
const view = viewGame(game, target), own = view.players.find(candidate => candidate.id === target)!;
const destination = TERRITORIES.flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
.find(to => to.sector !== game.storm && distance('polar_sink:0', location(to.territory, to.sector)) === 2 &&
botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 1));
assert.ok(destination, 'The original Fedaykin group has a legal native two-step movement');
twoStepDestination = destination.territory;
twoStepMove = { type: 'move', from: 'polar_sink:0', ...destination, amount: 1, elite: 1 };
}
return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup,
fremenSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost,
targetFaction, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
hajr: withHajr ? targetCard.id : null, targetCard: targetCard.id, karama: karama?.id ?? null, karamaAcquisition,
truthStep, declaration: truthStep.action, questionAction, matchingMove, twoStepMove, twoStepDestination,
nonmatchingMove: targetFaction === 'atreides'
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 4 } satisfies Action : null,
hajrAction: withHajr ? { type: 'card', card: targetCard.id } satisfies Action : null,
typedGroup: targetFaction === 'fremen' ? { source: 'polar_sink:0', normal: 0, elite: 1 } : null,
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalSpice, startingPositions,
actions, steps: actions, sourceActionCount: actions.length,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
staging: [
'Actual public two-seat classic lobby starts Basic, uses host rules advanced:true, readies both seats and submits exact start advancedPreview:true. No played Basic state is converted and no development initializer is used.',
'Select only original scalar lotteries, unopened starting-deal positions and two original unopened ordinary Spice positions. The target really receives Hajr (or the explicitly requested ordinary projectile) and Emperor really receives Truthtrance from the native base33 deal.',
targetFaction === 'atreides'
? 'Actual Atreides private Traitor setup retains its original ten Arrakeen counters and ten reserves. Native safe Storm, both Advanced Spice blows, Charity, Bidding and Revival precede an actual two-spice one-force Polar Sink shipment; the original ten Arrakeen forces and held Hajr remain.'
: 'Actual Fremen setup places ten Tabr14 counters including two original Fedaykin, retaining one original reserve Fedaykin. Emperor actually wins the original next Karama auction lot with a one-spice invoice through native bidding/payment/income. Original Storm/forecast responses, both Spice blows, Charity and Revival precede a real free Polar Sink shipment of the last reserve Fedaykin.',
'Stop at clean first Shipment and Movement with the target active, actual shipment used and ordinary ground movement unused. Source, beforeShipment, original paid/dealt cards and typed counters are real chronology snapshots. No hand, force, wallet, phase, allowance, movement promise, pending response or receipt is installed.',
'Truthtrance declaration/question, Hajr play and returned original ground movements remain caller-owned. This source establishes native Advanced provenance only; it does not certify the expanded forecast, every faction/module or later-round movement semantics.',
] };
}
export type TruthMovementAdvancedFixture = ReturnType<typeof createTruthMovementAdvancedFixture>;
export function createFremenTruthMovementAdvancedFixture(options: { hajr?: boolean } = {}) {
return createTruthMovementAdvancedFixture({ ...options, targetFaction: 'fremen' });
}
/** Submit a real declaration, priority passes and one structured question. */
export function askTruthMovementAdvanced(game: Game, asker: string, card: string,
target: string, minimum = 6, territory = 'carthag') {
game = actTruthMovement(game, asker, { type: 'card', card });
for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id))
game = actTruthMovement(game, player.id, { type: 'truthPass' });
return actTruthMovement(game, asker,
{ type: 'truthAsk', question: { kind: 'movement', target, territory, minimum } });
}
+15 -1
View File
@@ -116,7 +116,7 @@ void test('movement question profile permits an unspent shipment but rejects an
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), false);
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
});
void test('Basic native selected families qualify, while optional modules and undeclared native families do not', () => {
const native = movementBoundary(); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
@@ -143,3 +143,17 @@ void test('earlier current-round movement question profiles include declared Bas
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
}
});
void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => {
const advanced = movementBoundary(); advanced.advanced = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
for (const field of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
}
const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
advanced.players[1].shipped = true;
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
});
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Truthtrance } from '../components/truthtrance';
import { MovementPromises } from '../components/movement-promises';
import { viewGame, type Action } from '../game/engine';
import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced,
actTruthMovement as act } from './fixture-truth-movement-advanced';
function callbacks(node: unknown): (() => void)[] {
if (Array.isArray(node)) return node.flatMap(callbacks);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
}
void test('genuine Advanced movement answers and persistent guidance connect to a real physical move', () => {
const f = createTruthMovementAdvancedFixture();
let g = askTruthMovementAdvanced(f.game, f.asker, f.truthtrance, f.target);
const answers = renderToStaticMarkup(createElement(Truthtrance,
{ game: viewGame(g, f.target), busy: false, act() {} }));
assert.match(answers, />Answer Yes<\/button>/); assert.match(answers, />Answer No<\/button>/);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const view = viewGame(g, f.target), actions: Action[] = [];
const props = { game: view, busy: false, act: (action: Action) => { actions.push(action); } };
const html = renderToStaticMarkup(createElement(MovementPromises, props));
assert.match(html, /Your ground-movement promises/); assert.match(html, /Move 6 forces to Carthag/);
callbacks(MovementPromises(props))[0]();
assert.deepEqual(actions, view.movementPromisePolicy);
g = act(g, f.target, actions[0]); assert.equal(g.movementPromises![0].fulfilled, true);
callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1);
assert.equal(MovementPromises({ ...props, game: viewGame(g, f.asker) }), null);
});
@@ -0,0 +1,214 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { viewGame, type Game } from '../game/engine';
import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced as askAdvancedMovement } from './fixture-truth-movement-advanced';
import { actTruthMovement as act, advanceTruthMovement as advance,
truthMovementClean as clean, truthMovementPlayer as player,
truthMovementInventory as inventory } from './fixture-truth-movement';
import { createAdvisorMatchingMoveFixture } from './fixture-advisor-matching-move';
void test('genuine Advanced Yes binds physical movement and preserves immediate Hajr timing', () => {
const f = createTruthMovementAdvancedFixture();
assert.throws(() => act(f.game, f.target, { type: 'card', card: f.hajr! }),
'Advanced Hajr cannot prepare the initial move');
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.throws(() => act(g, f.target, { type: 'endMovement' }));
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
territory: 'imperial_basin', sector: 9 });
g = advance(g, clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
const next = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.deepEqual(next, { type: 'card', card: f.hajr });
g = advance(act(g, f.target, next!), clean);
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
assert.equal(g.movementPromises![0].fulfilled, true);
g = act(advance(g, clean), f.target, { type: 'endMovement' });
assert.equal(player(g, f.target).forces['carthag:11'], 6);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('Advanced No remains binding through a genuine later Hajr movement', () => {
const f = createTruthMovementAdvancedFixture();
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
g = act(g, f.target, { type: 'truthAnswer', answer: 'no' });
g = advance(act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
territory: 'imperial_basin', sector: 9 }), clean);
g = advance(act(g, f.target, { type: 'card', card: f.hajr! }), clean);
const before = structuredClone(g);
assert.throws(() => act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6,
territory: 'carthag', sector: 11 }));
assert.deepEqual(g, before);
g = act(g, f.target, { type: 'endMovement' });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('an Advanced unused shipment prepares a larger ground group without itself fulfilling Yes', () => {
const f = createTruthMovementAdvancedFixture();
let g = askAdvancedMovement(f.beforeShipment, f.asker, f.truthtrance, f.target, 11);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(shipment?.type, 'ship');
g = advance(act(g, f.target, shipment!), clean);
assert.ok(!g.movementPromises![0].fulfilled);
const movement = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(movement?.type, 'move');
g = act(g, f.target, movement!);
assert.equal(g.movementPromises![0].fulfilled, true);
});
function fremenRoute(g: Game, actor: string) {
const owner = player(g, actor);
const from = Object.keys(owner.forces).find(key => owner.forces[key] >= 3 && (owner.elites?.forces[key] ?? 0) >= 2);
assert.ok(from);
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
t.sectors.filter(sector => sector !== g.storm).map(sector => ({ territory: t.id, sector })))
.find(to => gameDistance(g, from, `${to.territory}:${to.sector}`,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm) === 2);
assert.ok(destination);
return { type: 'move', forces: { [from]: 3 }, eliteForces: { [from]: 2 }, ...destination };
}
for (const cancel of [false, true]) void test(`native Advanced typed Fremen movement ${cancel ? 'prevention releases an impossible Yes' : 'waits for allowance before fulfillment'}`, () => {
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
const move = fremenRoute(f.game, f.target);
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 3, move.territory);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const before = structuredClone(player(g, f.target));
g = act(g, f.target, move);
assert.equal(g.response?.kind, 'fremenMovement');
assert.equal(g.movementPromises![0].fulfilled, undefined);
assert.equal(g.movementPromises![0].released, undefined);
assert.deepEqual(player(g, f.target).forces, before.forces);
if (cancel) {
assert.ok(f.karama);
g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(g.movementPromises![0].released, true);
assert.deepEqual(player(g, f.target).forces, before.forces);
assert.equal(player(g, f.target).moved, 0);
} else {
g = advance(g, clean);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).elites!.forces[`${move.territory}:${move.sector}`], 2);
assert.equal(player(g, f.target).forces[`${move.territory}:${move.sector}`], 3);
}
assert.deepEqual(inventory(g), f.originalCards);
});
void test('elite combat strength cannot substitute for eleven physical forces in one movement', () => {
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
const move = fremenRoute(f.game, f.target);
const g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 11, move.territory);
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
});
for (const cancel of [false, true]) void test(`controlled original Truthtrance plus native BG matching ${cancel ? 'prevention retains a legal replacement' : 'allowance fulfills only at transfer'}`, () => {
const f = createAdvisorMatchingMoveFixture();
// Only this card acquisition is controlled; setup, original advisors/fighter,
// paid shipment, Karama and matching declaration come from the native source.
const source = structuredClone(f.game), at = source.deck.findIndex(c => c.effect === 'truthtrance');
assert.ok(at >= 0); const truth = source.deck.splice(at, 1)[0];
player(source, f.emperor).hand.push(truth);
let g = askAdvancedMovement(source, f.emperor, truth.id, f.bg, 1, f.destination);
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const forces = structuredClone(player(g, f.bg).forces);
g = act(g, f.bg, f.action);
assert.ok(g.response?.advisorMatchingSource);
assert.deepEqual(player(g, f.bg).forces, forces);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.bg, f.ordinaryAction);
} else g = advance(g, clean);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalInventory);
});
function controlledTruth(game: Game, recipient: string) {
const at = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; player(game, recipient).hand.push(card); return card.id;
}
const routeDistance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
for (const cancel of [false, true]) void test(`native pre-first-shipment advisor preparation ${cancel ? 'prevention releases the blocked route' : 'prepares the promised city-range move'}`, () => {
const f = createAdvisorMatchingMoveFixture();
let g = act(f.beforeShipment, f.bg, { type: 'bribe', target: f.emperor,
amount: player(f.beforeShipment, f.bg).spice });
assert.equal(g.advisorPreparation?.firstShipment, null, 'A real bribe does not close preparation');
const truth = controlledTruth(g, f.emperor);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const preparation = viewGame(g, f.bg).movementPromisePolicy?.[0];
assert.deepEqual(preparation, { type: 'prepareAdvisors', territory: 'arrakeen' });
g = act(g, f.bg, preparation!);
assert.ok(g.response?.advisorPreparationSource);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(g.movementPromises![0].released, true);
} else {
g = advance(g, clean);
const movement = viewGame(g, f.bg).movementPromisePolicy?.[0]; assert.equal(movement?.type, 'move');
g = act(g, f.bg, movement!); assert.equal(g.movementPromises![0].fulfilled, true);
}
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('controlled advisor arrival flip supplies city range for the subsequent original Hajr', () => {
const f = createAdvisorMatchingMoveFixture();
let g = structuredClone(f.game);
const bg = player(g, f.bg), emperor = player(g, f.emperor);
// Controlled stance, two real reserve counters and original card custody
// isolate this two-move search boundary; not an unstaged native source.
bg.advisors!.imperial_basin = {};
emperor.reserves -= 2; emperor.forces['carthag:11'] = 1; emperor.forces['imperial_basin:9'] = 1;
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
const hajr = g.deck.splice(at, 1)[0]; bg.hand.push(hajr);
const truth = controlledTruth(g, f.emperor);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && routeDistance(g, 'carthag:11', `${to.territory}:${to.sector}`) === 3 &&
routeDistance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) > 2 &&
routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) > 1);
assert.ok(to);
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const first = viewGame(g, f.bg).movementPromisePolicy?.[0];
assert.equal(first?.type, 'move'); assert.equal(first.territory, 'carthag'); assert.equal(first.fighters, true);
g = act(g, f.bg, first);
assert.equal(g.response?.kind, 'advisorFlip');
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = advance(g, clean);
assert.deepEqual(viewGame(g, f.bg).movementPromisePolicy?.[0], { type: 'card', card: hajr.id });
g = advance(act(g, f.bg, { type: 'card', card: hajr.id }), clean);
g = act(g, f.bg, viewGame(g, f.bg).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('all four existing policies answer and complete the genuine Advanced movement question legally', () => {
const f = createTruthMovementAdvancedFixture();
const question = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
for (const difficulty of DIFFICULTIES) {
let g = question, count = 0;
while (g.phase === 5 && g.active === f.target && count++ < 20) {
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
g = act(g, f.target, action);
}
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
}
});