Combine native shipment and ground movement promises

This commit is contained in:
Quintine committed 2026-10-26 19:25:07 +11:00
1 parent 13398aa76e
commit d4e82c74b1
26 files changed
+2341 -10

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actTruthBid, advanceTruthBid, createTruthBidFixture, truthBidClean, truthBidInventory,
truthBidPlayer, type TruthBidStep,
} from './fixture-truth-bid';
import {
settleTruthTravel, truthTravelGroup, truthTravelMove, truthTravelShip,
type TruthTravelExpression,
} from './fixture-truth-travel';
export type TruthTravelOrderStep = TruthBidStep;
export const actTruthTravelOrder = actTruthBid;
export const truthTravelOrderClean = truthBidClean;
export const truthTravelOrderInventory = truthBidInventory;
export const truthTravelOrderPlayer = truthBidPlayer;
export const settleTruthTravelOrder = settleTruthTravel;
/** The existing public H/A/E base33 start genuinely deals both printed
* Truthtrances to Harkonnen. Continue its actual auction/Revival and Harkonnen
* movement ending until Atreides' first unused native turn. Only the inherited
* unopened deal/ordinary lots/Spice selections and scalar lotteries are used.
* No hand, card custody, wallet, army, played clock, promise or receipt is set. */
export function createTruthTravelOrderFixture() {
const base = createTruthBidFixture({ window: 'waiting' });
const actions = structuredClone(base.sourceActions), suffixFrom = actions.length;
const game = advanceTruthBid(structuredClone(base.game), state => state.turn === 1 && state.phase === 5 &&
state.active === base.opponent && truthBidClean(state), actions);
const asker = base.asker, target = base.opponent, observer = base.target;
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), base.truths);
assert.equal(base.truths.length, 2); assert.equal(new Set(base.truths).size, 2);
assert.equal(truthBidPlayer(game, target).shipped, false); assert.equal(truthBidPlayer(game, target).moved, 0);
assert.deepEqual(truthBidPlayer(game, target).forces, { 'arrakeen:10': 10 });
assert.equal(truthBidPlayer(game, target).reserves, 10);
assert.ok(!game.movementRemaining?.includes(asker)); assert.ok(game.movementRemaining?.includes(target));
assert.equal(game.travelPromises?.length ?? 0, 0); assert.equal(game.shipmentPromises?.length ?? 0, 0);
assert.equal(game.movementPromises?.length ?? 0, 0); assert.equal(game.movementExpressionPromises?.length ?? 0, 0);
assert.deepEqual(truthBidInventory(game), base.originalCards);
const mixed = truthTravelGroup('or', truthTravelShip(), truthTravelMove());
return {
game, source: structuredClone(game), base, asker, target, observer, truths: structuredClone(base.truths),
setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal,
originalCards: base.originalCards, originalSpice: base.originalSpice,
dealOrder: base.dealOrder, spiceOrder: base.spiceOrder,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
continuation: structuredClone(actions.slice(suffixFrom)),
sourceCounts: { inheritedBidding: suffixFrom, nativeContinuation: actions.length - suffixFrom, selected: actions.length },
mixed,
travelQuestion: { kind: 'travel', target, turn: game.turn, mixed } satisfies TruthQuestion,
shipmentQuestion: { kind: 'shipment', target, territory: 'polar_sink', minimum: 1 } satisfies TruthQuestion,
movementQuestion: { kind: 'movement', target, territory: 'imperial_basin', minimum: 1 } satisfies TruthQuestion,
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
alternativeShipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
move: { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action,
alternativeMove: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
end: { type: 'endMovement' } satisfies Action,
sourceDisclosure: `${base.sourceDisclosure} This adapter continues the recorded original auction, normal Revival and ` +
'Harkonnen endMovement to clean phase5 Atreides. Both native opening Truthtrances remain physically in Harkonnen custody; ' +
'each later declaration uses a distinct genuine card. All selected action counts come from actual recorded arrays. ' +
'No native stopping card is present to demonstrate an opponent-caused loss of the final route, so this source proves ' +
'actual accepted-order binding and native compatibility, without claiming an opponent-release path.',
};
}
export type TruthTravelOrderFixture = ReturnType<typeof createTruthTravelOrderFixture>;
export function declareTruthTravelOrder(f: TruthTravelOrderFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTravelOrderStep[] = []) {
const before = structuredClone(source), from = actions.length;
let game = actTruthTravelOrder(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTravelOrder(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, steps: structuredClone(actions.slice(from)), actionCount: actions.length - from };
}
export function askTruthTravelOrder(game: Game, asker: string, question: TruthQuestion,
actions: TruthTravelOrderStep[] = []): Game {
return actTruthTravelOrder(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthTravelOrder(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthTravelOrderStep[] = []): Game {
return actTruthTravelOrder(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTravelOrder(f: TruthTravelOrderFixture, source: Game, copy: 0 | 1,
question: TruthQuestion, answer: 'yes' | 'no' = 'yes', actions: TruthTravelOrderStep[] = []) {
const declared = declareTruthTravelOrder(f, source, copy, actions);
const asked = askTruthTravelOrder(declared.game, f.asker, question, actions);
const game = answerTruthTravelOrder(asked, f.target, answer, actions);
return { game, declared, asked, actions };
}
export function truthTravelOrderQuestion(f: TruthTravelOrderFixture, mixed: TruthTravelExpression): TruthQuestion {
return { kind: 'travel', target: f.target, turn: f.game.turn, mixed: structuredClone(mixed) };
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TravelActionClaim } from '../game/travel-expression-promises';
import type { TruthFact } from '../game/truthtrance';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import {
actTruthMixedMovement, createTruthMixedMovementFixture, truthMixedMovementClean,
truthMixedMovementInventory, truthMixedMovementPlayer, type TruthMixedMovementStep,
} from './fixture-truth-mixed-movement';
import { createTruthMovementWaitingFixture } from './fixture-truth-movement-waiting';
export type TruthTravelExpression = MixedActionExpression<TravelActionClaim>;
export type TruthTravelStep = TruthMixedMovementStep;
export const actTruthTravel = actTruthMixedMovement;
export const truthTravelClean = truthMixedMovementClean;
export const truthTravelInventory = truthMixedMovementInventory;
export const truthTravelPlayer = truthMixedMovementPlayer;
export const truthTravelFact = (fact: TruthFact): TruthTravelExpression => ({ kind: 'fact', fact });
export const truthTravelShip = (territory = 'polar_sink', minimum = 1): TruthTravelExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTravelMove = (territory = 'imperial_basin', minimum = 1): TruthTravelExpression =>
({ kind: 'action', action: { kind: 'move', territory, minimum } });
export const truthTravelGroup = (kind: 'and' | 'or', ...terms: TruthTravelExpression[]): TruthTravelExpression =>
({ kind, terms });
/** One original public Basic base33 start. Active stops immediately before the
* source's paid Polar Sink shipment; shipped retains that one actual action.
* Waiting replays the original unopened deal reversal and native phase prefix.
* Charity is only an explicitly unsupported-window source for a boundary test.
* Every selected count comes from the corresponding recorded action array. */
export function createTruthTravelFixture(options: {
hajr?: boolean; window?: 'active' | 'waiting' | 'shipped' | 'charity'; mixed?: TruthTravelExpression;
} = {}) {
const window = options.window ?? 'active';
const activeSource = window === 'waiting' ? null : createTruthMixedMovementFixture({
hajr: options.hajr, window: window === 'charity' ? 'charity' : 'active',
});
const waitingSource = window === 'waiting' ? createTruthMovementWaitingFixture({ hajr: options.hajr }) : null;
const original = activeSource ?? waitingSource!;
let game = structuredClone(original.game), actions = structuredClone(original.actions);
let shipmentIndex: number | null = null;
if (window === 'active') {
assert.ok(activeSource);
shipmentIndex = activeSource.actions.findIndex(step => step.actor === activeSource.shipmentStep.actor &&
JSON.stringify(step.action) === JSON.stringify(activeSource.shipmentStep.action));
assert.ok(shipmentIndex >= 0, 'The source records its original paid shipment explicitly');
game = structuredClone(activeSource.beforeShipment);
actions = structuredClone(activeSource.actions.slice(0, shipmentIndex));
}
const asker = original.asker, target = original.target;
const shipmentAction: Action = waitingSource ? structuredClone(waitingSource.shipment) :
structuredClone(activeSource!.shipmentStep.action);
const firstMove: Action = waitingSource ? structuredClone(waitingSource.move) : structuredClone(activeSource!.firstMove);
const secondMove: Action = waitingSource
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 }
: structuredClone(activeSource!.secondMove);
const shipmentTerritory = String(shipmentAction.territory), movementTerritory = String(firstMove.territory);
const mixed = options.mixed ?? truthTravelGroup('and', truthTravelShip(shipmentTerritory), truthTravelMove(movementTerritory));
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
const hajrCard = original.setup.deck.find(card => card.effect === 'hajr'); assert.ok(hajrCard);
const projectile = original.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile);
const hajrFact: TruthFact = { kind: 'hand', name: hajrCard.name };
const projectileFact: TruthFact = { kind: 'hand', name: projectile.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthTravelPlayer(game, target).spice };
assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
assert.ok(truthTravelClean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.ok(truthTravelPlayer(game, asker).hand.some(card => card.id === original.truth));
assert.deepEqual(truthTravelInventory(game), original.originalCards);
assert.equal(game.travelPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), base: original, activeSource, waitingSource, window,
asker, target, truth: original.truth, card: original.truth, hajr: original.hajr,
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
afterSetup: original.afterSetup, beforeShipment: activeSource?.beforeShipment ?? null,
originalCards: original.originalCards, originalSpice: original.originalSpice,
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, startingPositions: original.startingPositions,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { selected: actions.length, inherited: original.sourceActionCount, shipment: shipmentIndex },
mixed, question, hajrFact, projectileFact, spiceFact,
shipmentAction, shipmentStep: { actor: target, action: shipmentAction },
shipmentTerritory, movementTerritory, firstMove, secondMove, sameDestinationMove: { ...firstMove },
shipmentCost: waitingSource ? waitingSource.shipmentCost : 2,
hajrAction: original.hajr ? { type: 'card', card: original.hajr } satisfies Action : null,
declaration: { type: 'card', card: original.truth } satisfies Action,
questionAction: { type: 'truthAsk', question } satisfies Action,
currentMove: waitingSource?.currentMove ?? null,
currentEnd: { type: 'endMovement' } satisfies Action,
sourceDisclosure: `${'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ')} ` +
`This travel adapter selects only the recorded ${window} native prefix and original raw structured question. ` +
'Its shipment leaves all refer to one future original reserve shipment after answering, and ground leaves refer to actual native ground groups. ' +
'The source has one actually dealt Truthtrance, so simultaneous prior scalar-plus-travel answers are outside this fixture. ' +
'No additional physical card, dealt hand, purse, force, played clock, promise, receipt or compiled fact is installed.',
};
}
export type TruthTravelFixture = ReturnType<typeof createTruthTravelFixture>;
export function declareTruthTravel(f: TruthTravelFixture, source = f.game) {
const before = structuredClone(source), actions: TruthTravelStep[] = [];
let game = actTruthTravel(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 = actTruthTravel(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, opened, actions, actionCount: actions.length };
}
export function askTruthTravel(game: Game, asker: string, target: string, mixed: TruthTravelExpression,
actions: TruthTravelStep[] = []): Game {
return actTruthTravel(game, asker, { type: 'truthAsk', question: {
kind: 'travel', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTravel(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTravelStep[] = []): Game {
return actTruthTravel(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTravel(f: TruthTravelFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, source = f.game) {
const declared = declareTruthTravel(f, source), actions = [...declared.actions];
const asked = askTruthTravel(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTravel(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** The private aggregate exposes the original question and its status only. */
export function truthTravelPromise(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).travelPromises.find(promise => promise.player === target);
assert.ok(row, 'The respondent retains the raw travel question and aggregate answer');
return row;
}
export function nextTruthTravel(game: Game): TruthTravelStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).travelPromiseGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthTravel(game: Game, until: (state: Game) => boolean, actions: TruthTravelStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original travel source ended before its requested native boundary');
const next = nextTruthTravel(game); assert.ok(next, 'Supply the original Truthtrance question or answer');
game = actTruthTravel(game, next.actor, next.action, actions);
}
throw Error('The original travel source did not reach its finite boundary');
}
export function settleTruthTravel(game: Game, actions: TruthTravelStep[] = []): Game {
return advanceTruthTravel(game, state => truthTravelClean(state) && !state.pendingShipment, actions);
}
export function completeTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTravelStep[] = []): Game {
for (let count = 0; count < 16; count++) {
const promise = truthTravelPromise(game, target);
if (promise.fulfilled || promise.released) return game;
game = settleTruthTravel(game, actions);
const guide = viewGame(game, target).travelPromiseGuide; assert.ok(guide, 'The accepted travel answer has a native completion');
game = settleTruthTravel(actTruthTravel(game, target, guide.action, actions), actions);
}
throw Error('The exact original native travel guide did not settle the accepted aggregate');
}
export function finishTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTravelStep[] = []): Game {
game = settleTruthTravel(game, actions);
assert.equal(game.phase, 5); assert.equal(game.active, target);
game = settleTruthTravel(actTruthTravel(game, target, { type: 'endMovement' }, actions), actions);
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes this native interval');
return game;
}
+15
View File
@@ -69,6 +69,21 @@ void test('mixed action parsing requires both families and preserves raw nested
assert.throws(() => decode(input), MixedActionQuestionError);
});
void test('optional current facts permit all-action travel questions without weakening other defaults or budgets', () => {
for (const original of [action(), group('or', action(), action('arrakeen')),
group('and', fact(), action())])
assert.deepEqual(decode(original, { requireFact: false }), original);
for (const original of [fact(), group('and', fact(), fact(10))])
assert.throws(() => decode(original, { requireFact: false }), MixedActionQuestionError);
assert.throws(() => decode(action(), { requireFact: true }), MixedActionQuestionError);
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxLeaves: 1 }),
MixedActionQuestionError);
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxDepth: 0 }),
MixedActionQuestionError);
for (const requireFact of [null, 0, 'false'])
assert.throws(() => decode(action(), { requireFact } as unknown as MixedActionExpressionLimits), MixedActionQuestionError);
});
void test('mixed action public parsing rejects frozen constants, metadata and malformed groups everywhere', () => {
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] },
{ kind: 'or', terms: [fact()] }, { kind: 'xor', terms: [fact(), action()] },
@@ -26,6 +26,22 @@ void test('spent shipment and paid Box decisions cannot start another preparatio
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []);
});
void test('Fremen reserve preparation retains Guild transport variants only with a reciprocal native ally', () => {
const game = source(), owner = game.players[0]; owner.faction = 'fremen';
const guild: Game['players'][number] = { ...owner, id: 'ally', faction: 'guild', ally: owner.id };
game.players.push(guild);
const matching = (action: Record<string, unknown>) => action.type === 'guildShip' && action.from === 'reserves' &&
action.territory === 'carthag' && action.sector === 11 && action.amount === 2 && action.allyPayment === 1;
const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) =>
matching(action) ? structuredClone(state) : null)];
assert.equal(choices.length, 2);
assert.deepEqual(choices.map(choice => choice.actions[0].elite), [0, 1]);
guild.ally = null;
assert.deepEqual([...movementShipmentPreparations(game, owner.id, (_state, _actor, action) => {
assert.notEqual(action.type, 'guildShip'); return null;
})], []);
});
void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => {
const game = source(), owner = game.players[0]; owner.faction = 'richese';
owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }],
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
import {
finishTravelTurn,
liveTravelPromises,
parseTravelActionClaim,
recordTravelMovement,
recordTravelShipment,
travelBranches,
travelPromiseValue,
TravelPromiseError,
validateTravelPromises,
type TravelActionClaim,
type TravelBranchLeaf,
type TravelPromise,
} from '../game/travel-expression-promises';
type Expression = MixedActionExpression<TravelActionClaim>;
const action = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression => ({
kind: 'action', action: { kind, territory, minimum },
});
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const leaf = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3, answer = true): TravelBranchLeaf =>
({ kind, territory, minimum, answer });
function promise(mixed: Expression, answer = true, event = 'travel:1'): TravelPromise {
return {
turn: 1, mixed, event, player: 'respondent', asker: 'asker', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: true, event: null }, progress: [], movementClosed: false,
fulfilled: false, released: false,
};
}
const event = (territory = 'carthag', amount = 3) => ({ territory, amount });
void test('travel action leaves use native destination/minimum validation and reject every extra field', () => {
for (const kind of ['ship', 'move'] as const) {
const original = { kind, territory: 'carthag', minimum: 3 };
assert.deepEqual(parseTravelActionClaim(original), original);
assert.notEqual(parseTravelActionClaim(original), original);
for (const invalid of [null, [], {}, { ...original, kind: 'revive' },
{ ...original, minimum: 0 }, { ...original, minimum: 21 }, { ...original, minimum: 1.5 },
{ ...original, minimum: '3' }, { ...original, territory: 'not-a-territory' },
{ ...original, turn: 1 }, { ...original, answer: true }, { ...original, maximum: 3 },
{ ...original, claim: { territory: 'carthag', minimum: 3 } }])
assert.throws(() => parseTravelActionClaim(invalid), TravelPromiseError);
}
});
void test('whole Yes AND requires both native events while OR keeps either alternative available', () => {
const and = promise(group('and', action('ship'), action('move', 'arrakeen', 2)));
const or = promise(group('or', action('ship'), action('move', 'arrakeen', 2)));
assert.deepEqual([...travelBranches(and)], [[leaf('ship'), leaf('move', 'arrakeen', 2)]]);
assert.deepEqual([...travelBranches(or)], [[leaf('ship')], [leaf('move', 'arrakeen', 2)]]);
const shipped = recordTravelShipment([and, or], 'respondent', 1, event());
assert.equal(travelPromiseValue(shipped[0]), false);
assert.equal(shipped[0].fulfilled, false);
assert.equal(shipped[1].fulfilled, true);
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2)]]);
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2));
assert.equal(moved[0].fulfilled, true);
assert.deepEqual([...travelBranches(moved[0])], [[]]);
assert.equal(liveTravelPromises(moved, 'respondent', 1).length, 0);
assert.equal(and.shipment.pending, true);
assert.deepEqual(and.progress, []);
});
void test('whole No applies De Morgan and permits changing branches after a real action', () => {
const andNo = promise(group('and', action('ship'), action('move', 'arrakeen', 2)), false);
const orNo = promise(group('or', action('ship'), action('move', 'arrakeen', 2)), false);
assert.deepEqual([...travelBranches(andNo)], [[leaf('ship', 'carthag', 3, false)],
[leaf('move', 'arrakeen', 2, false)]]);
assert.deepEqual([...travelBranches(orNo)], [[leaf('ship', 'carthag', 3, false),
leaf('move', 'arrakeen', 2, false)]]);
const shipped = recordTravelShipment([andNo], 'respondent', 1, event());
assert.equal(shipped[0].fulfilled, false);
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2, false)]]);
const before = structuredClone(shipped);
assert.throws(() => recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TravelPromiseError);
assert.deepEqual(shipped, before);
assert.throws(() => recordTravelShipment([orNo], 'respondent', 1, event()), TravelPromiseError);
const skipped = recordTravelShipment([andNo], 'respondent', 1, null);
assert.ok([...travelBranches(skipped[0])].some(branch => branch.length === 0),
'Skipping the shipment leaves an unrestricted completion, regardless of redundant narrower DNF branches');
assert.doesNotThrow(() => recordTravelMovement(skipped, 'respondent', 1, event('arrakeen', 2)));
});
void test('nested branches keep all signed alternatives and remove impossible positive shipment conjunctions', () => {
const asked = group('and', group('or', action('ship'), action('move')),
group('or', action('ship', 'arrakeen', 2), action('move', 'arrakeen', 4)));
const branches = [...travelBranches(promise(asked))];
assert.deepEqual(branches, [
[leaf('ship'), leaf('move', 'arrakeen', 4)],
[leaf('move'), leaf('ship', 'arrakeen', 2)],
[leaf('move'), leaf('move', 'arrakeen', 4)],
]);
assert.deepEqual([...travelBranches(promise(group('and', action('ship'), action('ship', 'arrakeen'))))], []);
const no = [...travelBranches(promise(asked, false))];
assert.deepEqual(no, [
[leaf('ship', 'carthag', 3, false), leaf('move', 'carthag', 3, false)],
[leaf('ship', 'arrakeen', 2, false), leaf('move', 'arrakeen', 4, false)],
]);
});
void test('same-destination signed bounds keep strongest minimum or lowest negative cap', () => {
const asked = group('and', action('move', 'carthag', 2), action('move', 'carthag', 5));
assert.deepEqual([...travelBranches(promise(asked))], [[leaf('move', 'carthag', 5)]]);
const negative = promise(group('or', action('move', 'carthag', 2), action('move', 'carthag', 5)), false);
assert.deepEqual([...travelBranches(negative)], [[leaf('move', 'carthag', 2, false)]]);
const atCap = recordTravelMovement([negative], 'respondent', 1, event('carthag', 1));
assert.equal(atCap[0].progress[0].maximum, 1);
assert.deepEqual([...travelBranches(atCap[0])], [[leaf('move', 'carthag', 2, false)]]);
assert.throws(() => recordTravelMovement(atCap, 'respondent', 1, event('carthag', 2)), TravelPromiseError);
});
void test('shipment leaves share one event and later answers receive no earlier shipment or movement', () => {
const expression = group('or', action('ship', 'carthag', 5), action('move', 'arrakeen', 5));
const old = promise(expression);
const shipped = recordTravelShipment([old], 'respondent', 1, event('carthag', 4));
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 4));
const fresh = promise(expression, true, 'travel:2');
fresh.shipment.pending = false;
const otherPlayer = { ...promise(expression, true, 'travel:3'), player: 'other' };
const otherTurn = { ...promise(expression, true, 'travel:4'), turn: 2 };
const list = [moved[0], fresh, otherPlayer, otherTurn];
const next = recordTravelMovement(list, 'respondent', 1, event('arrakeen', 3));
assert.deepEqual(next[0].progress, [{ territory: 'arrakeen', maximum: 4 }]);
assert.deepEqual(next[1].progress, [{ territory: 'arrakeen', maximum: 3 }]);
assert.equal(next[2], otherPlayer);
assert.equal(next[3], otherTurn);
const repeatedShipment = recordTravelShipment(next, 'respondent', 1, event('carthag', 20));
assert.equal(repeatedShipment[0].shipment.event?.amount, 4);
assert.equal(repeatedShipment[1].shipment.event, null);
assert.deepEqual([...travelBranches(repeatedShipment[1])], [[leaf('move', 'arrakeen', 5)]]);
});
void test('only the largest physical movement group counts and event updates are immutable', () => {
const original = promise(action('move', 'carthag', 5));
const once = recordTravelMovement([original], 'respondent', 1, event('carthag', 3));
const twice = recordTravelMovement(once, 'respondent', 1, event('carthag', 3));
assert.equal(travelPromiseValue(twice[0]), false);
assert.equal(twice[0], once[0]);
assert.equal(twice[0].progress[0].maximum, 3);
assert.deepEqual(recordTravelMovement(twice, 'respondent', 1, event('carthag', 0)), twice);
assert.equal(recordTravelMovement(twice, 'respondent', 1, event('arrakeen', 20))[0], twice[0]);
const complete = recordTravelMovement(twice, 'respondent', 1, event('carthag', 5));
assert.equal(complete[0].fulfilled, true);
assert.deepEqual(original.progress, []);
assert.equal(once[0].progress[0].maximum, 3);
});
void test('frozen known and unknown facts constrain only the whole selected answer', () => {
for (const answer of [true, false]) {
for (const factAnswer of ['yes', 'no', 'unknown'] as const) {
for (const kind of ['and', 'or'] as const) {
const mixed = group(kind, fact(), action('move'));
const record = promise(mixed, answer);
record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer);
assert.deepEqual(record.mixed, mixed);
if (record.compiled.kind === 'constant') {
assert.equal(travelPromiseValue(record), record.compiled.value);
assert.deepEqual([...travelBranches(record)], record.compiled.value === answer ? [[]] : []);
if (record.compiled.value === answer) {
record.fulfilled = true;
validateTravelPromises([record], ['respondent', 'asker']);
}
} else {
assert.deepEqual([...travelBranches(record)], [[leaf('move', 'carthag', 3, answer)]]);
}
}
}
}
});
void test('turn closure rejects unmet Yes, fulfills compatible No and folds every closed opportunity', () => {
const yes = promise(group('or', action('ship'), action('move')));
const before = structuredClone(yes);
assert.throws(() => finishTravelTurn([yes], 'respondent', 1), TravelPromiseError);
assert.deepEqual(yes, before);
const no = promise(group('and', action('ship'), action('move')), false);
const closed = finishTravelTurn([no], 'respondent', 1);
assert.equal(closed[0].fulfilled, true);
assert.equal(closed[0].movementClosed, true);
assert.equal(closed[0].shipment.pending, false);
validateTravelPromises(closed, ['respondent', 'asker']);
assert.equal(recordTravelMovement(closed, 'respondent', 1, event())[0], closed[0]);
const projectedClosed = { ...yes, movementClosed: true, shipment: { pending: false, event: null } };
assert.deepEqual([...travelBranches(projectedClosed)], []);
assert.deepEqual([...travelBranches({ ...projectedClosed, answer: false })], [[]]);
const released = { ...yes, released: true };
const finishedRelease = finishTravelTurn([released], 'respondent', 1)[0];
assert.equal(finishedRelease.released, true);
assert.equal(finishedRelease.fulfilled, false);
assert.deepEqual([...travelBranches(finishedRelease)], [[]]);
});
void test('stored structure, whole-answer progress and strict native events reject malformed input', () => {
const original = promise(group('and', action('ship'), action('move')));
validateTravelPromises([original], ['respondent', 'asker']);
const corruptions: unknown[] = [
{ ...original, turn: 0 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined },
{ ...original, fulfilled: true, released: true },
{ ...original, shipment: { pending: true, event: event() } },
{ ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } },
{ ...original, progress: [{ territory: 'carthag', maximum: 21 }] },
{ ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] },
{ ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] },
{ ...original, compiled: { kind: 'constant', value: 'unknown' } },
{ ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 3 } } },
{ ...original, compiled: { kind: 'and', terms: [] } },
{ ...original, mixed: { kind: 'constant', value: true } },
{ ...original, mixed: fact() },
];
for (const corrupted of corruptions)
assert.throws(() => travelPromiseValue(corrupted as TravelPromise));
for (const record of [{ ...original, event: '' }, { ...original, asker: 'respondent' },
{ ...original, player: 'missing' }, { ...original, fulfilled: true }])
assert.throws(() => validateTravelPromises([record], ['respondent', 'asker']), TravelPromiseError);
assert.throws(() => validateTravelPromises([original, original], ['respondent', 'asker']), TravelPromiseError);
for (const invalid of [{ ...event(), amount: 21 }, { ...event(), amount: -1 },
{ ...event(), amount: 2.5 }, { ...event(), amount: '3' },
{ ...event(), territory: 'missing' }, { ...event(), elite: 1 }]) {
assert.throws(() => recordTravelMovement([original], 'respondent', 1, invalid as never), TravelPromiseError);
assert.throws(() => recordTravelShipment([original], 'respondent', 1, invalid as never), TravelPromiseError);
}
const violatedNo = { ...original, answer: false,
shipment: { pending: false, event: event() }, progress: [{ territory: 'carthag', maximum: 3 }] };
assert.throws(() => validateTravelPromises([violatedNo], ['respondent', 'asker']), TravelPromiseError);
});
+272
View File
@@ -0,0 +1,272 @@
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 { TravelClaimFields, travelClaimInputError } from '../components/travel-claim-fields';
import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TravelActionClaim } from '../game/travel-expression-promises';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<TravelActionClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const action = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [action('ship', 3), { kind: 'and', terms: [fact(), action('move', 4, 'arrakeen')] }] });
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-travel-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
(find(tree, 'id', `truth-travel-${path}-${field}`).props.onChange as
(event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
(find(tree, 'id', `truth-travel-${path}-minimum`).props.onChange as
(event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, value: TruthFact) {
const scope = find(tree, 'data-travel-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields);
assert.ok(fields);
(fields.props.onChange as (next: TruthFact) => void)(value);
}
/** Explicitly controlled projections verify UI callbacks. They do not claim
* native acquisition, force movement or promise fulfillment evidence. */
function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
const source = createGame('TRUTHTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = 5; game.active = 'p'; game.ready = [];
game.travelPromises = [{ event: 'controlled-travel-event', player: 'p', asker: 'a', turn: 2,
mixed: claim(), answer: true, fulfilled: false, released: false }];
game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next, travelPromiseEvent: 'controlled-travel-event' } };
game.truthTravelAnswers = null;
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => TravelClaimFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(TravelClaimFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => travelClaimInputError(value, game, 'p'),
};
}
function guide(props: Parameters<typeof TravelPromiseGuideButton>[0]) {
const button = TravelPromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(TravelPromises, { game, busy: false, act() {} }));
function answering(): GameView {
const game = projected();
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [],
question: { kind: 'travel', target: 'p', turn: 2, mixed: claim() } };
game.truthTravelAnswers = ['yes', 'no', 'unknown'];
return game;
}
void test('travel editor accepts action-only expressions and preserves shipment and movement alternatives through edits', () => {
const e = editor(action('ship', 3)); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'move'); select(e.tree(), '1', 'territory', 'arrakeen'); minimum(e.tree(), '1', 5);
assert.deepEqual(e.value, action('move', 5, 'arrakeen'));
select(e.tree(), '1', 'action', 'ship');
assert.deepEqual(e.value, action('ship', 5, 'arrakeen'));
click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'timing', 'action');
select(e.tree(), '1.2', 'action', 'move'); minimum(e.tree(), '1.2', 4);
assert.deepEqual(e.value, { kind: 'or', terms: [action('ship', 5, 'arrakeen'), action('move', 4)] });
assert.equal(e.error(), null);
const html = e.html();
assert.match(html, /Ship from reserves/); assert.match(html, /Make ground movement/);
assert.match(html, /single future original reserve shipment/); assert.match(html, /smaller moves never add together/);
assert.match(html, /No makes the whole expression false/); assert.match(html, /Original costs, movement rights/);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, action('move', 4));
select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'A pure-fact tree still needs a future action');
});
void test('travel editor retains sixteen optional-fact clauses and four grouping levels without truncation', () => {
const e = editor({ kind: 'or', terms: [action('ship', 3), action('move', 4)] });
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1'));
minimum(e.tree(), `1.${count}`, count);
if (count % 2) select(e.tree(), `1.${count}`, 'action', 'move');
}
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
const full = structuredClone(e.value), changes = e.changes;
for (const key of ['add-action-1', 'add-fact-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.deepEqual(e.value, full); assert.equal(e.changes, changes);
const deep = editor({ kind: 'and', terms: [fact(), action('move')] });
click(control(deep.tree(), 'or-1.2')); select(deep.tree(), '1.2', 'meaning', 'and');
click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
editFact(deep.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 3 }); assert.equal(deep.error(), null);
click(control(deep.tree(), 'remove-1.2.1.1.2'));
assert.match(deep.html(), /3 \/ 4 levels of grouping/);
});
void test('travel fields preserve invalid numeric drafts and reject disabled callbacks', () => {
for (const invalid of [NaN, 0, -1, 1.5, 21, Infinity]) {
const e = editor(action()); minimum(e.tree(), '1', invalid);
assert.ok(e.error());
if (Number.isNaN(invalid)) assert.match(e.html(), /value=""/);
minimum(e.tree(), '1', 20); assert.equal(e.error(), null);
}
const malformed = editor(action()); select(malformed.tree(), '1', 'territory', 'invented-place');
assert.ok(malformed.error()); assert.match(malformed.html(), /aria-invalid="true"/);
const frozen = editor(claim(), true), original = structuredClone(frozen.value);
select(frozen.tree(), '1', 'meaning', 'and'); minimum(frozen.tree(), '1.1', 7);
select(frozen.tree(), '1.1', 'action', 'move'); select(frozen.tree(), '1.1', 'territory', 'arrakeen');
editFact(frozen.tree(), '1.2.1', { kind: 'spice', compare: 'gte', value: 9 });
for (const key of ['and-1', 'add-action-1', 'remove-1.1', 'nested-or-1']) click(control(frozen.tree(), key));
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
});
void test('travel inputs use printed destinations and current-fact schema without private eligibility or custody reads', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const expression of [action('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), action('move')] } as Expression]) {
const e = editor(expression, false, game);
assert.equal(e.error(), null); assert.match(e.html(), /Carthag/); assert.match(e.html(), /Arrakeen/);
assert.match(e.html(), /Only the aggregate answer is shared/);
}
});
void test('travel history retains own original whole questions and terminal statuses without compiled branches or foreign history', () => {
const game = projected(); game.travelPromiseGuide = null;
game.travelPromises.push(
{ ...game.travelPromises[0], event: 'controlled-no', answer: false },
{ ...game.travelPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true },
{ ...game.travelPromises[0], event: 'controlled-release', released: true },
{ ...game.travelPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), action('ship', 19, 'sietch_tabr')] } },
);
for (const promise of game.travelPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /Turn 1/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
assert.match(html, /you hold Shield/); assert.match(html, / OR /); assert.match(html, / AND /);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /maximum physical forces in one actual future move, never the sum/);
assert.match(html, /Individual fact answers and any reduced future branch remain private/);
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
assert.equal(TravelPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
game.travelPromises = game.travelPromises.filter(promise => promise.fulfilled || promise.released);
assert.doesNotMatch(markup(game), /<button|Movement turn is still ahead/);
});
void test('travel guide forwards exact native shipment, movement and preparation packets without rewriting nested forces', () => {
const actions: Action[] = [
projected().travelPromiseGuide!.action,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
{ type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {},
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' },
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
];
for (const action of actions) {
const game = projected(action), sent: Action[] = [];
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action);
assert.match(markup(game), /data-testid="travel-promise-guide"/);
if (action.type === 'guildShip') assert.match(markup(game), /using Guild transport/);
}
const game = projected({ type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-card' });
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-search', cards: [{ id: 'controlled-recovered-card', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
game.nullentropy.search!.event = 'changed-search'; press(button); assert.equal(sent.length, 1);
});
void test('travel guide rejects stale view, actor, source, turn, phase, native ownership and quiet-window changes', () => {
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.travelPromiseGuide = null; },
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; },
game => { game.travelPromiseGuide!.event = 'changed'; },
game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; },
game => { (game.travelPromiseGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
game => { (game.travelPromiseGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
game => { game.travelPromises[0].event = 'expired'; }, game => { game.travelPromises[0].player = 'a'; },
game => { game.travelPromises[0].turn = 1; }, game => { game.travelPromises[0].fulfilled = true; },
game => { game.travelPromises[0].released = true; }, game => { game.travelPromises = []; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = answering().truthtrance; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
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 }; },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, act() { assert.fail('Busy guide cannot send'); } };
const busyButton = guide(busy); busy.busy = true; press(busyButton);
const replaced = { game: projected(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const replacedButton = guide(replaced); replaced.game = { ...replaced.game }; press(replacedButton);
});
void test('whole travel answers offer only native choices and forward guarded original answer packets', () => {
const game = answering(), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } };
for (const answer of game.truthTravelAnswers!) {
const tree = TravelAnswers(props); assert.ok(tree);
click(find(tree, 'data-travel-answer', answer));
}
assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer })));
const html = renderToStaticMarkup(createElement(TravelAnswers, props));
assert.match(html, /Answer Yes/); assert.match(html, /Answer No/); assert.match(html, /I don’t know yet/);
game.truthTravelAnswers = ['no'];
const restricted = renderToStaticMarkup(createElement(TravelAnswers, { game, busy: false, act() {} }));
assert.match(restricted, /Answer No/); assert.doesNotMatch(restricted, /Answer Yes|I don’t know yet/);
assert.equal(TravelAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
assert.equal(TravelAnswers({ game: { ...game, truthTravelAnswers: null }, busy: false, act() {} }), null);
});
void test('travel answer callbacks reject stale source, offered choices, view identity, turn and modal ownership', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.truthtrance = null; }, game => { game.truthtrance!.stage = 'ask'; },
game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; },
game => { game.truthtrance!.question!.target = 'a'; },
game => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; },
game => { game.truthtrance!.queue[0].card = 'another-card'; },
game => { game.truthTravelAnswers = ['yes']; }, game => { game.truthTravelAnswers!.splice(0); },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
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 }; },
];
for (const change of changes) {
const game = answering(), sent: Action[] = [], tree = TravelAnswers({ game, busy: false, act(value) { sent.push(value); } });
const button = find(tree, 'data-travel-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []);
}
const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } };
const button = find(TravelAnswers(busy), 'data-travel-answer', 'yes'); busy.busy = true; click(button);
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const original = find(TravelAnswers(replaced), 'data-travel-answer', 'yes'); replaced.game = { ...replaced.game }; click(original);
});
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
/** Controlled native state, conserving the original two physical Truths. This
* isolates the printed Fremen/Guild transport rule; no setup/earned-alliance
* history is claimed by this fixture. */
function controlled(paidByAlly = false) {
const game = createGame('TRAVELGUILDFREMEN', newPlayer('a', 'Asker', 'harkonnen'), false);
game.players.push(newPlayer('p', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'p',
order: ['p', 'a', 'g'], movementRemaining: ['p', 'a', 'g'], deck: baseDeck(), discard: [] });
for (const player of game.players) {
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
}
game.players[1].ally = 'g'; game.players[2].ally = 'p'; game.players[1].spice = paidByAlly ? 0 : 3;
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
game.deck = game.deck.filter(card => card.effect !== 'truthtrance'); game.players[0].hand = truths;
const inventory = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)].map(card => card.id).sort();
const original = inventory(game);
return { game: paidByAlly ? applyAction(game, 'g', { type: 'pledgeAid', amount: 3 }) : game,
truths: truths.map(card => card.id), inventory, original };
}
const ship = (minimum: number) => ({ kind: 'action' as const, action: { kind: 'ship' as const, territory: 'carthag', minimum } });
const move = { kind: 'action' as const, action: { kind: 'move' as const, territory: 'imperial_basin', minimum: 6 } };
function asked(game: Game, card: string, question: unknown) {
game = applyAction(game, 'a', { type: 'card', card });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'truthPass' });
}
return applyAction(game, 'a', { type: 'truthAsk', question });
}
void test('controlled Guild-allied Fremen travel uses the paid reserve transport outside ordinary reinforcement radius and then moves the physical group', () => {
for (const paidByAlly of [false, true]) {
const f = controlled(paidByAlly); let game = asked(f.game, f.truths[0], {
kind: 'travel', target: 'p', turn: 2, mixed: { kind: 'and', terms: [ship(6), move] },
});
assert.ok(viewGame(game, 'p').truthTravelAnswers?.includes('yes'));
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 }));
assert.deepEqual(game, before);
const guide = viewGame(game, 'p').travelPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'guildShip'); assert.equal(guide.action.from, 'reserves');
assert.equal(guide.action.territory, 'carthag'); assert.equal(guide.action.amount, 6);
assert.equal(guide.action.allyPayment, paidByAlly ? 3 : 0);
game = applyAction(game, 'p', guide.action);
assert.equal(game.players[1].spice, 0); assert.equal(game.players[1].reserves, 14);
if (paidByAlly) assert.equal(game.aid.g.amount, 0);
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false);
assert.deepEqual(game.travelPromises![0].progress, []);
const moving = viewGame(game, 'p').travelPromiseGuide; assert.ok(moving); assert.equal(moving.action.type, 'move');
game = applyAction(game, 'p', moving.action);
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true);
assert.equal(Object.entries(game.players[1].forces).filter(([key]) => key.startsWith('imperial_basin:')).reduce((sum, [, count]) => sum + count, 0), 6);
assert.deepEqual(f.inventory(game), f.original);
}
});
void test('controlled earlier travel No retains the legal Guild-allied Fremen route for a later scalar shipment Yes', () => {
const f = controlled(); let game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(7) });
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
game = asked(game, f.truths[1], { kind: 'shipment', target: 'p', territory: 'carthag', minimum: 6 });
assert.ok(viewGame(game, 'p').truthShipmentAnswers?.includes('yes'));
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
const action: Action = { type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6 };
game = applyAction(game, 'p', action); assert.equal(game.shipmentPromises?.[0].fulfilled, true);
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false);
game = applyAction(game, 'p', { type: 'endMovement' });
assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true); assert.deepEqual(f.inventory(game), f.original);
});
void test('controlled Fremen cannot use a free ordinary price to fund paid Guild transport', () => {
const f = controlled(); f.game.players[1].spice = 2;
const game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(6) });
assert.deepEqual(viewGame(game, 'p').truthTravelAnswers, ['no']);
});
+152
View File
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { truthTravelGroup as group, truthTravelMove as move, truthTravelShip as ship } from './fixture-truth-travel';
import {
actTruthTravelOrder as act, answerTruthTravelOrder as answer, askTruthTravelOrder as ask,
bindTruthTravelOrder as bind, createTruthTravelOrderFixture as fixture, declareTruthTravelOrder as declare,
settleTruthTravelOrder as settle, truthTravelOrderInventory as inventory, truthTravelOrderPlayer as player,
truthTravelOrderQuestion as travelQuestion, type TruthTravelOrderFixture, type TruthTravelOrderStep,
} from './fixture-truth-travel-order';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physicalTruthsOnce(game: Game, f: TruthTravelOrderFixture) {
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.equal(player(game, f.asker).hand.some(card => f.truths.includes(card.id)), false);
assert.deepEqual(inventory(game), f.originalCards);
}
function scalarQuestion(f: TruthTravelOrderFixture, kind: 'shipment' | 'movement' | 'movementExpression'): TruthQuestion {
if (kind === 'shipment') return f.shipmentQuestion;
if (kind === 'movement') return f.movementQuestion;
return { kind: 'movementExpression', target: f.target, turn: f.game.turn, claim: { kind: 'or', terms: [
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
{ kind: 'move', territory: 'carthag', minimum: 1 },
] } };
}
function nativeRow(game: Game, kind: 'shipment' | 'movement' | 'movementExpression') {
const row = kind === 'shipment' ? game.shipmentPromises?.[0]
: kind === 'movement' ? game.movementPromises?.[0] : game.movementExpressionPromises?.[0];
assert.ok(row); return row;
}
void test('native travel-order source genuinely owns both original opening Truthtrances before any question', () => {
const f = fixture();
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target); assert.equal(f.target, 'atreides');
assert.equal(f.sourceCount, f.actions.length); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCounts.inheritedBidding + f.sourceCounts.nativeContinuation, f.sourceCount);
assert.deepEqual(f.sourceActions.slice(0, f.sourceCounts.inheritedBidding), f.base.sourceActions);
assert.equal(f.continuation.length, f.sourceCounts.nativeContinuation);
assert.ok(f.continuation.some(step => step.action.type === 'passBid'));
assert.ok(f.continuation.some(step => step.action.type === 'ready'));
assert.ok(f.continuation.some(step => step.actor === f.asker && step.action.type === 'endMovement'));
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 10);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.travelPromiseOrder, undefined);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
});
for (const kind of ['shipment', 'movement', 'movementExpression'] as const)
for (const first of ['native', 'travel'] as const)
void test(`two native opening Truths retain ${kind} No and travel OR Yes in actual ${first}-first acceptance order`, () => {
const f = fixture(), actions: TruthTravelOrderStep[] = [];
const native = scalarQuestion(f, kind), travel = f.travelQuestion;
let game = f.game;
game = bind(f, game, 0, first === 'native' ? native : travel, first === 'native' ? 'no' : 'yes', actions).game;
const firstHistory = structuredClone(game.truthHistory!.at(-1));
game = bind(f, game, 1, first === 'native' ? travel : native, first === 'native' ? 'yes' : 'no', actions).game;
assert.deepEqual(game.travelPromiseOrder, first === 'native'
? [{ kind, index: 0 }, { kind: 'travel', index: 0 }]
: [{ kind: 'travel', index: 0 }, { kind, index: 0 }]);
assert.deepEqual(game.truthHistory!.at(-2), firstHistory);
assert.equal(nativeRow(game, kind).answer, false); assert.ok(!nativeRow(game, kind).released);
assert.equal(game.travelPromises![0].answer, true); assert.ok(!game.travelPromises![0].released);
assert.deepEqual(viewGame(game, f.observer).travelPromises, []);
if (kind === 'shipment') {
rejected(game, f.target, f.shipment);
const beforeSpice = player(game, f.target).spice;
game = settle(act(game, f.target, f.alternativeShipment, actions), actions);
assert.equal(player(game, f.target).spice, beforeSpice - 1);
assert.ok(!game.travelPromises![0].fulfilled);
game = settle(act(game, f.target, f.move, actions), actions);
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
} else {
rejected(game, f.target, f.move);
const beforeSpice = player(game, f.target).spice;
game = settle(act(game, f.target, f.shipment, actions), actions);
assert.equal(player(game, f.target).spice, beforeSpice - 2);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
if (kind === 'movement') game = settle(act(game, f.target, f.alternativeMove, actions), actions);
}
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!nativeRow(game, kind).released);
game = settle(act(game, f.target, f.end, actions), actions);
assert.equal(nativeRow(game, kind).fulfilled, true); assert.ok(!nativeRow(game, kind).released);
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
assert.equal(game.truthHistory!.length, 2); assert.equal(actions.filter(step => step.action.type === 'truthAsk').length, 2);
physicalTruthsOnce(game, f);
});
void test('two native opening Truths keep whole travel No with an earlier or later compatible scalar shipment Yes', () => {
const f = fixture(), travel = travelQuestion(f, group('and', ship(), move()));
for (const first of ['native', 'travel'] as const) {
const actions: TruthTravelOrderStep[] = [];
let game = bind(f, f.game, 0, first === 'native' ? f.shipmentQuestion : travel,
first === 'native' ? 'yes' : 'no', actions).game;
const earlier = structuredClone(game.truthHistory!.at(-1));
game = bind(f, game, 1, first === 'native' ? travel : f.shipmentQuestion,
first === 'native' ? 'no' : 'yes', actions).game;
assert.deepEqual(game.travelPromiseOrder, first === 'native'
? [{ kind: 'shipment', index: 0 }, { kind: 'travel', index: 0 }]
: [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]);
rejected(game, f.target, f.end);
game = settle(act(game, f.target, f.shipment, actions), actions);
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.ok(!game.travelPromises![0].fulfilled); rejected(game, f.target, f.move);
game = settle(act(game, f.target, f.end, actions), actions);
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released);
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.truthHistory!.at(-2), earlier);
physicalTruthsOnce(game, f);
}
});
void test('an incompatible second definite answer cannot erase the first native or travel receipt', () => {
const f = fixture();
// Earlier original shipment Yes forces the later incompatible-destination
// travel AND to No. The second native card remains present after rejection.
let game = bind(f, f.game, 0, f.shipmentQuestion, 'yes').game;
const earlierShipment = structuredClone(game.shipmentPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]);
const incompatibleTravel = travelQuestion(f, group('and', ship('arrakeen'), move()));
game = ask(declare(f, game, 1).game, f.asker, incompatibleTravel);
assert.deepEqual(viewGame(game, f.target).truthTravelAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.shipmentPromises![0], earlierShipment); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.shipment));
game = settle(act(game, f.target, f.end)); physicalTruthsOnce(game, f);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.travelPromises![0].fulfilled, true);
// Reverse actual acceptance: the original travel AND Yes requires Polar
// Sink. A later scalar cannot answer No and erase that earlier raw receipt.
game = bind(f, f.game, 0, travelQuestion(f, group('and', ship(), move())), 'yes').game;
const earlierTravel = structuredClone(game.travelPromises![0]);
game = ask(declare(f, game, 1).game, f.asker, f.shipmentQuestion);
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
assert.deepEqual(game.travelPromises![0], earlierTravel);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = answer(game, f.target, 'yes');
assert.deepEqual(game.travelPromiseOrder, [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]);
game = settle(act(game, f.target, f.shipment)); game = settle(act(game, f.target, f.move));
game = settle(act(game, f.target, f.end));
assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released);
physicalTruthsOnce(game, f);
});
+305
View File
@@ -0,0 +1,305 @@
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 { truthFactAnswer } from '../game/truthtrance';
import {
actTruthTravel as act, answerTruthTravel as answer, askTruthTravel as ask, bindTruthTravel as bind,
completeTruthTravel as complete, createTruthTravelFixture as fixture, declareTruthTravel as declare,
finishTruthTravel as finish, settleTruthTravel as settle, truthTravelClean as clean,
truthTravelFact as fact, truthTravelGroup as group, truthTravelInventory as inventory,
truthTravelMove as move, truthTravelPlayer as player, truthTravelPromise as promise,
truthTravelShip as ship, type TruthTravelExpression, type TruthTravelStep,
} from './fixture-truth-travel';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physicalTruthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
function nativeControls(game: Game) {
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
movementRemaining: game.movementRemaining, storm: game.storm,
response: game.response ?? null, decision: game.decision ?? null,
phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
}
const progress = (game: Game, target: string) => game.travelPromises!.find(row => row.player === target)!.progress;
void test('native travel source freezes only the original Basic prefix before its one paid reserve shipment', () => {
const f = fixture();
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target);
assert.equal(f.sourceCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(f.actions, f.activeSource!.actions.slice(0, f.sourceCounts.shipment!));
assert.deepEqual(f.activeSource!.actions[f.sourceActionCount], f.shipmentStep);
assert.equal(f.sourceCounts.inherited, f.sourceCount + 1);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
assert.equal(player(f.game, f.target).reserves, 10); assert.equal(player(f.game, f.target).shipped, false);
assert.equal(player(f.game, f.target).moved, 0); assert.equal(f.game.truthHistory?.length ?? 0, 0);
assert.equal(f.game.travelPromises?.length ?? 0, 0); assert.equal(f.game.truthtrance ?? null, null);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
assert.equal(f.game.players.flatMap(player => player.hand).filter(card => card.effect === 'truthtrance').length, 1,
'Only one original Truthtrance has actually been dealt in this source; an undrawn copy is not a second owned answer');
});
void test('native AND Yes requires the original shipment and a distinct actual ground move before own end', () => {
const f = fixture(), source = structuredClone(f.game), beforeSpice = player(f.game, f.target).spice;
const bound = bind(f); let game = bound.game;
assert.deepEqual(f.game, source); assert.deepEqual(nativeControls(game), nativeControls(source));
assert.deepEqual(promise(game, f.target).mixed, f.mixed); assert.deepEqual(progress(game, f.target), []);
physicalTruthOnce(game, f.truth); rejected(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost);
assert.equal(player(game, f.target).reserves, 9); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native OR Yes retains both legal manual routes without selecting a fixed branch', () => {
const f = fixture(), mixed = group('or', ship(), move()), bound = bind(f, 'yes', mixed).game;
for (const action of [f.shipmentAction, f.firstMove]) {
let game = settle(act(bound, f.target, action));
assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.equal(player(game, f.target).moved, action.type === 'move' ? 1 : 0);
if (action.type === 'move') assert.equal(player(game, f.target).reserves, 10);
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native No to AND retains either true component and settles only at the respondent actual end', () => {
const f = fixture(), bound = bind(f, 'no').game;
for (const action of [f.shipmentAction, f.firstMove]) {
let game = settle(act(bound, f.target, action));
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
if (action.type === 'ship') rejected(game, f.target, f.firstMove);
else rejected(game, f.target, f.shipmentAction);
game = finish(game, f.target);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native whole OR No forbids either matching action while preserving nonmatching shipment and movement', () => {
const f = fixture(), mixed = group('or', ship(), move()); let game = bind(f, 'no', mixed).game;
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 }));
assert.equal(player(game, f.target).forces['carthag:11'], 1); assert.deepEqual(progress(game, f.target), []);
game = settle(act(game, f.target, f.secondMove));
assert.equal(player(game, f.target).forces['carthag:11'], 2); assert.ok(!promise(game, f.target).fulfilled);
game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native shipment leaves describe the same one future original shipment rather than independent events', () => {
const f = fixture(), mixed = group('and', ship(), ship('arrakeen'));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no');
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(player(game, f.target).shipped, true); assert.equal(player(game, f.target).reserves, 9);
game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native shipment into a named destination contributes no physical ground movement progress', () => {
const f = fixture(), mixed = group('and', ship('imperial_basin'), move());
let game = bind(f, 'yes', mixed).game;
game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'imperial_basin', sector: 9 }));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native future shipment excludes the original shipment already completed before the answer', () => {
const f = fixture({ window: 'shipped' }); assert.equal(player(f.game, f.target).shipped, true);
assert.equal(f.sourceCount, f.sourceCounts.inherited);
const impossible = group('and', ship(), ship('polar_sink', 1));
const asked = ask(declare(f).game, f.asker, f.target, impossible);
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const mixed = group('or', ship(), move()); let game = bind(f, 'yes', mixed).game;
assert.ok(!promise(game, f.target).fulfilled); assert.deepEqual(progress(game, f.target), []);
rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove));
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native smaller repeated Hajr groups retain the maximum move rather than their accumulated force total', () => {
const f = fixture(), mixed = group('and', ship(), move('imperial_basin', 2));
let game = bind(f, 'no', mixed).game;
game = settle(act(game, f.target, f.shipmentAction));
game = settle(act(game, f.target, f.firstMove)); 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.equal(player(game, f.target).moved, 2);
assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!promise(game, f.target).fulfilled); game = finish(game, f.target);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native voluntary spending or consuming the last move cannot erase an accepted travel Yes', () => {
const f = fixture({ hajr: false }); let game = bind(f).game;
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 });
game = settle(act(game, f.target, f.shipmentAction));
rejected(game, f.target, f.secondMove); rejected(game, f.target, f.currentEnd);
assert.ok(!promise(game, f.target).released); game = settle(act(game, f.target, f.firstMove));
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native nonqualifying move remains legal when the genuinely held Hajr preserves the missing route', () => {
const f = fixture(); let game = bind(f).game;
game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.secondMove));
assert.ok(!promise(game, f.target).fulfilled);
const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
game = complete(game, f.target); assert.equal(promise(game, f.target).fulfilled, true);
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 mixed travel freezes the actual current-spice fact before an ordinary paid bribe', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), ship(), move());
const beforeSpice = player(f.game, f.target).spice, beforeBribes = player(f.game, f.asker).bribes;
assert.ok(beforeSpice >= 5); assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.target).spice, beforeSpice - 3);
assert.equal(player(game, f.asker).bribes, beforeBribes + 3);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).spice, beforeSpice - 3 - f.shipmentCost);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native true held-card OR travel and false held-card AND travel settle without unnecessary actions', () => {
const f = fixture();
for (const [mixed, answerValue] of [
[group('or', fact(f.hajrFact), ship(), move()), 'yes'],
[group('and', fact(f.projectileFact), ship(), move()), 'no'],
] as const) {
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, [answerValue]);
const game = answer(asked, f.target, answerValue);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
assert.equal(player(game, f.target).shipped, false); assert.equal(player(game, f.target).moved, 0);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
}
});
void test('native waiting travel answer preserves the current owner queue until the respondent real shipment and move', () => {
const f = fixture({ window: 'waiting' }); assert.equal(f.game.active, f.asker);
assert.equal(f.sourceCount, f.actions.length); assert.equal(player(f.game, f.target).reserves, 20);
assert.deepEqual(player(f.game, f.target).forces, {});
const before = nativeControls(f.game); let game = bind(f).game;
assert.deepEqual(nativeControls(game), before); assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
rejected(game, f.target, f.currentEnd);
game = settle(act(game, f.asker, f.currentEnd));
assert.equal(game.active, f.target); assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(progress(game, f.target), []);
const beforeSpice = player(game, f.target).spice;
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost);
assert.equal(player(game, f.target).reserves, 19); assert.equal(player(game, f.target).forces['arrakeen:10'], 1);
assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled);
game = settle(act(game, f.target, f.firstMove)); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).forces['carthag:11'], 1);
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native travel authoring excludes earlier phases and a respondent whose own movement interval has closed', () => {
const charity = fixture({ window: 'charity' }); assert.equal(charity.game.phase, 2);
const declaredCharity = declare(charity);
assert.ok(!viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target));
rejected(declaredCharity.game, charity.asker, charity.questionAction);
assert.equal(declaredCharity.game.discard.filter(card => card.id === charity.truth).length, 0);
const f = fixture(); let game = finish(f.game, f.target);
assert.equal(game.active, f.asker); assert.ok(!game.movementRemaining?.includes(f.target));
game = declare(f, game).game;
assert.ok(!viewGame(game, f.asker).truthTravelTargets.includes(f.target)); rejected(game, f.asker, f.questionAction);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truth)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native travel projection keeps only the owner raw aggregate while exact next-step guides retain event authority', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.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.mixed);
assert.equal(viewGame(asked, f.asker).truthTravelAnswers, null);
assert.ok(viewGame(asked, f.target).truthTravelAnswers?.includes('yes'));
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'yes');
const own = viewGame(game, f.target), other = viewGame(game, f.asker), row = promise(game, f.target);
const required = ['event', 'player', 'asker', 'turn', 'mixed', 'answer'];
assert.ok(required.every(key => Object.keys(row).includes(key)));
assert.ok(Object.keys(row).every(key => [...required, 'fulfilled', 'released'].includes(key)));
assert.deepEqual(other.travelPromises, []); assert.equal(other.travelPromiseGuide, null);
assert.deepEqual(own.truthHistory.at(-1)?.question, f.question);
assert.deepEqual(own.travelPromises, [row]);
const guide = own.travelPromiseGuide; assert.ok(guide);
assert.equal(guide.event, row.event); assert.equal(guide.action.travelPromiseEvent, row.event);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, travelPromiseEvent: 'stale-native-travel-event' });
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
assert.equal(promise(game, f.target).event, row.event); rejected(game, f.target, wire);
game = complete(game, f.target); rejected(game, f.target, guide.action);
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('all four minimal profiles answer the raw travel question and perform its exact native completion', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTravelStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(answering), answerAction = botActions(answering).find(action => action.type === 'truthAnswer');
assert.ok(answerAction, difficulty); assert.deepEqual(answering, before); assert.equal(answerAction.answer, 'yes', difficulty);
assert.ok(answering.truthTravelAnswers?.includes('yes')); game = act(game, f.target, answerAction, actions);
for (let count = 0; count < 12 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.travelPromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.travelPromiseEvent === guide.event);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action, actions), actions);
}
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
assert.ok(actions.some(step => step.action.type === 'ship'), difficulty);
assert.ok(actions.some(step => step.action.type === 'move'), difficulty);
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).reserves, 9);
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards);
}
});
void test('native raw travel validates every leaf without consuming the physical card at a rejected Ask', () => {
const f = fixture(), declared = declare(f), before = structuredClone(declared.game);
const invalid: TruthTravelExpression[] = [
group('or', fact(f.hajrFact), ship('not-a-territory'), move()),
group('and', ship(), move('imperial_basin', 0)),
group('and', ship('polar_sink', 21), move()),
];
for (const mixed of invalid) rejected(declared.game, f.asker, { type: 'truthAsk', question: {
kind: 'travel', target: f.target, turn: f.game.turn, mixed,
} });
assert.deepEqual(declared.game, before); assert.ok(clean(f.game));
assert.ok(player(declared.game, f.asker).hand.some(card => card.id === f.truth));
assert.deepEqual(inventory(declared.game), f.originalCards);
});