Combine current facts with future ground movement

This commit is contained in:
Quintine committed 2026-10-26 16:25:54 +11:00
1 parent d524642b65
commit b63a60da64
19 files changed
+1590 -7

No files matched your search

+4
View File
@@ -1353,6 +1353,10 @@ function policyActions(g: GameView): Action[] {
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'mixedMovement') {
const answer = g.truthMixedMovementAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'movementExpression') {
const answer = g.truthMovementExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+69 -4
View File
@@ -1,5 +1,6 @@
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
validateMovementExpressionPromises, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
@@ -599,6 +600,7 @@ import {
type TruthQueueEntry,
type TruthRecord,
type TruthAnswer,
type MixedMovementClaim,
} from './truthtrance';
import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control';
@@ -2164,6 +2166,10 @@ export type Game = {
alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[];
movementExpressionPromises?: MovementExpressionPromise[];
/** Original mixed questions only; actual obligations remain in the native ledger. */
mixedMovementQuestions?: (MixedMovementClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
})[];
movementPromiseSequence?: string[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -6902,6 +6908,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(next.record.question.kind === 'mixedMovement' ? {
mixedMovementQuestions: g.mixedMovementQuestions, movementExpressionPromises: g.movementExpressionPromises,
} : {}),
active: g.active,
order: g.order,
status: g.status,
@@ -18607,6 +18616,56 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi
(g.movementExpressionPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event);
}
function compileMixedMovement(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant') return { constant: compiled.value };
const movement = (node: CompiledMixedAction<MovementClaim>): MovementExpression => {
if (node.kind === 'action') return { kind: 'move', ...node.action };
requireRule(node.kind !== 'constant', 'Retain the complete compiled mixed-movement residual.');
return { kind: node.kind, terms: node.terms.map(movement) };
};
return { residual: { turn: claim.turn, claim: movement(compiled) } };
}
function mixedMovementTruthAnswers(g: Game, p: Player, claim: MixedMovementClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedMovement(g, p, claim, answer);
if (compiled.constant !== undefined ? compiled.constant === answer :
movementExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMixedMovementTruth(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean, asker: string) {
requireRule(mixedMovementTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole mixed-movement answer compatible with current facts and owned actions.');
const compiled = compileMixedMovement(g, p, claim, answer);
let event: string;
if (compiled.residual) {
bindMovementExpressionTruth(g, p, compiled.residual, answer, asker);
event = g.movementExpressionPromises!.at(-1)!.event;
} else {
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-movement answer.');
// Uniform early readiness avoids publishing whether a private fact made a
// residual necessary. Only the original aggregate answer is public.
reopenMovementPreparation(g, p);
event = crypto.randomUUID();
}
(g.mixedMovementQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed),
event, player: p.id, asker, answer, binding: !!compiled.residual });
}
function isMixedMovementEvent(g: Game, event: string): boolean {
return !!g.mixedMovementQuestions?.some(question => question.event === event);
}
function projectedMixedMovementPromises(g: Game, player: string) {
return (g.mixedMovementQuestions ?? []).filter(question => question.player === player).map(question => {
const bound = g.movementExpressionPromises?.find(promise => promise.event === question.event);
return { event: question.event, player: question.player, asker: question.asker, turn: question.turn,
mixed: structuredClone(question.mixed), answer: question.answer, progress: structuredClone(bound?.progress ?? []),
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
});
}
/** Nested native force records survive JSON transport; record key order is
* immaterial but fields, values and the originating guide event must match. */
function canonicalMovementGuideValue(value: unknown): unknown {
@@ -18652,7 +18711,7 @@ function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
try { g.movementExpressionPromises = recordMovementExpressionEvent(expressions, p.id, g.turn, groundMovementEvent(move)); }
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
g.movementExpressionPromises.forEach((promise, index) => {
if (promise.fulfilled && !expressions[index].fulfilled)
if (promise.fulfilled && !expressions[index].fulfilled && !isMixedMovementEvent(g, promise.event))
log(g, `${p.name} fulfilled a whole Truthtrance movement answer with this committed move.`);
});
}
@@ -36388,6 +36447,8 @@ function applyActionInner(
bindMovement: bindMovementTruth,
movementExpressionAnswers: movementExpressionTruthAnswers,
bindMovementExpression: bindMovementExpressionTruth,
mixedMovementAnswers: mixedMovementTruthAnswers,
bindMixedMovement: bindMixedMovementTruth,
allianceAnswers: allianceTruthAnswers,
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
@@ -36411,7 +36472,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -40198,7 +40259,11 @@ export function viewGame(state: Game, id: string) {
truthMovementExpressionTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthMovementExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movementExpression' &&
g.truthtrance.question.target === id ? movementExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
movementExpressionPromises: (g.movementExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
movementExpressionPromises: (g.movementExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedMovementEvent(g, promise.event)).map(promise => structuredClone(promise)),
truthMixedMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthMixedMovementAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'mixedMovement' &&
g.truthtrance.question.target === id ? mixedMovementTruthAnswers(g, me, g.truthtrance.question) : null,
mixedMovementPromises: projectedMixedMovementPromises(g, id),
movementExpressionPromiseGuide: projectedMovementExpressionGuide(g, me),
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
+116
View File
@@ -0,0 +1,116 @@
import type { TruthAnswer, TruthFact } from './truthtrance';
/** Original public clauses only; answer-time constants never belong in a question. */
export type MixedActionExpression<A> =
| { kind: 'fact'; fact: TruthFact }
| { kind: 'action'; action: A }
| { kind: 'and' | 'or'; terms: MixedActionExpression<A>[] };
/** Private answer-time result. Residual groups contain only actions and groups. */
export type CompiledMixedAction<A> =
| { kind: 'constant'; value: boolean }
| { kind: 'action'; action: A }
| { kind: 'and' | 'or'; terms: CompiledMixedAction<A>[] };
export type MixedActionExpressionLimits = { maxDepth?: number; maxLeaves?: number };
export class MixedActionQuestionError extends Error {}
function check(value: unknown, message: string): asserts value {
if (!value) throw new MixedActionQuestionError(message);
}
function record(value: unknown): Record<string, unknown> {
check(value && typeof value === 'object' && !Array.isArray(value),
'Choose a current fact, future action, or AND/OR group.');
return value as Record<string, unknown>;
}
/** The callbacks retain fact authority and validate one action leaf, respectively.
* One logical budget spans the outer expression and all embedded fact groups. */
export function parseMixedActionExpression<A>(
value: unknown,
parseFact: (value: unknown) => TruthFact,
parseAction: (value: unknown) => A,
limits: MixedActionExpressionLimits = {},
): MixedActionExpression<A> {
const maxDepth = limits.maxDepth ?? 4;
const maxLeaves = limits.maxLeaves ?? 16;
check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 &&
Number.isSafeInteger(maxLeaves) && maxLeaves >= 1,
'Choose valid logical depth and clause limits.');
let leaves = 0;
let hasFact = false;
let hasAction = false;
const count = (depth: number, leaf: boolean): void => {
check(depth <= maxDepth && (!leaf || ++leaves <= maxLeaves),
`Truthtrance supports at most ${maxLeaves} logical clauses and ${maxDepth} levels of grouping.`);
};
const countFact = (fact: TruthFact, depth: number): void => {
const group = fact.kind === 'and' || fact.kind === 'or';
count(depth, !group);
if (group) for (const term of fact.terms) countFact(term, depth + 1);
};
const parse = (input: unknown, depth: number): MixedActionExpression<A> => {
const node = record(input);
if (node.kind === 'fact') {
check(Object.keys(node).sort().join(',') === 'fact,kind',
'Supply only the original current fact, not a frozen answer.');
const fact = parseFact(node.fact);
countFact(fact, depth);
hasFact = true;
return { kind: 'fact', fact };
}
count(depth, node.kind === 'action');
if (node.kind === 'action') {
check(Object.keys(node).sort().join(',') === 'action,kind',
'Supply only one original future-action statement.');
const action = parseAction(node.action);
hasAction = true;
return { kind: 'action', action };
}
check(Object.keys(node).sort().join(',') === 'kind,terms' &&
(node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) &&
node.terms.length >= 2 && node.terms.length <= maxLeaves,
'Join two or more current facts or future actions with AND or OR.');
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.');
return mixed;
}
/** Snapshot every fact before folding. Unknown takes the worst case for the
* selected whole answer, so the residual guarantees it without hidden guesses. */
export function compileMixedActionExpression<A>(
mixed: MixedActionExpression<A>,
answerFact: (fact: TruthFact) => TruthAnswer,
answer: boolean,
): CompiledMixedAction<A> {
if (mixed.kind === 'fact') {
const result = answerFact(mixed.fact);
return { kind: 'constant', value: result === 'unknown' ? !answer : result === 'yes' };
}
if (mixed.kind === 'action') return { kind: 'action', action: mixed.action };
// Do not short-circuit: a decisive earlier clause cannot skip later facts.
const terms = mixed.terms.map(term => compileMixedActionExpression(term, answerFact, answer));
const identity = mixed.kind === 'and';
if (terms.some(term => term.kind === 'constant' && term.value !== identity))
return { kind: 'constant', value: !identity };
const residual = terms.filter(term => term.kind !== 'constant');
if (residual.length === 0) return { kind: 'constant', value: identity };
if (residual.length === 1) return residual[0];
return { kind: mixed.kind, terms: residual };
}
/** Public text comes from the original tree, never private compiled clauses. */
export function mixedActionExpressionText<A>(
mixed: MixedActionExpression<A>,
factText: (fact: TruthFact) => string,
actionText: (action: A) => string,
): string {
if (mixed.kind === 'fact') return factText(mixed.fact);
if (mixed.kind === 'action') return actionText(mixed.action);
return `(${mixed.terms.map(term => mixedActionExpressionText(term, factText, actionText))
.join(mixed.kind === 'and' ? ' AND ' : ' OR ')})`;
}
+27
View File
@@ -3382,6 +3382,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] },
],
},
{
id: 'truthtrance-mixed-movement',
title: 'Truthtrance: current facts and future ground moves',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Combine a fact at answer time with future physical ground moves, publishing the aggregate answer and preserving the original native movement obligations.',
steps: [
'Choose Combine current facts and future ground moves. Build full AND/OR groups with at least one current fact and one destination/minimum action. One logical budget spans nested fact groups and future conditions: sixteen leaves and depth four as software limits.',
'Current facts are evaluated when the respondent answers. Later spending, card disposal or other custody changes do not rewrite that historical fact. The question, whole answer and physical actions are public; individual fact answers and the reduced obligation are not published.',
'A definite answer must be guaranteed by the current facts and remaining owned movement. Unknown facts take the conservative case for that answer: Yes cannot rely on an unknown being true, and No cannot rely on it being false. If neither whole answer is currently supportable, the holder may ask differently or save the unanswered card.',
'A fact may already settle the aggregate answer, requiring no future move. Otherwise the remaining pure movement conditions use the original native search, funding, allowances, cards, responses and actual owner finish. No negates the entire mixed question.',
'Each movement condition needs one actual future physical group; smaller moves never add together. Prior moves, shipment and the concealed No-Field marker do not fulfill it. Owned Hajr still follows its original timing, even when the question asked about holding that same physical Hajr before it was played.',
'Persistent own raw question, aggregate status, recorded movement maxima and one exact native guide connect to real actions and all four minimal policies. Pure and mixed panels share the guide. Stale, foreign or modified nested packets reject.',
'Native Basic/Advanced phases0–5 retain the existing unused movement opportunity and preparation scope. The fact/action composition is provisional application of general Truthtrance authority; other action families, optional modules and complete-mode acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-fact-expression', 'truthtrance-movement-expression', 'truthtrance-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Strict generic mixed parser/compiler freezes facts conservatively and binds only the resulting native movement obligation; immediate aggregate answers create none.', evidence: ['game/mixed-action-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and follow native movement guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-movement-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot, conservative unknown semantics, original physical obligations and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_MOVEMENT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused generic, controlled editor/guide and original native known/unknown/Hajr/Charity cases. Broader composition and final browser/deployed acceptance remain open.', evidence: ['tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-movement.ts', 'tests/truth-mixed-movement-runtime.test.ts', 'tests/truth-mixed-movement-controls.test.tsx'] },
],
},
{
id: 'truthtrance-movement-expression',
title: 'Truthtrance: combined ground moves',
@@ -3570,6 +3595,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
'A mixed current-fact and ground-movement question freezes the fact at answer time and keeps only the native movement obligation needed for the whole answer. Later card disposal does not rewrite the fact, and unknown clauses require a guaranteed aggregate. See the mixed movement guide for its provisional scope.',
'A grouped ground-movement question combines future physical group minima with full AND/OR. One move must meet each leaf; smaller moves never add together. Actual owned entitlements can fulfill several leaves over several moves, while No negates the whole expression. See the combined movement guide for its provisional opportunity and scope.',
'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.',
'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.',
@@ -3592,6 +3618,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-movement-expression',
'truthtrance-mixed-movement',
'truthtrance-bid',
'truthtrance-revival',
'truthtrance-leader-revival',
+27
View File
@@ -1,3 +1,4 @@
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
type BribeExpressionClaim } from './bribe-expression-promises';
@@ -67,7 +68,9 @@ export type TruthFact =
| { kind: 'traitor'; leader: string }
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
@@ -311,6 +314,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose the current revealed battle and a still-unsubmitted Traitor decision.');
return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string };
}
if (v.kind === 'mixedMovement') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
movementPromiseTargetAvailable(g, v.target as string),
'Supply the current turn, complete mixed movement question and supported unused respondent.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), action => {
check(action && typeof action === 'object' && !Array.isArray(action) &&
Object.keys(action).sort().join(',') === 'minimum,territory', 'Supply only a printed movement destination and physical minimum.');
return parseMovementClaim(action);
});
return { kind: 'mixedMovement', target: v.target as string, turn: g.turn, mixed };
}
if (v.kind === 'movementExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
'Supply the current turn, complete movement expression and respondent.');
@@ -563,6 +578,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 === 'mixedMovement') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${movementClaimText(action)} before your Movement opportunity ends`)}?`;
return `Is it true that ${q.kind === 'mixedShipment'
? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`;
}
@@ -587,6 +604,8 @@ export function resolveTruthAction(
asker: string,
mixed?: MixedShipmentExpression,
) => void;
mixedMovementAnswers?: (g: Game, p: Player, claim: MixedMovementClaim) => TruthAnswer[];
bindMixedMovement?: (g: Game, p: Player, claim: MixedMovementClaim, answer: boolean, asker: string) => void;
movementExpressionAnswers?: (g: Game, p: Player, claim: MovementExpressionClaim) => TruthAnswer[];
bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
@@ -732,6 +751,9 @@ export function resolveTruthAction(
if (w.question.kind === 'alliance')
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a current Nexus question with a compatible answer.');
if (w.question.kind === 'mixedMovement')
check(effects.mixedMovementAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported mixed current-fact and ground-movement question.');
if (w.question.kind === 'movementExpression')
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported whole ground-movement question.');
@@ -792,6 +814,11 @@ export function resolveTruthAction(
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
q.kind === 'mixedShipment' ? q.mixed : undefined);
}
if (q.kind === 'mixedMovement') {
check(effects.mixedMovementAnswers && effects.bindMixedMovement &&
effects.mixedMovementAnswers(g, p, q).includes(a.answer), 'Choose a compatible whole mixed-movement answer.');
if (a.answer !== 'unknown') effects.bindMixedMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'movementExpression') {
check(effects.movementExpressionAnswers && effects.bindMovementExpression &&
effects.movementExpressionAnswers(g, p, q).includes(a.answer),