Support whole AND/OR future ground moves

This commit is contained in:
Quintine committed 2026-10-26 16:09:35 +11:00
1 parent 0e3c155876
commit d524642b65
20 files changed
+1803 -63

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMovement, createTruthMovementFixture, truthMovementClean,
truthMovementInventory, truthMovementPlayer, type TruthMovementStep,
} from './fixture-truth-movement';
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
export type TruthMovementExpressionStep = TruthMovementStep;
export const actTruthMovementExpression = actTruthMovement;
export const truthMovementExpressionClean = truthMovementClean;
export const truthMovementExpressionInventory = truthMovementInventory;
export const truthMovementExpressionPlayer = truthMovementPlayer;
/** Every continuation uses the original native response/readiness controls.
* A live grouped obligation uses the same exact action the player sees. */
export function nextTruthMovementExpression(game: Game): TruthMovementExpressionStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).movementExpressionPromiseGuide;
if (guide?.phase === game.phase) return { actor: player.id, action: guide.action };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthMovementExpression(game: Game, until: (state: Game) => boolean,
actions: TruthMovementExpressionStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original grouped movement source ended before its requested control');
const next = nextTruthMovementExpression(game);
assert.ok(next, 'Supply the original grouped Truthtrance question or answer');
game = actTruthMovementExpression(game, next.actor, next.action, actions);
}
throw Error('The original grouped movement source did not reach its finite boundary');
}
export function settleTruthMovementExpression(game: Game, actions: TruthMovementExpressionStep[] = []): Game {
return advanceTruthMovementExpression(game, truthMovementExpressionClean, actions);
}
export function finishTruthMovementExpression(game: Game, target = game.players[0].id,
actions: TruthMovementExpressionStep[] = []): Game {
game = settleTruthMovementExpression(game, actions);
assert.equal(game.phase, 5); assert.equal(game.active, target);
game = actTruthMovementExpression(game, target, { type: 'endMovement' }, actions);
game = settleTruthMovementExpression(game, actions);
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes its interval');
return game;
}
export function completeTruthMovementExpression(game: Game, target = game.players[0].id,
actions: TruthMovementExpressionStep[] = []): Game {
const live = () => game.movementExpressionPromises?.some(promise => promise.player === target &&
promise.turn === game.turn && promise.answer && !promise.fulfilled && !promise.released);
for (let count = 0; count < 12 && live(); count++) {
game = settleTruthMovementExpression(game, actions);
const guide = viewGame(game, target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, game.phase);
game = settleTruthMovementExpression(actTruthMovementExpression(game, target, guide.action, actions), actions);
}
assert.ok(!live(), 'The exact native movement guide fulfills the accepted whole expression');
return game;
}
/** Thin adapter over genuine public Basic/Advanced base33 starts. The optional
* Charity source replays only the original setup prefix from its unopened deal,
* selects the same still-unopened Spice cards, and stops at real phase2. */
export function createTruthMovementExpressionFixture(options: {
advanced?: boolean; hajr?: boolean; window?: 'active' | 'charity'; claim?: MovementExpression;
} = {}) {
const base = options.advanced
? createTruthMovementAdvancedFixture({ hajr: options.hajr })
: createTruthMovementFixture({ hajr: options.hajr });
const window = options.window ?? 'active';
let game = structuredClone(base.game), actions = structuredClone(base.actions);
let afterSetup = structuredClone(base.afterSetup), afterSetupActionCount: number | null = null;
let spiceOrder = structuredClone(base.spiceOrder);
if (window === 'charity') {
const start = base.actions.findIndex(step => step.action.type === 'start'); assert.ok(start >= 0);
actions = structuredClone(base.actions.slice(0, start + 1));
game = advanceTruthMovementExpression(structuredClone(base.beforeStartingDeal),
state => state.status === 'playing', actions);
afterSetup = structuredClone(game); afterSetupActionCount = actions.length;
spiceOrder = base.spiceOrder.map(({ position, card }) => {
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
JSON.stringify(candidate) === JSON.stringify(card));
assert.ok(from >= position, 'Choose 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) };
});
game = advanceTruthMovementExpression(game,
state => state.turn === 1 && state.phase === 2 && truthMovementExpressionClean(state), actions);
assert.equal(truthMovementExpressionPlayer(game, base.target).shipped, false);
assert.equal(truthMovementExpressionPlayer(game, base.target).moved, 0);
assert.deepEqual(truthMovementExpressionPlayer(game, base.target).forces, { 'arrakeen:10': 10 });
}
assert.equal(game.advanced, options.advanced ?? false); assert.deepEqual(game.expansions, []);
assert.deepEqual(truthMovementExpressionInventory(game), base.originalCards);
assert.ok(truthMovementExpressionPlayer(game, base.asker).hand.some(card => card.id === base.truth));
assert.equal(game.movementExpressionPromises, undefined); assert.equal(game.truthtrance ?? null, null);
const firstMove = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
const secondMove = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action;
const claim = parseMovementExpression(options.claim ?? { kind: 'and', terms: [
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
{ kind: 'move', territory: 'carthag', minimum: 1 },
] });
const question = { kind: 'movementExpression' as const, target: base.target, turn: game.turn, claim };
return { ...base, base, game, source: structuredClone(game), window, claim, question,
afterSetup, afterSetupActionCount, spiceOrder,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { inheritedActive: base.sourceActionCount, setup: afterSetupActionCount, selected: actions.length },
firstMove, secondMove, sameDestinationMove: { ...firstMove },
hajrAction: base.hajr ? { type: 'card', card: base.hajr } satisfies Action : null,
declaration: { type: 'card', card: base.truth } satisfies Action,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceDisclosure: `${base.staging.join(' ')} This grouped adapter changes only the structured question. ${window === 'charity'
? 'Its recorded source excludes the original active-window suffix: the unopened deal is replayed, the same unopened Spice selection is preserved, and actual Storm/Spice controls stop at clean Charity.'
: 'Its recorded source retains the exact original public setup, phase controls and paid one-force Polar Sink shipment.'} The future Imperial Basin9 and Carthag11 ground groups use the original Arrakeen10 army; Hajr remains the actually dealt physical card. No played clock, purse, hand, army, movement allowance, answer or progress is installed.` };
}
export type TruthMovementExpressionFixture = ReturnType<typeof createTruthMovementExpressionFixture>;
export function declareTruthMovementExpression(f: TruthMovementExpressionFixture, source = f.game) {
const before = structuredClone(source), actions: TruthMovementExpressionStep[] = [];
let game = actTruthMovementExpression(source, f.asker, f.declaration, actions);
const opened = structuredClone(game);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthMovementExpression(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, opened, actions, actionCount: actions.length };
}
export function askTruthMovementExpression(game: Game, asker: string, target: string, claim: MovementExpression,
actions: TruthMovementExpressionStep[] = []): Game {
return actTruthMovementExpression(game, asker, { type: 'truthAsk', question: {
kind: 'movementExpression', target, turn: game.turn, claim: parseMovementExpression(claim),
} }, actions);
}
export function answerTruthMovementExpression(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthMovementExpressionStep[] = []): Game {
return actTruthMovementExpression(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMovementExpression(f: TruthMovementExpressionFixture, answer: 'yes' | 'no' = 'yes',
claim = f.claim, source = f.game) {
const declared = declareTruthMovementExpression(f, source), actions = [...declared.actions];
const asked = askTruthMovementExpression(declared.game, f.asker, f.target, claim, actions);
const game = answerTruthMovementExpression(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { territory } from '../game/board';
import type { Action, Game } from '../game/engine';
import {
MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES, MovementExpressionPromiseError,
evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionText,
parseMovementExpression, parseMovementExpressionClaim, recordMovementExpressionEvent, validateMovementExpressionPromises,
type MovementExpression, type MovementExpressionProgress, type MovementExpressionPromise,
} from '../game/movement-expression-promises';
import { findMovementCompletion, type MovementPromiseAdapter } from '../game/movement-promise-search';
import type { MovementEvent, MovementPromise } from '../game/movement-promises';
const move = (destination = 'carthag', minimum = 3): MovementExpression => ({ kind: 'move', territory: destination, minimum });
const group = (kind: 'and' | 'or', ...terms: MovementExpression[]): MovementExpression => ({ kind, terms });
const progress = (destination: string, maximum: number): MovementExpressionProgress => [{ territory: destination, maximum }];
const promise = (event = 'event', claim: MovementExpression = move(), extra: Partial<MovementExpressionPromise> = {}): MovementExpressionPromise =>
({ event, turn: 2, player: 'mover', asker: 'asker', answer: true, progress: [], claim, ...extra });
const atomic = (destination = 'carthag', minimum = 3, extra: Partial<MovementPromise> = {}): MovementPromise =>
({ turn: 2, player: 'mover', asker: 'asker', answer: true, territory: destination, minimum, ...extra });
const seats = ['mover', 'asker', 'other'];
void test('movement expressions parse all sixteen leaves and four nested groups through scalar printed-board rules', () => {
assert.equal(MOVEMENT_EXPRESSION_MAX_DEPTH, 4); assert.equal(MOVEMENT_EXPRESSION_MAX_LEAVES, 16);
const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => move('carthag', index + 1)));
const parsed = parseMovementExpression(sixteen);
assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen);
if ('terms' in parsed && 'terms' in sixteen) {
assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]);
}
const nested = (depth: number): MovementExpression => depth === 0 ? move() : group('or', move('arrakeen'), nested(depth - 1));
assert.deepEqual(parseMovementExpression(nested(4)), nested(4));
assert.throws(() => parseMovementExpression(nested(5)), MovementExpressionPromiseError);
assert.throws(() => parseMovementExpression(group('or', ...Array.from({ length: 17 }, () => move()))), MovementExpressionPromiseError);
assert.throws(() => parseMovementExpression(group('and',
group('or', ...Array.from({ length: 9 }, () => move())),
group('or', ...Array.from({ length: 8 }, () => move())))), MovementExpressionPromiseError);
assert.deepEqual(parseMovementExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) }),
{ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) });
});
void test('strict movement parsing checks decisive later subtrees, exact fields and safe one-to-twenty minima', () => {
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [move()] },
{ kind: 'not', terms: [move(), move()] }, { kind: 'move', territory: 'carthag', minimum: 1, progress: 0 },
{ kind: 'and', terms: [move(), move('arrakeen')], branch: 0 },
...['', ' carthag', 'homeworld:caladan', 'hidden_mobile_stronghold', 'missing'].map(destination => move(destination)),
...[0, -1, 0.5, 21, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum =>
({ kind: 'move', territory: 'carthag', minimum }))];
for (const value of invalid) assert.throws(() => parseMovementExpression(value), MovementExpressionPromiseError);
const decisive = group('or', move('carthag', 1), move('arrakeen', 0));
assert.throws(() => evaluateMovementExpression(decisive, progress('carthag', 1)), MovementExpressionPromiseError);
assert.throws(() => evaluateMovementExpression(group('and', move(), decisive), []), MovementExpressionPromiseError);
for (const value of [{ turn: 0, claim: move() }, { turn: '2', claim: move() }, { turn: 2, claim: move(), branch: 0 },
{ turn: 2 }, { claim: move() }])
assert.throws(() => parseMovementExpressionClaim(value), MovementExpressionPromiseError);
});
void test('nested movement text and unique destinations preserve the whole expression', () => {
const claim = group('and', move('carthag', 3), group('or', move('arrakeen', 2), move('carthag', 6)));
assert.deepEqual(movementExpressionTerritories(claim), ['carthag', 'arrakeen']);
assert.equal(evaluateMovementExpression(claim, progress('carthag', 3)), false);
assert.equal(evaluateMovementExpression(claim, progress('carthag', 6)), true);
assert.equal(evaluateMovementExpression(claim, [...progress('carthag', 3), ...progress('arrakeen', 2)]), true);
assert.equal(movementExpressionText(claim),
'(make a ground movement of at least 3 physical forces to Carthag AND (make a ground movement of at least 2 physical forces to Arrakeen OR make a ground movement of at least 6 physical forces to Carthag))');
});
void test('progress records one actual move maximum and never sums smaller arrivals or marker pieces', () => {
const original = [promise('largest', move('carthag', 6))];
let ledger = original;
for (const amount of [0, 2, 4, 3, 5]) ledger = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount });
assert.deepEqual(ledger[0].progress, progress('carthag', 5)); assert.equal(ledger[0].fulfilled, undefined);
const bySix = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount: 6 });
assert.equal(bySix[0].fulfilled, true); assert.deepEqual(bySix[0].progress, progress('carthag', 6));
assert.deepEqual(original[0].progress, []); assert.deepEqual(ledger[0].progress, progress('carthag', 5));
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'carthag', amount: 0 })[0], original[0]);
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'hidden_mobile_stronghold', amount: 10 })[0], original[0]);
});
void test('whole Yes keeps alternate OR routes after partial movement and shares a same-destination event across leaves', () => {
const original = promise('alternate', group('or', move('carthag', 5), move('arrakeen', 3)));
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 4 });
assert.equal(partial[0].fulfilled, undefined);
const alternate = recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'arrakeen', amount: 3 });
assert.equal(alternate[0].fulfilled, true); assert.equal('branch' in alternate[0], false);
assert.deepEqual(alternate[0].progress, [...progress('carthag', 4), ...progress('arrakeen', 3)]);
const same = recordMovementExpressionEvent([promise('shared', group('and', move('carthag', 3), move('carthag', 5)))],
'mover', 2, { territory: 'carthag', amount: 5 });
assert.equal(same[0].fulfilled, true); assert.deepEqual(same[0].progress, progress('carthag', 5));
assert.deepEqual(original.progress, []);
});
void test('whole No to nested AND allows one conjunct and rejects the actual move completing the whole statement', () => {
const original = promise('not-all', group('and', move('carthag', 2), group('or', move('arrakeen', 3), move('polar_sink', 4))),
{ answer: false });
const first = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 });
assert.equal(first[0].fulfilled, undefined);
for (const event of [{ territory: 'arrakeen', amount: 3 }, { territory: 'polar_sink', amount: 4 }])
assert.throws(() => recordMovementExpressionEvent(first, 'mover', 2, event), MovementExpressionPromiseError);
const reversed = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
assert.throws(() => recordMovementExpressionEvent(reversed, 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
assert.deepEqual(first[0].progress, progress('carthag', 2));
});
void test('whole No to nested OR forbids either completed branch while permitting an incomplete AND branch', () => {
const original = promise('not-either', group('or', move('carthag', 2), group('and', move('arrakeen', 3), move('polar_sink', 4))),
{ answer: false });
assert.throws(() => recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
assert.doesNotThrow(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 1 }));
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'polar_sink', amount: 4 }), MovementExpressionPromiseError);
});
void test('answer intervals exclude prior movements and retain other-player, old-turn, terminal and nonmatching records', () => {
const earlier = recordMovementExpressionEvent([promise('earlier')], 'mover', 2, { territory: 'carthag', amount: 3 })[0];
const later = promise('later');
const records = [earlier, promise('released', move(), { released: true }), promise('old-turn', move(), { turn: 1 }),
promise('other', move(), { player: 'other' }), promise('elsewhere', move('arrakeen')), later];
assert.deepEqual(liveMovementExpressionPromises(records, 'mover', 2), [records[4], later]);
const next = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
for (let index = 0; index < 5; index++) assert.equal(next[index], records[index]);
assert.deepEqual(next[5].progress, progress('carthag', 2)); assert.equal(next[5].fulfilled, undefined);
assert.deepEqual(later.progress, []);
});
void test('rejected completion cannot partly update another answer or frozen input progress', () => {
const records = [promise('yes'), promise('no', group('or', move(), move('arrakeen')), { answer: false })];
const partial = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
const before = structuredClone(partial);
partial.forEach(entry => { entry.progress.forEach(Object.freeze); Object.freeze(entry.progress); Object.freeze(entry); });
Object.freeze(partial);
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 3 }), MovementExpressionPromiseError);
assert.deepEqual(partial, before); assert.equal(partial[0].fulfilled, undefined);
});
void test('movement progress and events reject unsafe, overflowing, duplicate or extra-key entries', () => {
const invalid: unknown[] = [null, {}, progress('carthag', -1), progress('carthag', 0.5), progress('carthag', 21),
progress('carthag', Number.MAX_SAFE_INTEGER + 1), [...progress('carthag', 1), ...progress('carthag', 2)],
progress('missing', 1), [{ territory: 'carthag', maximum: 1, sum: 1 }]];
for (const value of invalid)
assert.throws(() => evaluateMovementExpression(move(), value as MovementExpressionProgress), MovementExpressionPromiseError);
for (const event of [{ territory: '', amount: 1 }, { territory: ' carthag', amount: 1 },
{ territory: 'carthag', amount: -1 }, { territory: 'carthag', amount: 0.5 }, { territory: 'carthag', amount: 21 },
{ territory: 'carthag', amount: Infinity }, { territory: 'carthag', amount: Number.MAX_SAFE_INTEGER + 1 },
{ territory: 'carthag', amount: 1, marker: 1 }])
assert.throws(() => recordMovementExpressionEvent([], 'mover', 2, event), MovementExpressionPromiseError);
assert.throws(() => recordMovementExpressionEvent([promise('foreign-progress', move(), { progress: progress('arrakeen', 1) })],
'mover', 2, { territory: 'carthag', amount: 1 }), MovementExpressionPromiseError);
});
void test('movement ledger validation binds events, participants, matching maxima and whole-answer terminal flags', () => {
const valid = [promise('live', move(), { progress: progress('carthag', 2) }),
promise('fulfilled', move(), { progress: progress('carthag', 3), fulfilled: true }),
promise('closed-no', move(), { answer: false, fulfilled: true }), promise('released', move(), { released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateMovementExpressionPromises(valid, seats));
assert.doesNotThrow(() => validateMovementExpressionPromises(JSON.parse(JSON.stringify(valid)) as MovementExpressionPromise[], seats));
const base = promise();
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'mover' }, { answer: 'yes' },
{ fulfilled: 1 }, { released: 1 }, { fulfilled: true }, { fulfilled: true, released: true },
{ progress: progress('carthag', 3) }, { progress: progress('arrakeen', 1) },
{ answer: false, progress: progress('carthag', 3) }, { answer: false, progress: progress('carthag', 3), released: true },
{ turn: 0 }].map(extra => [{ ...base, ...extra }])];
for (const value of invalid)
assert.throws(() => validateMovementExpressionPromises(value as MovementExpressionPromise[], seats), MovementExpressionPromiseError);
assert.deepEqual(valid, before);
});
/** Structural adapter seam only: native engine entitlement and physical-route
* proof belongs to its runtime tests. This fixture consumes one real Hajr ID. */
function searchSource(hajr = false): Game {
return { turn: 2, advanced: false, players: [{ id: 'mover', faction: 'atreides',
forces: { 'imperial_basin:9': 6 }, moved: 0, shipped: true, reserves: 0, tanks: 0, spice: 0,
hand: hajr ? [{ id: 'owned-hajr', effect: 'hajr' }] : [] }], discard: [], hajr: [] } as unknown as Game;
}
function searchAdapter(projected: MovementEvent[] = []): MovementPromiseAdapter {
return {
allowance: game => game.hajr.includes('mover') ? 2 : 1,
canFinish: () => true,
*preparations(game) {
const player = game.players[0], card = player.hand.find(card => card.effect === 'hajr');
if (!card || game.hajr.includes(player.id)) return;
const next = structuredClone(game);
next.players[0].hand = next.players[0].hand.filter(held => held.id !== card.id);
next.discard.push(card); next.hajr.push(player.id);
yield { game: next, actions: [{ type: 'card', card: card.id }] };
},
reachable: (_game, _player, origin, destination, sector) => origin !== destination &&
['carthag', 'arrakeen'].includes(destination) && sector === territory(destination).sectors[0],
project(game, _actor, action: Action) {
const next = structuredClone(game), player = next.players[0];
const forces = action.forces as Record<string, number>;
const amount = Object.values(forces).reduce((sum, value) => sum + value, 0);
for (const [key, count] of Object.entries(forces)) {
if (count > player.forces[key]) return null;
player.forces[key] -= count;
if (player.forces[key] === 0) delete player.forces[key];
}
const destination = `${String(action.territory)}:${String(action.sector)}`;
player.forces[destination] = (player.forces[destination] ?? 0) + amount; player.moved++;
const event = { territory: String(action.territory), amount }; projected.push(event);
return { game: next, event };
},
};
}
void test('joint search uses an owned Hajr for two distinct AND moves and leaves the original state detached', () => {
const source = searchSource(true), before = structuredClone(source);
source.movementExpressionPromises = [promise('two-moves', group('and', move('arrakeen', 3), move('carthag', 3)))];
const result = findMovementCompletion(source, 'mover', [], searchAdapter());
assert.ok(result);
assert.equal(result.actions.filter(action => action.type === 'move').length, 2);
assert.equal(result.actions.filter(action => action.type === 'card' && action.card === 'owned-hajr').length, 1);
assert.deepEqual(source.players, before.players); assert.deepEqual(source.discard, before.discard);
assert.deepEqual(source.movementExpressionPromises[0].progress, []);
assert.equal(findMovementCompletion(searchSource(), 'mover', [], searchAdapter(), source.movementExpressionPromises), null);
});
void test('joint search retains alternate expression routes under scalar No and shares one move across positive answers', () => {
const source = searchSource();
const choice = promise('either', group('or', move('arrakeen', 3), move('carthag', 3)));
const result = findMovementCompletion(source, 'mover', [atomic('arrakeen', 1, { answer: false }), atomic('carthag', 3)],
searchAdapter(), [choice]);
assert.ok(result); assert.equal(result.actions.length, 1); assert.equal(result.actions[0].territory, 'carthag');
assert.equal(Object.values(result.actions[0].forces as Record<string, number>).reduce((sum, value) => sum + value, 0), 3);
});
void test('joint search rejects whole-No completion before projecting physical pieces and respects earlier progress', () => {
const projected: MovementEvent[] = [];
const notBoth = promise('not-both', group('and', move('arrakeen', 3), move('carthag', 3)),
{ answer: false, progress: progress('arrakeen', 3) });
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(projected), [notBoth]), null);
assert.deepEqual(projected, []);
const permitted = findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 2)], searchAdapter(), [notBoth]);
assert.ok(permitted); assert.equal(permitted.actions[0].territory, 'carthag');
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(),
[promise('neither', group('or', move('arrakeen', 3), move('carthag', 3)), { answer: false })]), null);
});
void test('whole-expression physical bounds retain reachable OR alternatives and previously satisfied larger leaves', () => {
const source = searchSource();
const choice = promise('smaller-alternate', group('or', move('arrakeen', 20), move('carthag', 3)));
const byAlternate = findMovementCompletion(source, 'mover', [], searchAdapter(), [choice]);
assert.ok(byAlternate); assert.equal(byAlternate.actions[0].territory, 'carthag');
const partlyDone = promise('larger-earlier', group('and', move('arrakeen', 20), move('carthag', 3)),
{ progress: progress('arrakeen', 20) });
const remaining = findMovementCompletion(source, 'mover', [], searchAdapter(), [partlyDone]);
assert.ok(remaining); assert.equal(remaining.actions.length, 1); assert.equal(remaining.actions[0].territory, 'carthag');
assert.equal(findMovementCompletion(source, 'mover', [], searchAdapter(),
[promise('impossible', group('or', move('arrakeen', 20), move('carthag', 7)))]), null);
});
void test('explicit empty expression subsets preserve the legacy search contract even when the source has a pending expression', () => {
const source = searchSource(); source.movementExpressionPromises = [promise('needs-both', group('and', move('arrakeen'), move('carthag')))];
const result = findMovementCompletion(source, 'mover', [atomic()], {
...searchAdapter(),
project(game, actor, action) {
assert.deepEqual(game.movementExpressionPromises, []);
return searchAdapter().project(game, actor, action);
},
}, []);
assert.ok(result); assert.equal(result.actions.length, 1);
});
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementExpressionFields, movementExpressionInputError } from '../components/movement-expression-fields';
import { MovementExpressionPromises, MovementExpressionPromiseGuideButton } from '../components/movement-expression-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import { createTechTokens } from '../game/tech-tokens';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const territories = [{ id: 'carthag', name: 'Carthag' }, { id: 'arrakeen', name: 'Arrakeen' }, { id: 'sietch_tabr', name: 'Sietch Tabr' }];
const move = (minimum = 1, territory = 'carthag'): MovementExpression => ({ kind: 'move', territory, minimum });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-movement-expression-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-movement-expression-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-movement-expression-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editor(initial: MovementExpression, disabled = false) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => MovementExpressionFields({ value, territories, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MovementExpressionFields, { value, territories, disabled, onChange() {} })),
};
}
function nested(depth: number): MovementExpression {
let value = move();
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, move(2, 'arrakeen')] };
return value;
}
function guide(props: Parameters<typeof MovementExpressionPromiseGuideButton>[0]) {
const button = MovementExpressionPromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(MovementExpressionPromises, { game, busy: false, send() {} }));
const event = 'controlled-movement-expression-event';
/** These controlled personalized projections test callbacks and labels only.
* They do not claim source-earned cards, physical movement or feasibility. */
function projected(phase = 5, action: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
const source = createGame('TRUTHMOVEMENTEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = []; game.active = phase === 5 ? 'p' : null;
game.movementExpressionPromises = [{ event, player: 'p', asker: 'a', turn: 2,
claim: { kind: 'or', terms: [move(4), { kind: 'and', terms: [move(2, 'arrakeen'), move(3)] }] },
answer: true, progress: [{ territory: 'carthag', maximum: 2 }, { territory: 'arrakeen', maximum: 1 }] }];
game.movementExpressionPromiseGuide = { phase, action: { ...action, movementExpressionPromiseEvent: event } };
game.movementPromises = [];
game.auction = phase === 3 ? { bid: 4, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null } : null;
return game;
}
function nativeProjected(method: 'silent' | 'onceAround') {
const game = projected(3, { type: 'richeseBid' });
const native = createRicheseAuction({ event: 'controlled-native-auction', cardId: 'controlled-card', owner: 'a',
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
game.movementExpressionPromiseGuide!.action = { type: 'richeseBid', event: native.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0, movementExpressionPromiseEvent: event };
return game;
}
function paidBox(phase = 5) {
const game = projected(phase, { type: 'decision', event: 'controlled-paid-box', card: 'controlled-hajr' });
game.decision = { kind: 'nullentropy', player: 'p' };
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' },
blocked: null, search: { event: 'controlled-paid-box', cards: [{ id: 'controlled-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
return game;
}
void test('controlled movement editor retains sixteen exact leaves, arbitrary nested groups and singleton collapse', () => {
const e = editor(move(2)); click(control(e.tree(), 'or-1'));
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count);
if (count % 3 === 0) select(e.tree(), `1.${count}`, 'territory', 'arrakeen');
}
const full = e.value; assert.ok(full.kind !== 'move'); assert.equal(full.terms.length, 16);
assert.deepEqual(parseMovementExpression(full), full); assert.match(e.html(), /16 \/ 16 conditions/);
assert.equal((e.html().match(/Ground-movement destination/g) ?? []).length, 16);
const before = structuredClone(full), changes = e.changes;
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
const grouped = editor({ kind: 'and', terms: [move(3), move(2, 'arrakeen')] });
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
const groupedValue = grouped.value; assert.ok(groupedValue.kind !== 'move'); assert.equal(groupedValue.terms.length, 4);
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual((grouped.value as typeof groupedValue).terms[1], move(2, 'arrakeen'));
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, move(2, 'arrakeen'));
});
void test('depth-four limits and busy controls preserve structure while numeric and destination drafts stay repairable', () => {
const deep = editor(nested(4)); assert.equal(movementExpressionInputError(deep.value), null);
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', 20); assert.equal(deep.changes, 1);
for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) {
const e = editor(move()); minimum(e.tree(), '1', invalid);
const draft = e.value; assert.ok(draft.kind === 'move'); assert.ok(Object.is(draft.minimum, invalid));
assert.ok(movementExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/);
select(e.tree(), '1', 'territory', ''); click(control(e.tree(), 'or-1'));
const wrapped = e.value; assert.ok(wrapped.kind !== 'move'); assert.deepEqual(wrapped.terms[0], move(invalid, ''));
minimum(e.tree(), '1.1', 2); assert.ok(movementExpressionInputError(e.value));
select(e.tree(), '1.1', 'territory', 'arrakeen'); assert.equal(movementExpressionInputError(e.value), null);
}
for (const invalid of [nested(5), { kind: 'and', terms: [move()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => move()) },
move(1, 'homeworld:emperor'), move(1, 'hidden_mobile_stronghold')]) assert.ok(movementExpressionInputError(invalid));
const frozen = editor({ kind: 'and', terms: [move(), move(2, 'arrakeen')] }, true), before = structuredClone(frozen.value);
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'territory', 'carthag');
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('persistent whole-No panel shows the full unpinned tree and destination maxima without summing smaller moves', () => {
const game = projected(); game.movementExpressionPromiseGuide = null;
game.movementExpressionPromises.push(
{ ...game.movementExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [move(4), move(2, 'arrakeen')] } },
{ ...game.movementExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true },
{ ...game.movementExpressionPromises[0], event: 'released-expression', released: true },
{ ...game.movementExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: move(19, 'sietch_tabr') },
);
const before = structuredClone(game), html = markup(game);
assert.match(html, /Your movement expression promises/); assert.match(html, / OR /); assert.match(html, / AND /);
assert.match(html, /Carthag: 2 physical forces; Arrakeen: 1 physical forces/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
assert.match(html, /Smaller moves never add together; progress records the maximum in one move/);
assert.match(html, /Prior moves and shipments do not count/); assert.match(html, /before your Movement turn ends this round/);
assert.match(html, /No remains binding through every later Hajr or Ornithopter move/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-movement-expression"/);
assert.doesNotMatch(html, /19 physical|controlled-movement-expression-event/); assert.deepEqual(game, before);
for (const phase of [0, 1, 2, 3, 4]) { game.phase = phase; assert.match(markup(game), /Your Movement turn is still ahead/); }
game.phase = 5; game.active = 'a'; assert.match(markup(game), /Your Movement turn is still ahead/);
assert.equal(MovementExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('native movement, shipment, reveal, Hajr and Ornithopter guides forward every quoted field exactly', () => {
const actions: Action[] = [
projected().movementExpressionPromiseGuide!.action,
{ type: 'move', forces: { 'arrakeen:10': 4 }, eliteForces: {}, territory: 'carthag', sector: 11,
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'original-flight' },
{ type: 'move', forces: {}, eliteForces: {}, noField: 'controlled-token', event: 'original-token-event', territory: 'carthag', sector: 11 },
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0, noField: 'controlled-token' },
{ type: 'revealNoField', event: 'original-token-event' }, { type: 'prepareAdvisors', territory: 'arrakeen', event: 'original-advisor-event' },
{ type: 'card', card: 'controlled-hajr' }, { type: 'endMovement' },
];
for (const action of actions) {
const game = projected(5, action), sent: Action[] = [], props = { game, busy: false, send(value: Action) { sent.push(value); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action); assert.deepEqual(sent, [game.movementExpressionPromiseGuide!.action]);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
assert.match(markup(projected()), /Move 4 forces to Carthag, sector 11/);
assert.match(markup(projected(5, actions[1])), /Use Ornithopter and move 4 forces/);
assert.match(markup(projected(5, actions[2])), /Move your concealed No-Field/);
assert.match(markup(projected(5, actions[3])), /Ship a concealed No-Field/);
assert.match(markup(projected(5, actions[4])), /Reveal your No-Field/);
assert.match(markup(projected(5, actions[5])), /Prepare advisors in Arrakeen/);
assert.match(markup(projected(5, actions[6])), /Play Hajr/);
});
void test('owned current preparation guides preserve Charity, Revival, native auction and paid Box packets', () => {
for (const [phase, action] of [[2, { type: 'charity' }], [2, { type: 'ready' }], [4, { type: 'revive', amount: 3, elite: 1 }],
[4, { type: 'ready' }], [3, { type: 'passBid' }], [3, { type: 'decision', karama: false }]] satisfies [number, Action][]) {
const game = projected(phase, action), sent: Action[] = [];
if (action.type === 'decision') game.decision = { kind: 'auctionPayment', player: 'p' };
const button = guide({ game, busy: false, send(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
if (action.type === 'decision') game.decision!.player = 'a';
else if (phase === 3) game.auction!.active = 'a'; else game.ready.push('p');
press(button); assert.equal(sent.length, 1);
}
for (const method of ['silent', 'onceAround'] as const) {
const game = nativeProjected(method), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1);
}
for (const phase of [0, 2, 3, 4, 5]) {
const game = paidBox(phase), sent: Action[] = []; game.active = 'a';
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
assert.match(markup(game), /Choose Hajr in your paid Box search/); assert.match(markup(game), /fee is paid only once/);
game.nullentropy!.search!.event = 'later-paid-search'; press(button); assert.equal(sent.length, 1);
}
});
void test('stale callbacks reject changed phase, own event, busy windows and nested physical move mutation', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.active = 'a'; }, game => { game.movementExpressionPromiseGuide = null; },
game => { game.movementExpressionPromiseGuide = { phase: 5, action: { ...game.movementExpressionPromiseGuide!.action } }; },
game => { game.movementExpressionPromiseGuide!.phase = 4; }, game => { game.movementExpressionPromiseGuide!.action.territory = 'arrakeen'; },
game => { (game.movementExpressionPromiseGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
game => { (game.movementExpressionPromiseGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
game => { game.movementExpressionPromises[0].event = 'expired'; }, game => { game.movementExpressionPromises[0].player = 'a'; },
game => { game.movementExpressionPromises[0].turn = 1; }, game => { game.movementExpressionPromises[0].fulfilled = true; },
game => { game.movementExpressionPromises[0].released = true; }, game => { game.movementExpressionPromises = []; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
const original = guide(replaced); replaced.game = { ...replaced.game }; press(original);
});
void test('waiting and wrong-phase quotes cannot expose premature movement and scalar promises suppress a duplicate visible guide', () => {
for (const [phase, action] of [[0, { type: 'ready' }], [1, { type: 'move' }], [2, { type: 'revive' }], [3, { type: 'ready' }],
[4, { type: 'charity' }], [4, { type: 'ship' }], [5, { type: 'revive' }], [6, { type: 'move' }]] satisfies [number, Action][]) {
const game = projected(phase, action), sent: Action[] = [];
const button = guide({ game, busy: false, send(value) { sent.push(value); } });
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
}
const game = projected(); game.active = 'a'; game.movementExpressionPromiseGuide = null;
assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /<button/);
game.active = 'p'; game.movementExpressionPromiseGuide = projected().movementExpressionPromiseGuide;
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
game.movementPromises.push({ player: 'p', asker: 'a', turn: 2, territory: 'carthag', minimum: 4, answer: true });
assert.doesNotMatch(markup(game), /data-testid="movement-expression-promise-guide"/);
game.movementPromises[0].fulfilled = true;
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
});
void test('projected own card names label preparation while eligibility never examines unrelated secret or physical resources', () => {
const cards = [
{ id: 'controlled-ghola', name: 'Tleilaxu Ghola', kind: 'special' as const, effect: 'ghola' },
{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' },
{ id: 'controlled-karama', name: 'Karama', kind: 'special' as const, effect: 'karama' },
];
const ghola = projected(5, { type: 'card', card: cards[0].id, amount: 2 });
ghola.players.find(player => player.id === ghola.me)!.hand = cards;
assert.match(markup(ghola), /Play Ghola to return 2 forces to reserves/);
const box = projected(5, { type: 'card', card: cards[1].id }); box.players.find(player => player.id === box.me)!.hand = cards;
assert.match(markup(box), /Pay 2 spice and search with Nullentropy Box/);
const payment = projected(3, { type: 'decision', karama: true, card: cards[2].id });
payment.decision = { kind: 'auctionPayment', player: 'p' }; payment.players.find(player => player.id === payment.me)!.hand = cards;
assert.match(markup(payment), /Use Karama for the current auction payment/);
for (const game of [projected(), ghola, box, payment, paidBox()]) {
const sent: Action[] = [];
for (const seat of game.players) for (const key of ['traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites', 'forces',
...(seat.id !== game.me ? ['hand'] : [])]) Object.defineProperty(seat, key, {
get() { throw Error(`No unrelated ${key} inspection`); }, configurable: true,
});
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, {
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
});
assert.match(markup(game), /Your movement expression promises/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
}
});
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MovementExpression } from '../game/movement-expression-promises';
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
import {
actTruthMovementExpression as act, advanceTruthMovementExpression as advance,
answerTruthMovementExpression as answer, askTruthMovementExpression as ask,
bindTruthMovementExpression as bind, completeTruthMovementExpression as complete,
createTruthMovementExpressionFixture as fixture, declareTruthMovementExpression as declare,
finishTruthMovementExpression as finish, settleTruthMovementExpression as settle,
truthMovementExpressionClean as clean, truthMovementExpressionInventory as inventory,
truthMovementExpressionPlayer as player,
} from './fixture-truth-movement-expression';
const move = (territory = 'imperial_basin', minimum = 1): MovementExpression => ({ kind: 'move', territory, minimum });
const both = (): MovementExpression => ({ kind: 'and', terms: [move(), move('carthag')] });
const either = (): MovementExpression => ({ kind: 'or', terms: [move(), move('carthag')] });
const promise = (game: Game, target = 'atreides') => game.movementExpressionPromises!.find(row => row.player === target)!;
const maximum = (game: Game, territory: string, target = 'atreides') =>
promise(game, target).progress.find(entry => entry.territory === territory)?.maximum ?? 0;
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} movement AND uses two actual groups and the original Hajr`, () => {
const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.equal(f.originalCards.length, 33);
assert.deepEqual(f.game.expansions, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 2).map(card => card.id), f.deal);
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 2);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10, 'polar_sink:0': 1 });
const source = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, source);
let game = bound.game; assert.deepEqual(promise(game).progress, []);
rejected(game, f.target, { type: 'endMovement' });
if (advanced) rejected(game, f.target, f.hajrAction!);
game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).moved, 1); assert.equal(maximum(game, 'imperial_basin'), 1);
assert.equal(maximum(game, 'carthag'), 0); assert.ok(!promise(game).fulfilled && !promise(game).released);
rejected(game, f.target, { type: 'endMovement' });
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, 5); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
const progress = structuredClone(promise(game).progress);
game = settle(act(game, f.target, guide.action));
assert.deepEqual(promise(game).progress, progress, 'An actual Hajr disposal grants movement without moving a force');
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
game = settle(act(game, f.target, f.secondMove));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, 'carthag'), 1);
assert.equal(player(game, f.target).forces['arrakeen:10'], 8);
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.equal(player(game, f.target).forces['carthag:11'], 1);
assert.equal(player(game, f.target).moved, 2);
game = finish(game, f.target); assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native movement OR Yes accepts the manually chosen alternate actual destination', () => {
const f = fixture(); let game = bind(f, 'yes', either()).game;
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'move');
const alternate = guide.action.territory === 'imperial_basin' ? f.secondMove : f.firstMove;
assert.notEqual(alternate.territory, guide.action.territory);
game = settle(act(game, f.target, alternate));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, String(alternate.territory)), 1);
assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr));
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
for (const first of ['imperial_basin', 'carthag']) void test(`native movement AND No permits ${first} first and blocks only whole completion`, () => {
const f = fixture(); let game = bind(f, 'no').game;
const firstMove = first === 'imperial_basin' ? f.firstMove : f.secondMove;
const secondMove = first === 'imperial_basin' ? f.secondMove : f.firstMove;
game = settle(act(game, f.target, firstMove));
assert.equal(maximum(game, first), 1); assert.ok(!promise(game).fulfilled);
game = settle(act(game, f.target, f.hajrAction!)); rejected(game, f.target, secondMove);
assert.equal(maximum(game, String(secondMove.territory)), 0);
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native movement OR No rejects either actual ground group until owner finish', () => {
const f = fixture(); let game = bind(f, 'no', either()).game;
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.secondMove);
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated destination thresholds share the actual largest group and never sum two smaller Hajr moves', () => {
const f = fixture(), claim: MovementExpression = { kind: 'and', terms: [move(), move('imperial_basin', 2)] };
let game = bind(f, 'no', claim).game;
game = settle(act(game, f.target, f.firstMove));
assert.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
game = settle(act(game, f.target, f.hajrAction!));
game = settle(act(game, f.target, f.sameDestinationMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!promise(game).fulfilled); game = finish(game, f.target);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
let yes = bind(f, 'yes', claim).game;
yes = settle(act(yes, f.target, { ...f.firstMove, amount: 2 }));
assert.equal(promise(yes).fulfilled, true);
assert.deepEqual(promise(yes).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
assert.ok(player(yes, f.target).hand.some(card => card.id === f.hajr));
assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
});
void test('native prior ground movement is excluded from a newly answered whole expression', () => {
const f = fixture(); let game = settle(act(f.game, f.target, f.firstMove));
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
game = bind(f, 'yes', either(), game).game;
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
rejected(game, f.target, { type: 'endMovement' });
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
game = settle(act(game, f.target, guide.action)); assert.deepEqual(promise(game).progress, []);
game = settle(act(game, f.target, f.secondMove));
assert.deepEqual(promise(game).progress, [{ territory: 'carthag', maximum: 1 }]);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native no-Hajr feasibility offers No for distinct-destination AND while OR retains either Yes route', () => {
const f = fixture({ hajr: false }), declared = declare(f);
let game = ask(declared.game, f.asker, f.target, both());
assert.deepEqual(viewGame(game, f.target).truthMovementExpressionAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.firstMove));
assert.ok(!promise(game).fulfilled); assert.equal(promise(finish(game, f.target)).fulfilled, true);
const orQuestion = ask(declared.game, f.asker, f.target, either());
assert.deepEqual(viewGame(orQuestion, f.target).truthMovementExpressionAnswers, ['yes', 'no']);
const yes = complete(answer(orQuestion, f.target, 'yes'), f.target);
assert.equal(promise(yes).fulfilled, true); assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
});
void test('native exact nested movement guide accepts wire objects and rejects stale foreign or modified packets', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMovementExpressionTargets.includes(f.target));
rejected(declared.game, f.target, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
const asked = ask(declared.game, f.asker, f.target, f.claim);
assert.equal(viewGame(asked, f.asker).truthMovementExpressionAnswers, null);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'yes');
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'move'); assert.ok(guide.action.forces);
assert.ok(guide.action.movementExpressionPromiseEvent);
assert.deepEqual(viewGame(game, f.target).movementExpressionPromises, [promise(game)]);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, movementExpressionPromiseEvent: 'stale-whole-movement-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action));
const modified = structuredClone(wire), forces = modified.forces as Record<string, number>;
const origin = Object.keys(forces)[0]; assert.ok(origin); forces[origin] += 1;
rejected(game, f.target, modified);
const event = promise(game).event; game = settle(act(game, f.target, wire));
assert.equal(promise(game).event, event); assert.ok(promise(game).progress.length > 0);
rejected(game, f.target, wire);
game = complete(game, f.target); rejected(game, f.target, guide.action);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('all four minimal policies take the canonical native movement guide through real Hajr and complete AND', () => {
const f = fixture(), bound = bind(f).game;
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(bound);
for (let count = 0; count < 8 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.movementExpressionPromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.movementExpressionPromiseEvent === guide.action.movementExpressionPromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action));
}
assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).moved, 2);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
}
});
void test('native Charity whole movement promise follows actual Bidding Revival shipment and owner movement', () => {
const f = fixture({ window: 'charity' }); assert.equal(f.game.phase, 2); assert.equal(f.game.turn, 1);
assert.equal(f.sourceCount, f.sourceActions.length); assert.ok(f.afterSetupActionCount! < f.sourceCount);
assert.ok(f.sourceActionCount < f.sourceCounts.inheritedActive);
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
let game = bind(f).game; assert.deepEqual(promise(game).progress, []);
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.hajrAction!);
const actions: { actor: string; action: Action }[] = [];
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
assert.ok(actions.some(step => step.action.type === 'passBid'));
assert.ok(actions.some(step => step.action.type === 'ready'));
assert.deepEqual(promise(game).progress, []); assert.equal(player(game, f.target).shipped, false);
game = settle(act(game, f.target, f.shipmentStep.action, actions), actions);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.deepEqual(promise(game).progress, [], 'The native reserve shipment contributes no ground-movement progress');
game = complete(game, f.target, actions); assert.equal(promise(game).fulfilled, true);
assert.equal(maximum(game, 'imperial_basin'), 1); assert.equal(maximum(game, 'carthag'), 1);
assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards);
});
void test('native original Advanced Fremen grouped progress waits for the actual range response and physical commitment', () => {
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
assert.ok(f.twoStepMove && f.twoStepDestination);
let game = act(f.game, f.asker, f.declaration);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' });
}
const claim: MovementExpression = { kind: 'or', terms: [
move(f.twoStepDestination), move(f.twoStepDestination, 2),
] };
game = answer(ask(game, f.asker, f.target, claim), f.target, 'yes');
const before = structuredClone(player(game, f.target));
game = act(game, f.target, f.twoStepMove);
assert.equal(game.response?.kind, 'fremenMovement');
assert.deepEqual(promise(game, f.target).progress, []);
assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(player(game, f.target).forces, before.forces);
assert.deepEqual(player(game, f.target).elites, before.elites);
game = settle(game);
assert.deepEqual(promise(game, f.target).progress, [{ territory: f.twoStepDestination, maximum: 1 }]);
assert.equal(promise(game, f.target).fulfilled, true);
const destination = `${f.twoStepDestination}:${String(f.twoStepMove.sector)}`;
assert.equal(player(game, f.target).forces[destination], 1);
assert.equal(player(game, f.target).elites!.forces[destination], 1);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});