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

+7
View File
@@ -1374,6 +1374,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'travel') {
const answer = g.truthTravelAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'mixedTraitorCall') {
const answer = g.truthMixedTraitorAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
@@ -4259,6 +4263,9 @@ export function botActions(g: GameView): Action[] {
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.movementExpressionPromiseGuide.action];
if (g.travelPromiseGuide && g.status === 'playing' && g.phase === 5 && g.active === g.me &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.travelPromiseGuide.action];
if (g.movementPromisePolicy && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return g.movementPromisePolicy;
+177 -3
View File
@@ -1,3 +1,5 @@
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
@@ -2162,6 +2164,9 @@ export type Game = {
};
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
travelPromises?: TravelPromise[];
/** Accepted order shared by phase-five travel, shipment and movement answers. */
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel'; index: number }[];
mixedTraitorQuestions?: (MixedTraitorClaim & {
event: string; turn: number; player: string; asker: string; answer: boolean;
binding: boolean; fulfilled: boolean; released: boolean;
@@ -6942,6 +6947,8 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(g.travelPromiseOrder ? { travelPromiseOrder: g.travelPromiseOrder, travelPromises: g.travelPromises } : {}),
...(next.record.question.kind === 'travel' ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}),
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
@@ -18649,7 +18656,141 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
throw error;
}
}
function travelRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
}
function travelTargetAvailable(g: Game, actor: string): boolean {
return g.phase === 5 && movementPromiseTargetAvailable(g, actor);
}
function appendTravelOrder(g: Game, kind: NonNullable<Game['travelPromiseOrder']>[number]['kind'], index: number) {
g.travelPromiseOrder?.push({ kind, index });
}
function initializeTravelOrder(g: Game) {
if (g.travelPromiseOrder) return;
const movement = [
...(g.movementPromises ?? []).map((promise, index) => ({ kind: 'movement' as const, index, event: promise.event })),
...(g.movementExpressionPromises ?? []).map((promise, index) => ({ kind: 'movementExpression' as const, index, event: promise.event })),
];
const sequence = g.movementPromiseSequence ?? [];
const order = (event: string | undefined) => event === undefined ? -1 : sequence.indexOf(event);
movement.sort((a, b) => order(a.event) - order(b.event));
g.travelPromiseOrder = [
...(g.shipmentPromises ?? []).map((_, index) => ({ kind: 'shipment' as const, index })),
...movement.map(({ kind, index }) => ({ kind, index })),
...(g.travelPromises ?? []).map((_, index) => ({ kind: 'travel' as const, index })),
];
}
/** Only detached witness states receive signed native constraints. No selected
* DNF route is installed into the real shipment or movement ledgers. */
function travelCompletion(source: Game, actor: string,
movements = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn),
shipments = liveShipmentPromises(source.shipmentPromises ?? [], actor, source.turn)): MovementCompletion | null {
const travel = liveTravelPromises(source.travelPromises ?? [], actor, source.turn);
const search = (index: number, leaves: TravelBranchLeaf[]): MovementCompletion | null => {
if (index < travel.length) {
for (const branch of travelBranches(travel[index])) {
const result = search(index + 1, [...leaves, ...branch]);
if (result) return result;
}
return null;
}
const trial = { ...source, travelPromises: undefined, travelPromiseOrder: undefined,
shipmentPromises: [...shipments], movementPromises: [...movements], movementExpressionPromises: [...expressions] };
for (const leaf of leaves) {
const promise = { territory: leaf.territory, minimum: leaf.minimum, turn: source.turn,
player: actor, asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
if (leaf.kind === 'ship') trial.shipmentPromises.push(promise);
else trial.movementPromises.push(promise);
}
return groundMovementCompletionNative(trial, actor, trial.movementPromises, trial.movementExpressionPromises);
};
return travelRule(() => search(0, []));
}
function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !travelTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant' && compiled.value !== answer) continue;
const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false };
const trial = { ...g, travelPromises: [...(g.travelPromises ?? []), promise] };
if (travelCompletion(trial, p.id) && bribeCompatibleLaterPromises(trial, p, {})) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) {
requireRule(travelTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole travel answer compatible with the original shipment and ground movements.');
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
initializeTravelOrder(g);
(g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer,
compiled, shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false });
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
function reconcileTravelPromises(g: Game, actor?: string, action?: Action) {
if (!g.travelPromises?.length) return;
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
if (g.phase !== 5) return;
initializeTravelOrder(g);
for (const p of g.players) {
if (!liveTravelPromises(g.travelPromises, p.id, g.turn).length) continue;
const voluntary = actor === p.id && action?.type !== 'passResponse';
if (g.truthtrance || g.phaseOpening || g.pendingFremenMove?.order.player === p.id ||
g.pendingIxMove?.player === p.id || g.pendingChoamMove?.player === p.id ||
g.pendingAdvisorMatching?.intent.kind === 'movement' && g.pendingAdvisorMatching.intent.order.player === p.id ||
g.response?.kind === 'advisorFlip' && g.response.owner === p.id ||
g.pendingAutomaticAdvisorFlips?.some(source => source.owner === p.id ||
source.producer === 'groundMovement' && source.mover === p.id)) continue;
if (!voluntary && (g.response || g.decision || g.pendingAmbassador || g.pendingTerrorEntry ||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard)) continue;
const shipments: ShipmentPromise[] = [], movements: MovementPromise[] = [], expressions: MovementExpressionPromise[] = [], travel: TravelPromise[] = [];
for (const entry of g.travelPromiseOrder!) {
const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises
: entry.kind === 'movementExpression' ? g.movementExpressionPromises : g.travelPromises;
const promise = ledger?.[entry.index];
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
if (entry.kind === 'shipment') shipments.push(promise as ShipmentPromise);
else if (entry.kind === 'movement') movements.push(promise as MovementPromise);
else if (entry.kind === 'movementExpression') expressions.push(promise as MovementExpressionPromise);
else travel.push(promise as TravelPromise);
const trial = { ...g, shipmentPromises: shipments, movementPromises: movements,
movementExpressionPromises: expressions, travelPromises: travel };
if (travelCompletion(trial, p.id)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted travel commitment.');
promise.released = true;
log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
}
}
}
function projectedTravelPromises(g: Game, actor: string) {
return (g.travelPromises ?? []).filter(promise => promise.player === actor).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn, mixed: structuredClone(promise.mixed),
answer: promise.answer, fulfilled: promise.fulfilled, released: promise.released,
}));
}
function projectedTravelGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)[0];
const policy = promise && g.phase === 5 && g.active === player.id ? projectedGroundMovementPolicy(g, player) : null;
const action = policy?.[0];
return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event } } : null;
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
return liveTravelPromises(source.travelPromises ?? [], actor, source.turn).length
? travelCompletion(source, actor, promises, expressions) : groundMovementCompletionNative(source, actor, promises, expressions);
}
function groundMovementCompletionNative(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
@@ -18728,8 +18869,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
}
yield* movementShipmentPreparations(state, owner, (source, actor, action) => {
const payer = getPlayer(source, actor), share = Number(action.allyPayment);
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) },
territory(String(action.territory)).type, Number(action.amount));
const destinationType = territory(String(action.territory)).type;
const cost = action.type === 'guildShip'
? guildShipmentCost(destinationType, Number(action.amount), source.karamaShipping?.player === actor ||
!(source.guildRateBlocked?.turn === source.turn && source.guildRateBlocked.player === actor))
: reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) },
destinationType, Number(action.amount));
if (share > cost || share > (aidFor(source, payer)?.amount ?? 0) || cost - share > payer.spice) return null;
try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation'), actor); }
catch (error) { if (error instanceof RuleError) return null; throw error; }
@@ -18789,7 +18934,8 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
function hasMovementPromises(g: Game, player: string, positive = false): boolean {
return [...liveMovementPromises(g.movementPromises ?? [], player, g.turn),
...liveMovementExpressionPromises(g.movementExpressionPromises ?? [], player, g.turn)]
.some(promise => !positive || promise.answer);
.some(promise => !positive || promise.answer) ||
liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer);
}
function movementExpressionIntegrity(g: Game) {
try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); }
@@ -18852,6 +18998,7 @@ function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boo
const promise: MovementPromise = { ...claim, event: crypto.randomUUID(), turn: g.turn, player: p.id, asker, answer };
(g.movementPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event!);
appendTravelOrder(g, 'movement', g.movementPromises!.length - 1);
}
function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return [];
@@ -18876,6 +19023,7 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi
player: p.id, asker, answer, progress: [] };
(g.movementExpressionPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event);
appendTravelOrder(g, 'movementExpression', g.movementExpressionPromises!.length - 1);
}
function compileMixedMovement(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
@@ -18952,6 +19100,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn), event = groundMovementEvent(move);
requireRule(movementEventAllowed(live, event), 'This movement would break your accepted No answer.');
const projected = projectGroundMovement(g, move);
if (projected.travelPromises) projected.travelPromises = travelRule(() =>
recordTravelMovement(projected.travelPromises!, p.id, g.turn, event));
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
try { projected.movementExpressionPromises = recordMovementExpressionEvent(g.movementExpressionPromises ?? [], p.id, g.turn, event); }
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
@@ -18960,6 +19110,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
}
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
if (move.source === 'ambassador') return;
if (g.travelPromises) g.travelPromises = travelRule(() =>
recordTravelMovement(g.travelPromises!, p.id, g.turn, groundMovementEvent(move)));
const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? [];
if (g.movementPromises) {
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
@@ -19430,6 +19582,7 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
if (g.travelPromises) g.travelPromises = travelRule(() => finishTravelTurn(g.travelPromises!, id, g.turn));
try {
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
for (const promise of liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)) {
@@ -27204,6 +27357,8 @@ function findShipmentCompletion(
state.turn,
),
): ShipmentCompletion | null {
if (liveTravelPromises(state.travelPromises ?? [], owner.id, state.turn).length)
return travelCompletion(state, owner.id, undefined, undefined, promises);
if (
!shipmentPromiseTurnAvailable(state, owner.id, shipmentAvailable(state,owner))
)
@@ -27442,6 +27597,7 @@ function bindShipmentTruth(
...(mixed ? {mixed} : {}),
answer,
});
appendTravelOrder(g, 'shipment', g.shipmentPromises!.length - 1);
}
function checkShipmentPromises(
g: Game,
@@ -27461,6 +27617,8 @@ function finishShipmentPromises(
shipment: { territory: string; amount: number } | null,
) {
checkShipmentPromises(g, p, shipment);
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
for (const promise of liveShipmentPromises(
g.shipmentPromises ?? [],
p.id,
@@ -27481,6 +27639,7 @@ function ownGuildStopPromiseBlock(g: Game, p: Player): string | null {
* the respondent's promise cannot veto the opponent's printed power. */
function settleStoppedShipmentPromises(g: Game, p: Player, actor: string) {
if (actor === p.id) checkShipmentPromises(g, p, null);
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
for (const promise of liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn)) {
if (matchesShipment(promise, null) === promise.answer) promise.fulfilled = true;
else {
@@ -36157,6 +36316,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This alliance commitment guide is stale or belongs to another Nexus choice.');
}
if (action.travelPromiseEvent !== undefined) {
const guide = projectedTravelGuide(state, getPlayer(state, id));
requireRule(guide && movementGuideActionMatches(action, guide.action),
'This travel guide is stale or belongs to another opportunity.');
}
if (action.movementExpressionPromiseEvent !== undefined) {
const guide = projectedMovementExpressionGuide(state, getPlayer(state, id));
requireRule(guide && guide.phase === state.phase && movementGuideActionMatches(action, guide.action),
@@ -36187,6 +36351,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
'This revival commitment guide is stale or belongs to another native opportunity.');
}
const g = applyActionInner(state, id, action);
reconcileTravelPromises(g, id, action);
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
@@ -36513,6 +36678,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
reconcileTravelPromises(g);
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
@@ -36617,6 +36783,7 @@ export function normalizeAutomaticGame(state: Game): Game {
treacheryDiscardIntegrity(g);
return g;
}
reconcileTravelPromises(g);
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
@@ -36896,6 +37063,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
travelAnswers: travelTruthAnswers,
bindTravel: bindTravelTruth,
mixedTraitorAnswers: mixedTraitorTruthAnswers,
bindMixedTraitor: bindMixedTraitorTruth,
traitorAnswers: traitorTruthAnswers,
@@ -40687,6 +40856,11 @@ export function viewGame(state: Game, id: string) {
...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id
? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}),
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthTravelTargets: g.players.filter(p => travelTargetAvailable(g, p.id)).map(p => p.id),
truthTravelAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'travel' &&
g.truthtrance.question.target === id ? travelTruthAnswers(g, me, g.truthtrance.question) : null,
travelPromises: projectedTravelPromises(g, id),
travelPromiseGuide: projectedTravelGuide(g, me),
truthMixedTraitorTargets: g.players.filter(p => { const context = traitorTruthContext(g, p.id); return context && context.submitted === undefined; }).map(p => p.id),
truthMixedTraitorAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'mixedTraitorCall' &&
g.truthtrance.question.target === id ? mixedTraitorTruthAnswers(g, me, g.truthtrance.question) : null,
+7 -3
View File
@@ -17,6 +17,8 @@ export type MixedActionExpressionLimits = {
maxLeaves?: number;
maxNodes?: number;
maxTerms?: number;
/** Travel expressions may consist entirely of future actions. */
requireFact?: boolean;
};
export class MixedActionQuestionError extends Error {}
@@ -65,7 +67,8 @@ export function parseMixedActionExpression<A>(
check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 &&
Number.isSafeInteger(maxLeaves) && maxLeaves >= 1 &&
(limits.maxNodes === undefined || Number.isSafeInteger(limits.maxNodes) && limits.maxNodes >= 1) &&
(limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2),
(limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2) &&
(limits.requireFact === undefined || typeof limits.requireFact === 'boolean'),
'Choose valid logical depth and clause limits.');
let leaves = 0;
let nodes = 0;
@@ -115,8 +118,9 @@ export function parseMixedActionExpression<A>(
return { kind: node.kind, terms: node.terms.map(term => parse(term, depth + 1)) };
};
const mixed = parse(value, 0);
check(hasFact && hasAction,
'A mixed question must include a current fact and a future-action statement.');
check(hasAction && (limits.requireFact === false || hasFact), limits.requireFact === false
? 'An action question must include a future-action statement.'
: 'A mixed question must include a current fact and a future-action statement.');
return mixed;
}
+8 -1
View File
@@ -2,13 +2,15 @@ import { gameDistance, gameTerritories, splitLocation, territory } from './board
import type { Action, Game } from './engine';
import { liveMovementPromises } from './movement-promises';
/** Ordinary physical reserve shipments, not an AI shortlist. The authoritative
/** Physical reserve shipments, not an AI shortlist. The authoritative
* dispatcher supplies cost, funding, route and arrival legality. */
export function* movementShipmentPreparations(game: Game, actor: string,
project: (game: Game, actor: string, action: Action) => Game | null,
): Generator<{ game: Game; actions: Action[] }> {
const player = game.players.find(player => player.id === actor);
if (!player || player.shipped || game.pendingNullentropy) return;
const guildReserve = player.faction === 'fremen' && game.players.some(ally =>
ally.faction === 'guild' && ally.id === player.ally && ally.ally === actor);
const present = (territory: string) => Object.entries(player.forces)
.filter(([key]) => splitLocation(key).territory === territory)
.reduce((sum, [, amount]) => sum + amount, 0);
@@ -43,6 +45,11 @@ export function* movementShipmentPreparations(game: Game, actor: string,
const action: Action = { type: 'ship', territory: destination.id, sector, amount, elite, allyPayment };
const next = project(game, actor, action);
if (next) yield { game: next, actions: [action] };
if (guildReserve) {
const transport: Action = { ...action, type: 'guildShip', from: 'reserves' };
const transported = project(game, actor, transport);
if (transported) yield { game: transported, actions: [transport] };
}
}
}
}
+23
View File
@@ -3185,6 +3185,28 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
id: 'truthtrance-travel',
title: 'Truthtrance: combined shipment and ground movements',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine future native reserve shipment and physical ground-movement conditions in one whole answer, optionally with current facts.',
steps: [
'During Shipment and Movement, choose Combine this turn’s shipment and ground movements and an active or unused waiting native respondent. Full AND/OR groups can include ship, move and optional current-fact clauses. Earlier-phase and other cross-action questions remain unfinished.',
'Each shipment condition refers to the same one future original reserve shipment after answering. A used or skipped shipment does not count. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.',
'Each movement minimum requires one actual future physical ground group to that destination. Smaller moves never add together. Original owned Hajr, Ornithopter, card preparation, types, routes and movement allowances stay authoritative.',
'Yes requires the whole expression; OR preserves legal alternatives. No negates the whole question and stays binding through your actual movement end. A qualifying Yes completes immediately. Original legal actions must leave a possible completion; an opposing completed effect may release impossibility.',
'Current facts freeze at the actual answer and impose no promise to keep the same holdings. Unknown facts require an aggregate guaranteed without guessing; constant answers need no future action. Separate frozen values and private branch choices are not published.',
'Review the exact native next step or use another legal manual route. Private own history persists after the interval. Four minimal profiles participate legally; complete modes, broader compositions and final deployed acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-shipment', 'truthtrance-movement-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Private signed branches share the original shipment and movement completion search, recording distinct native physical receipts and accepted-order release.', evidence: ['game/travel-expression-promises.ts', 'game/mixed-action-question.ts', 'game/movement-shipment-preparation.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/travel-expression-promises.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Single-shipment, physical group maxima, whole No, snapshot, native order and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TRAVEL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts'] },
],
},
{
id: 'truthtrance-mixed-traitor',
title: 'Truthtrance: current facts and this Traitor declaration',
@@ -3793,6 +3815,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-travel',
'truthtrance-movement-expression',
'truthtrance-mixed-movement',
'truthtrance-bid',
+311
View File
@@ -0,0 +1,311 @@
import { TERRITORIES } from './board';
import {
parseMixedActionExpression,
type CompiledMixedAction,
type MixedActionExpression,
} from './mixed-action-question';
import { MovementClaimError, parseMovementClaim } from './movement-promises';
import { parseShipmentClaim, ShipmentClaimError } from './shipment-promises';
import type { TruthFact } from './truthtrance';
export const TRAVEL_MAX_DEPTH = 4;
export const TRAVEL_MAX_LEAVES = 16;
/** Shipment leaves all describe one original future reserve-shipment event. */
export type TravelActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number };
export type TravelClaim = { turn: number; mixed: MixedActionExpression<TravelActionClaim> };
export type TravelEvent = { territory: string; amount: number };
export type TravelBranchLeaf = TravelActionClaim & { answer: boolean };
export type TravelPromise = TravelClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
/** Private residual: facts are frozen at this answer, never reevaluated. */
compiled: CompiledMixedAction<TravelActionClaim>;
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest actual physical ground group per destination after this answer. */
progress: { territory: string; maximum: number }[];
movementClosed: boolean;
fulfilled: boolean;
released: boolean;
};
export class TravelPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'TravelPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new TravelPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const turnNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
const physical = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Only original kind/minimum/destination fields cross the question boundary. */
export function parseTravelActionClaim(value: unknown): TravelActionClaim {
check(object(value) && keys(value, 'kind,minimum,territory') &&
(value.kind === 'ship' || value.kind === 'move'),
'Choose one reserve shipment or physical ground movement, with only its minimum and printed destination.');
try {
const claim = value.kind === 'ship' ? parseShipmentClaim(value) : parseMovementClaim(value);
// parseShipmentClaim receives a flat leaf above, never its expression form.
return { kind: value.kind, territory: claim.territory!, minimum: claim.minimum! };
} catch (error) {
if (error instanceof ShipmentClaimError || error instanceof MovementClaimError)
throw new TravelPromiseError(error.message);
throw error;
}
}
/** Fact source authority remains with Truthtrance's game-aware fact decoder.
* Here the raw tree is retained and bounded, without reading private answers. */
function storedFact(value: unknown, depth = 0): TruthFact {
check(object(value) && identifier(value.kind) && depth <= TRAVEL_MAX_DEPTH &&
value.kind !== 'constant' && value.kind !== 'action' && value.kind !== 'fact',
'Retain the original current-fact statement.');
if (value.kind === 'and' || value.kind === 'or') {
check(keys(value, 'kind,terms') && Array.isArray(value.terms) &&
value.terms.length >= 2 && value.terms.length <= TRAVEL_MAX_LEAVES,
'Retain a bounded original current-fact group.');
return { kind: value.kind, terms: value.terms.map(term => storedFact(term, depth + 1)) };
}
return value as unknown as TruthFact;
}
function actionKey(action: TravelActionClaim): string {
return `${action.kind}:${action.territory}:${action.minimum}`;
}
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TravelActionClaim> {
let leaves = 0;
const read = (input: unknown, depth: number): CompiledMixedAction<TravelActionClaim> => {
check(object(input) && depth <= TRAVEL_MAX_DEPTH,
'Retain the bounded private answer-time travel expression.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= TRAVEL_MAX_LEAVES,
'Retain the private answer-time AND/OR travel groups.');
return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) };
}
check(++leaves <= TRAVEL_MAX_LEAVES, 'Retain at most sixteen private travel clauses.');
if (input.kind === 'constant') {
check(keys(input, 'kind,value') && typeof input.value === 'boolean',
'Retain a definite private frozen fact answer.');
return { kind: 'constant', value: input.value };
}
check(input.kind === 'action' && keys(input, 'action,kind'),
'Retain a private frozen fact or original travel action.');
const action = parseTravelActionClaim(input.action);
check(originals.has(actionKey(action)), 'A private travel clause must come from the original question.');
return { kind: 'action', action };
};
return read(value, 0);
}
function validateEvent(event: unknown): asserts event is TravelEvent {
check(object(event) && keys(event, 'amount,territory') && printedTerritory(event.territory) && physical(event.amount),
'Retain the printed destination and zero to twenty actual physical forces.');
}
function readPromise(promise: TravelPromise): CompiledMixedAction<TravelActionClaim> {
check(object(promise) && turnNumber(promise.turn), 'Retain the travel commitment and its current turn.');
const mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTravelActionClaim, { requireFact: false });
const originals = new Set<string>();
const destinations = new Set<string>();
const visit = (node: MixedActionExpression<TravelActionClaim>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
const compiled = readCompiled(promise.compiled, originals);
check(typeof promise.answer === 'boolean' && typeof promise.fulfilled === 'boolean' &&
typeof promise.released === 'boolean' && typeof promise.movementClosed === 'boolean' &&
!(promise.fulfilled && promise.released),
'Retain a definite whole travel answer and lifecycle.');
check(object(promise.shipment) && keys(promise.shipment, 'event,pending') &&
typeof promise.shipment.pending === 'boolean' &&
(!promise.shipment.pending || promise.shipment.event === null) &&
(!promise.movementClosed || !promise.shipment.pending),
'Retain this answer’s single pending or received reserve shipment.');
if (promise.shipment.event !== null) validateEvent(promise.shipment.event);
check(Array.isArray(promise.progress), 'Retain this answer’s physical ground-movement maxima.');
const seen = new Set<string>();
for (const entry of promise.progress) {
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
destinations.has(entry.territory) && physical(entry.maximum) && !seen.has(entry.territory),
'Retain unique named movement destinations and actual physical group maxima.');
seen.add(entry.territory);
}
return compiled;
}
function actionValue(action: TravelActionClaim, promise: TravelPromise): boolean {
if (action.kind === 'ship') return promise.shipment.event !== null &&
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum;
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
}
function evaluate(node: CompiledMixedAction<TravelActionClaim>, promise: TravelPromise): boolean {
if (node.kind === 'constant') return node.value;
if (node.kind === 'action') return actionValue(node.action, promise);
return node.kind === 'and' ? node.terms.every(term => evaluate(term, promise)) :
node.terms.some(term => evaluate(term, promise));
}
/** Current value uses this record's actual future events, never projected sums. */
export function travelPromiseValue(promise: TravelPromise): boolean {
return evaluate(readPromise(promise), promise);
}
export function liveTravelPromises<T extends TravelPromise>(promises: readonly T[], player: string, turn: number): T[] {
check(Array.isArray(promises) && promises.every((promise): boolean => object(promise)) && identifier(player) && turnNumber(turn),
'Choose the travel respondent and turn.');
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function mergeBranches(left: TravelBranchLeaf[], right: TravelBranchLeaf[]): TravelBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory &&
other.answer === leaf.answer);
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const yes = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && other.answer);
const no = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
// One reserve shipment cannot have two different printed destinations.
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
}
return merged;
}
/** Lazy signed DNF. A whole No negates the whole tree; no branch is selected
* at answer time. Native quotes still decide physical feasibility and routes. */
export function* travelBranches(promise: TravelPromise): Generator<TravelBranchLeaf[]> {
const compiled = readPromise(promise);
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: CompiledMixedAction<TravelActionClaim>, answer: boolean): Generator<TravelBranchLeaf[]> {
if (node.kind === 'constant') { if (node.value === answer) yield []; return; }
if (node.kind === 'action') {
const achieved = actionValue(node.action, promise);
const closed = node.action.kind === 'ship' ? !promise.shipment.pending : promise.movementClosed;
if (achieved || closed) { if (achieved === answer) yield []; return; }
yield [{ ...node.action, answer }];
return;
}
const conjunction = node.kind === 'and' ? answer : !answer;
if (!conjunction) {
for (const term of node.terms) yield* alternatives(term, answer);
return;
}
const terms = node.terms;
function* combine(index: number, prefix: TravelBranchLeaf[]): Generator<TravelBranchLeaf[]> {
if (index === terms.length) { yield prefix; return; }
for (const suffix of alternatives(terms[index], answer)) {
const merged = mergeBranches(prefix, suffix);
if (merged) yield* combine(index + 1, merged);
}
}
yield* combine(0, []);
}
yield* alternatives(compiled, promise.answer);
}
function settle<T extends TravelPromise>(promise: T, update: T): T {
const satisfied = travelPromiseValue(update);
check(promise.answer || !satisfied, 'This travel action conflicts with your whole Truthtrance travel No answer.');
return promise.answer && satisfied ? { ...update, fulfilled: true } : update;
}
/** Call once for the completed ordinary reserve shipment, or its actual skip.
* A later answer starts with pending=false and never receives an older event. */
export function recordTravelShipment<T extends TravelPromise>(promises: readonly T[], player: string, turn: number,
event: TravelEvent | null): T[] {
if (event !== null) validateEvent(event);
const live = liveTravelPromises(promises, player, turn);
const updates = new Map<T, T>();
for (const promise of live) {
readPromise(promise);
if (!promise.shipment.pending) continue;
updates.set(promise, settle(promise, { ...promise,
shipment: { pending: false, event: event === null ? null : { ...event } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Call only for an actual ordinary physical ground group. Several smaller
* groups never combine into one minimum; markers and shipments do not count. */
export function recordTravelMovement<T extends TravelPromise>(promises: readonly T[], player: string, turn: number,
event: TravelEvent): T[] {
validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTravelPromises(promises, player, turn)) {
readPromise(promise);
if (promise.movementClosed || event.amount === 0) continue;
let named = false;
const visit = (node: MixedActionExpression<TravelActionClaim>): void => {
if (node.kind === 'action') named ||= node.action.kind === 'move' && node.action.territory === event.territory;
else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(promise.mixed);
if (!named) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
if (event.amount <= old) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
const entry = progress.find(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
updates.set(promise, settle(promise, { ...promise, progress }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Close this actual respondent's travel turn. Ending cannot evade a Yes. */
export function finishTravelTurn<T extends TravelPromise>(promises: readonly T[], player: string, turn: number): T[] {
liveTravelPromises(promises, player, turn);
return promises.map(promise => {
if (promise.player !== player || promise.turn !== turn) return promise;
readPromise(promise);
const closed = { ...promise, movementClosed: true, shipment: { ...promise.shipment, pending: false } };
check(promise.released || travelPromiseValue(closed) === promise.answer,
'Complete your whole Truthtrance travel Yes answer before ending movement.');
return { ...closed, fulfilled: !promise.released };
});
}
/** Validate stored structure/progress; game-aware raw fact authority and native
* movement/shipment legality remain the integrating engine's responsibility. */
export function validateTravelPromises(promises: readonly TravelPromise[], players: readonly string[]): void {
check(Array.isArray(promises) && Array.isArray(players), 'Retain the travel commitment records and participants.');
const events = new Set<string>();
for (const promise of promises) {
const compiled = readPromise(promise);
const satisfied = evaluate(compiled, promise);
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer) &&
(!promise.movementClosed || promise.fulfilled)),
'The travel commitment lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}
+23
View File
@@ -1,3 +1,4 @@
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
@@ -78,6 +79,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'travel'; target: string } & TravelClaim)
| ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim)
| ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim)
| ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim)
@@ -370,6 +372,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose the current ordinary bid lot in a supported table and finish pending decisions.');
return { kind: 'bid', target: v.target, ...claim };
}
if (v.kind === 'travel') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' &&
g.phase === 5 && v.turn === g.turn,
'Choose a current Shipment and Movement turn for this combined travel question.');
const respondent = g.players.find(player => player.id === v.target)!;
return { kind: 'travel', target: v.target as string, turn: g.turn,
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseTravelActionClaim,
{ requireFact: false }) };
}
if (v.kind === 'mixedTraitorCall') {
check(Object.keys(v).sort().join(',') === 'battle,kind,mixed,target,territory' &&
g.status === 'playing' && g.phase === 6 && g.battle?.revealed && typeof v.battle === 'string' &&
@@ -670,6 +681,8 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
if (q.kind === 'travel') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)}`)} this turn?`;
if (q.kind === 'mixedTraitorCall') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => action.call ? 'you declare the opposing leader a Traitor in this battle'
: 'you decline to declare the opposing leader a Traitor in this battle')}?`;
@@ -739,6 +752,8 @@ export function resolveTruthAction(
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
bindTravel?: (g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) => void;
mixedTraitorAnswers?: (g: Game, p: Player, claim: MixedTraitorClaim) => TruthAnswer[];
bindMixedTraitor?: (g: Game, p: Player, claim: MixedTraitorClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
@@ -906,6 +921,9 @@ export function resolveTruthAction(
if (w.question.kind === 'bid')
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a bidder with a remaining ordinary opportunity in this lot.');
if (w.question.kind === 'travel')
check(effects.travelAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose an unused or active native Shipment and Movement opportunity.');
if (w.question.kind === 'mixedTraitorCall')
check(effects.mixedTraitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole mixed Traitor question for an actual current voter.');
@@ -1030,6 +1048,11 @@ export function resolveTruthAction(
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'travel') {
check(effects.travelAnswers && effects.bindTravel && effects.travelAnswers(g, p, q).includes(a.answer),
'Choose a whole travel answer compatible with native actions and earlier commitments.');
if (a.answer !== 'unknown') effects.bindTravel(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'mixedTraitorCall') {
check(effects.mixedTraitorAnswers && effects.bindMixedTraitor && effects.mixedTraitorAnswers(g, p, q).includes(a.answer),
'Choose an aggregate answer compatible with this mixed question and current native declaration.');