Support whole AND/OR future ground moves

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

No files matched your search

+12 -2
View File
@@ -1,3 +1,4 @@
import { liveMovementExpressionPromises } from './movement-expression-promises';
import { discoveryBotActions } from './discovery-options';
import { normalAuctionHandBlock } from './normal-auction';
import { atreidesFullPlanAction } from './atreides-full-plan';
@@ -1352,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 === 'movementExpression') {
const answer = g.truthMovementExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'movement') {
const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
@@ -2768,7 +2773,8 @@ function policyActions(g: GameView): Action[] {
];
}
if(d.kind==='advisor'&&!d.ambassadorEvent&&(liveShipmentPromises(g.shipmentPromises??[],me.id,g.turn).length ||
liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer)))
(liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer) ||
liveMovementExpressionPromises(g.movementExpressionPromises??[],me.id,g.turn).some(promise=>promise.answer))))
return [{type:'decision',accept:false}];
if (d.kind === 'advisor' && (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)) {
const choices = (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.filter(
@@ -2787,7 +2793,8 @@ function policyActions(g: GameView): Action[] {
];
}
if (d.kind === 'advisor' && (liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer)))
(liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer) ||
liveMovementExpressionPromises(g.movementExpressionPromises ?? [], me.id, g.turn).some(promise => promise.answer))))
// A waiting promise may need these reserves. Declining is the minimal
// legal policy until optional accompaniment strategy is implemented.
return [{ type: 'decision', accept: false }];
@@ -4215,6 +4222,9 @@ export function botActions(g: GameView): Action[] {
if (g.homeworldTupileBlocked) return [];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.movementExpressionPromiseGuide.action];
if (g.movementPromisePolicy && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return g.movementPromisePolicy;
+140 -47
View File
@@ -1,3 +1,5 @@
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
validateMovementExpressionPromises, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
@@ -2161,6 +2163,8 @@ export type Game = {
bribePromiseSequence?: string[];
alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[];
movementExpressionPromises?: MovementExpressionPromise[];
movementPromiseSequence?: string[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
ixSetupCards?: Card[] | null;
@@ -4829,7 +4833,7 @@ function finishNullentropy(
movementPreview.discard = preview.discard;
setMovementDiscardKnowledge(movementPreview, recordMovementBoxKnowledge(movementPreview.movementDiscardKnowledge,
movementPreview.players.map(p => p.id), player, preview.discard));
requireRule(!liveMovementPromises(g.movementPromises ?? [], player, g.turn).some(promise => promise.answer) ||
requireRule(!hasMovementPromises(g, player, true) ||
groundMovementCompletion(movementPreview, player) !== null,
'Choose a card that preserves your accepted ground-movement promises. The paid search remains available.');
// All rejection paths precede this single server shuffle. The paid record and
@@ -13789,7 +13793,7 @@ function beginRichesePreCostBetrayal(g: Game, source:
const owner=invoice.buyer;
if (liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn).length ||
g.battle?.truthPromises?.some(p=>p.player===owner && !p.released) ||
liveMovementPromises(g.movementPromises ?? [],owner,g.turn).some(p=>p.answer)) {
hasMovementPromises(g, owner, true)) {
const projected=structuredClone(g);
if (source.kind==='ordinary') {
const p=getPlayer(projected,owner);
@@ -15142,7 +15146,7 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?
return null;
}
function bribeCompatibleLaterPromises(g: Game, p: Player,
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean {
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'movementExpressionPromises' | 'battle'>): boolean {
return !hasBribePromises(g, p.id, true) || !!bribePromiseCompletion({ ...g, ...later }, p);
}
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
@@ -15236,7 +15240,7 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
const voluntary = { actor: p.id, action: { type: 'bribe' } };
reconcileBattlePromises(trial, voluntary);
reconcileShipmentPromises(trial, voluntary);
if (liveMovementPromises(trial.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) &&
if (hasMovementPromises(trial, p.id, true) &&
!groundMovementCompletion(trial, p.id)) return false;
reconcileRevivalPromises(trial, voluntary);
return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
@@ -15247,7 +15251,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
const constrained = hasBribePromises(g, p.id) ||
g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
hasMovementPromises(g, p.id, true) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer);
@@ -15458,7 +15462,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
try {
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
hasMovementPromises(g, p.id) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
@@ -18376,12 +18380,13 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
}
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
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;
const game = groundMovementProbe(source, actor);
if (source.phase === 5 && source.phaseOpening?.initialize) {
resetGroundMovementForecast(game, actor);
return groundMovementCompletionAtMovement(game, actor, promises);
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
}
if (source.phase < 5) {
const paid = source.pendingNullentropy;
@@ -18389,7 +18394,7 @@ function groundMovementCompletion(source: Game, actor: string,
if (nullentropyIntegrity(source)) return null;
for (const card of eligibleNullentropyCards(source.discard)) {
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
const completion = projected && groundMovementCompletion(projected, actor, promises);
const completion = projected && groundMovementCompletion(projected, actor, promises, expressions);
if (completion) return { ...completion,
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
}
@@ -18397,12 +18402,12 @@ function groundMovementCompletion(source: Game, actor: string,
}
for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) {
resetGroundMovementForecast(seed.game, actor);
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises);
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions);
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
}
return null;
}
return groundMovementCompletionAtMovement(game, actor, promises);
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
}
function resetGroundMovementForecast(game: Game, actor: string) {
resetPlayerMovement(getPlayer(game, actor));
@@ -18410,7 +18415,8 @@ function resetGroundMovementForecast(game: Game, actor: string) {
game.ornithopter = null;
game.movementRemaining = [actor];
}
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[]): MovementCompletion | null {
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[],
expressions: readonly MovementExpressionPromise[]): MovementCompletion | null {
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
allowance: movesAllowed,
@@ -18508,22 +18514,29 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
return { game: projectGroundMovement(copy, move), event: groundMovementEvent(move) };
} catch (error) { if (error instanceof RuleError) return null; throw error; }
},
});
}, expressions);
}
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);
}
function movementExpressionIntegrity(g: Game) {
try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); }
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
}
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
const paid = g.pendingNullentropy?.player === player.id;
if (!paid && g.phase < 5) return earlyGroundMovementPolicy(g, player);
if (g.truthtrance || !live.length || (!paid && g.active !== player.id) ||
(paid && !live.some(promise => promise.answer))) return null;
if (g.truthtrance || !hasMovementPromises(g, player.id) || (!paid && g.active !== player.id) ||
(paid && !hasMovementPromises(g, player.id, true))) return null;
const available = paid ? groundMovementProbe(g, player.id) : g;
if (!movementPromiseTargetAvailable(paid ? { ...available, decision: null } : available, player.id)) return null;
const completion = groundMovementCompletion(g, player.id);
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
}
function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !live.some(promise => promise.answer) ||
if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !hasMovementPromises(g, player.id, true) ||
g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
(g.decision && !(g.decision.kind === 'auctionPayment' && g.decision.player === player.id))) return null;
// Passing cannot spend the reserved route. In particular, a foreign Silent
@@ -18558,38 +18571,99 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
}
return answers.length ? answers : ['unknown'];
}
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
function reopenMovementPreparation(g: Game, p: Player) {
if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
g.ready = g.ready.filter(id => id !== p.id);
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
}
(g.movementPromises ??= []).push({ ...claim, turn: g.turn, player: p.id, asker, answer });
}
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
reopenMovementPreparation(g, p);
const promise: MovementPromise = { ...claim, event: crypto.randomUUID(), turn: g.turn, player: p.id, asker, answer };
(g.movementPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event!);
}
function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return [];
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [] };
const expressions = [...(g.movementExpressionPromises ?? []), promise];
const trial = { ...answerSource, movementExpressionPromises: expressions };
if (groundMovementCompletion(trial, p.id) &&
revivalCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions }) &&
bribeCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions })) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) {
requireRule(movementExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole movement answer compatible with owned actions and earlier answers.');
reopenMovementPreparation(g, p);
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(),
player: p.id, asker, answer, progress: [] };
(g.movementExpressionPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event);
}
/** 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 {
if (Array.isArray(value)) return value.map(canonicalMovementGuideValue);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonicalMovementGuideValue(item)]));
return value;
}
function movementGuideActionMatches(actual: Action, expected: Action): boolean {
return Object.keys(actual).sort().join(',') === Object.keys(expected).sort().join(',') &&
JSON.stringify(canonicalMovementGuideValue(actual)) === JSON.stringify(canonicalMovementGuideValue(expected));
}
function projectedMovementExpressionGuide(g: Game, player: Player): { phase: number; action: Action } | null {
if (g.truthtrance) return null;
const live = liveMovementExpressionPromises(g.movementExpressionPromises ?? [], player.id, g.turn);
const promise = live.find(promise => promise.answer) ?? live[0];
if (!promise) return null;
const action = projectedGroundMovementPolicy(g, player)?.[0];
return action ? { phase: g.phase, action: { ...structuredClone(action), movementExpressionPromiseEvent: promise.event } } : null;
}
function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
if (!live.length) return;
const event = groundMovementEvent(move);
if (move.source === 'ambassador' || !hasMovementPromises(g, p.id)) return;
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);
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; }
requireRule(groundMovementCompletion(projected, p.id) !== null,
'Leave a legal completion for your accepted ground-movement promises.');
}
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
if (move.source === 'ambassador') return;
if (!g.movementPromises) return;
const old = g.movementPromises;
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
g.movementPromises.forEach((promise, index) => {
if (promise.fulfilled && !old[index].fulfilled)
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
});
const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? [];
if (g.movementPromises) {
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
g.movementPromises.forEach((promise, index) => {
if (promise.fulfilled && !old[index].fulfilled)
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
});
}
if (g.movementExpressionPromises) {
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)
log(g, `${p.name} fulfilled a whole Truthtrance movement answer with this committed move.`);
});
}
}
function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
// A declared native movement is awaiting a real opponent choice. It is not
// impossible and must not be fulfilled before physical commitment.
movementExpressionIntegrity(g);
type Promise = MovementPromise | MovementExpressionPromise;
const isExpression = (promise: Promise): promise is MovementExpressionPromise => 'claim' in promise;
for (const p of g.players) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
const live: Promise[] = [...liveMovementPromises(g.movementPromises ?? [], p.id, g.turn),
...liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)];
if (!live.some(promise => promise.answer)) continue;
if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
g.pendingChoamMove?.player === p.id ||
@@ -18609,13 +18683,16 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome));
if (busy && !voluntary) continue;
if (groundMovementCompletion(g, p.id, live)) continue;
const sequence = g.movementPromiseSequence ?? [];
const completion = (promises: Promise[]) => groundMovementCompletion(g, p.id,
promises.filter((promise): promise is MovementPromise => !isExpression(promise)), promises.filter(isExpression));
if (completion(live)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
const kept = live.filter(promise => !promise.answer);
// Keep still-feasible earlier answers and every No constraint; an opposing
// loss does not erase independent promises that remain possible.
for (const promise of live.filter(promise => promise.answer)) {
if (groundMovementCompletion(g, p.id, [...kept, promise])) { kept.push(promise); continue; }
const positive = live.filter(promise => promise.answer).sort((a, b) =>
sequence.indexOf(a.event ?? '') - sequence.indexOf(b.event ?? ''));
for (const promise of positive) {
if (completion([...kept, promise])) { kept.push(promise); continue; }
promise.released = true;
log(g, `${p.name}'s Truthtrance ground-movement promise is released because no owned legal completion remains possible.`);
}
@@ -19035,6 +19112,10 @@ function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
try {
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
for (const promise of liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)) {
requireRule(!promise.answer, 'Complete your whole Truthtrance movement Yes before finishing movement.');
promise.fulfilled = true;
}
} catch (error) {
if (error instanceof MovementClaimError) throw new RuleError(error.message);
throw error;
@@ -30339,7 +30420,7 @@ function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) {
}
function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean {
if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true;
!hasMovementPromises(g, p.id)) return true;
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
if (revival) stagePromisedForceReturn(trial, actor, revival);
@@ -30462,7 +30543,7 @@ function revivalPromisePlan(g: Game, p: Player, promises = g.revivalPromises ??
function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
return revivalPromisePlan(g, p, promises)?.action ?? null;
}
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'movementExpressionPromises'>): boolean {
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
!liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length) return true;
@@ -30561,7 +30642,7 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length &&
!hasMovementPromises(g, p.id) &&
!hasBribePromises(g, p.id, true)) return;
if (!quote) {
requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []),
@@ -35594,6 +35675,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.movementExpressionPromiseEvent !== undefined) {
const guide = projectedMovementExpressionGuide(state, getPlayer(state, id));
requireRule(guide && guide.phase === state.phase && movementGuideActionMatches(action, guide.action),
'This whole movement guide is stale or belongs to another opportunity.');
}
if (action.bribePromiseEvent !== undefined) {
const guide = projectedBribePromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -36300,6 +36386,8 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
movementExpressionAnswers: movementExpressionTruthAnswers,
bindMovementExpression: bindMovementExpressionTruth,
allianceAnswers: allianceTruthAnswers,
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
@@ -36323,7 +36411,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 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 TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -37853,7 +37941,7 @@ function applyActionInner(
if(action.accept){
beginSpiritualAdvisor(g,decision,action);
if(liveShipmentPromises(g.shipmentPromises??[],p.id,g.turn).length ||
liveMovementPromises(g.movementPromises??[],p.id,g.turn).some(promise=>promise.answer)){
hasMovementPromises(g, p.id, true)){
const afterAdvisor=structuredClone(g);
for(let step=0;step<4&&afterAdvisor.response&&(afterAdvisor.response.spiritualAdvisorSource ||
afterAdvisor.response.automaticAdvisorFlip?.producer==='spiritualArrival');step++)
@@ -37940,7 +38028,7 @@ function applyActionInner(
amount,
};
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
hasMovementPromises(g, p.id, true)) {
// Accepting accompaniment is voluntary even though a later responder
// commits its reserve withdrawal. Check that allowed outcome now.
const afterAdvisor = structuredClone(g);
@@ -38866,7 +38954,7 @@ function applyActionInner(
'Not enough spice for these extra revivals.',
);
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
hasMovementPromises(g, p.id, true) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
@@ -39298,7 +39386,7 @@ function applyActionInner(
// unsupported arrival combinations before saving its private intent.
validateShipmentArrival(g, shipment);
checkShipmentIncomeFunding(g, p, cost, allyPayment);
if (liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
if (hasMovementPromises(g, p.id, true)) {
const afterShipment = structuredClone(g);
commitShipment(afterShipment, shipment, undefined, true);
reconcileGroundMovementPromises(afterShipment, p.id, action, g.phase);
@@ -39497,7 +39585,7 @@ function applyActionInner(
return g;
}
if (t === 'endMovement') {
requireRule(!liveMovementPromises(g.movementPromises ?? [], id, g.turn).some(promise => promise.answer),
requireRule(!hasMovementPromises(g, id, true),
'Complete your accepted ground-movement promises before ending movement.');
finishShipmentPromises(g, p, null);
requireRule(
@@ -40107,6 +40195,11 @@ export function viewGame(state: Game, id: string) {
? movementTruthAnswers(g, me, g.truthtrance.question) : null,
movementPromises: g.movementPromises ?? [],
movementPromisePolicy: projectedGroundMovementPolicy(g, me),
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)),
movementExpressionPromiseGuide: projectedMovementExpressionGuide(g, me),
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
+171
View File
@@ -0,0 +1,171 @@
import { TERRITORIES } from './board';
import { MovementClaimError, movementClaimText, parseMovementClaim, type MovementEvent } from './movement-promises';
export { movementPromiseModeSupported as movementExpressionPromiseModeSupported } from './movement-promises';
export type MovementExpression = { kind: 'move'; territory: string; minimum: number } |
{ kind: 'and' | 'or'; terms: MovementExpression[] };
export type MovementExpressionClaim = { turn: number; claim: MovementExpression };
export type MovementExpressionProgressEntry = { territory: string; maximum: number };
export type MovementExpressionProgress = MovementExpressionProgressEntry[];
export type MovementExpressionEvent = MovementEvent;
export type MovementExpressionPromise = MovementExpressionClaim & {
event: string; player: string; asker: string; answer: boolean;
/** Largest actual physical ground move to each named destination after this answer. */
progress: MovementExpressionProgress;
fulfilled?: boolean; released?: boolean;
};
export const MOVEMENT_EXPRESSION_MAX_DEPTH = 4;
export const MOVEMENT_EXPRESSION_MAX_LEAVES = 16;
export class MovementExpressionPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'MovementExpressionPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new MovementExpressionPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
const count = (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);
/** Validate every subtree before evaluation can short-circuit. The scalar
* parser remains the authority for printed destinations and physical minima. */
export function parseMovementExpression(value: unknown): MovementExpression {
let leaves = 0;
const parse = (input: unknown, depth: number): MovementExpression => {
check(object(input) && depth <= MOVEMENT_EXPRESSION_MAX_DEPTH,
'Use a ground-movement expression within four nested groups.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= MOVEMENT_EXPRESSION_MAX_LEAVES,
'Join two or more ground-movement statements with AND or OR.');
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
}
check(++leaves <= MOVEMENT_EXPRESSION_MAX_LEAVES,
'Use at most sixteen ground-movement statements.');
check(input.kind === 'move' && keys(input, 'kind,minimum,territory'),
'Choose one physical ground-movement minimum and printed destination.');
try { return { kind: 'move', ...parseMovementClaim(input) }; }
catch (error) {
if (error instanceof MovementClaimError) throw new MovementExpressionPromiseError(error.message);
throw error;
}
};
return parse(value, 0);
}
export function parseMovementExpressionClaim(value: unknown): MovementExpressionClaim {
check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' &&
Number.isSafeInteger(value.turn) && value.turn >= 1,
'Choose the current turn and a ground-movement expression.');
return { turn: value.turn, claim: parseMovementExpression(value.claim) };
}
export function movementExpressionText(expression: MovementExpression): string {
const text = (node: MovementExpression): string => 'terms' in node ?
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` : movementClaimText(node);
return text(parseMovementExpression(expression));
}
export function movementExpressionTerritories(expression: MovementExpression): string[] {
const destinations = new Set<string>();
const visit = (node: MovementExpression): void => {
if ('terms' in node) node.terms.forEach(visit);
else destinations.add(node.territory);
};
visit(parseMovementExpression(expression));
return [...destinations];
}
function validateProgress(value: unknown): asserts value is MovementExpressionProgress {
check(Array.isArray(value), 'Retain the largest future physical ground move by named destination.');
const destinations = new Set<string>();
for (const entry of value) {
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
count(entry.maximum) && !destinations.has(entry.territory),
'Retain unique printed destinations and physical ground-movement maxima from zero to twenty.');
destinations.add(entry.territory);
}
}
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[]): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
return (progress.find(entry => entry.territory === expression.territory)?.maximum ?? 0) >= expression.minimum;
}
export function evaluateMovementExpression(expression: MovementExpression,
progress: readonly MovementExpressionProgressEntry[]): boolean {
const parsed = parseMovementExpression(expression);
validateProgress(progress);
return evaluate(parsed, progress);
}
export function liveMovementExpressionPromises<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function readPromise(promise: MovementExpressionPromise): MovementExpression {
check(object(promise), 'The ground-movement expression commitment record is invalid.');
const { claim } = parseMovementExpressionClaim({ turn: promise.turn, claim: promise.claim });
validateProgress(promise.progress);
const destinations = movementExpressionTerritories(claim);
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
!(promise.fulfilled && promise.released) && promise.progress.every(entry => destinations.includes(entry.territory)),
'Retain a definite whole movement answer and its own matching future physical move progress.');
return claim;
}
/** Call only after a committed ordinary physical ground move. Shipments,
* ambassador moves and marker pieces are not qualifying forces. A marker-only
* event may have amount zero, which cannot advance a positive minimum. */
export function recordMovementExpressionEvent<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
turn: number, event: MovementEvent): T[] {
check(object(event) && keys(event, 'amount,territory') && identifier(event.territory) && count(event.amount),
'Retain the exact destination and zero to twenty actual physical ground-moving forces.');
const updates = new Map<T, T>();
for (const promise of liveMovementExpressionPromises(promises, player, turn)) {
const claim = readPromise(promise);
if (!movementExpressionTerritories(claim).includes(event.territory)) 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(entry => entry.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
const satisfied = evaluate(claim, progress);
check(promise.answer || !satisfied,
'This ground movement conflicts with your whole Truthtrance movement No answer.');
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
}
return promises.map(promise => updates.get(promise) ?? promise);
}
export function validateMovementExpressionPromises(promises: readonly MovementExpressionPromise[], players: readonly string[]): void {
check(Array.isArray(promises), 'The ground-movement expression commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
const claim = readPromise(promise);
const satisfied = evaluate(claim, promise.progress);
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.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
'The ground-movement expression lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}
+42 -13
View File
@@ -1,6 +1,9 @@
import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories,
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
type MovementExpressionPromise } from './movement-expression-promises';
export type MovementPromiseAdapter = {
allowance: (game: Game, player: Player) => number;
@@ -37,11 +40,16 @@ function* groups(player: Player, keys: string[], amount: number, index = 0,
* The adapter shares authoritative movement validation/physical commitment.
* Opposing arrival outcomes are not simulated or assumed. */
export function findMovementCompletion(source: Game, actor: string,
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter): MovementCompletion | null {
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter,
expressions: readonly MovementExpressionPromise[] = source.movementExpressionPromises ?? []): MovementCompletion | null {
const failed = new Set<string>();
const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => {
const retainWhole = expressions.length > 0 || source.movementExpressionPromises !== undefined;
const search = (game: Game, pending: readonly MovementPromise[], whole: readonly MovementExpressionPromise[],
actions: Action[]): MovementCompletion | null => {
const live = liveMovementPromises(pending, actor, game.turn);
const needsMove = live.some(promise => promise.answer);
const liveWhole = liveMovementExpressionPromises(whole, actor, game.turn);
const positiveWhole = liveWhole.filter(promise => promise.answer);
const needsMove = live.some(promise => promise.answer) || positiveWhole.length > 0;
if (!needsMove && adapter.canFinish(game, actor)) return { actions };
const player = game.players.find(candidate => candidate.id === actor)!;
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
@@ -52,8 +60,12 @@ export function findMovementCompletion(source: Game, actor: string,
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0;
const revealable = Math.min(markerValue, player.reserves + recovery);
if (live.some(promise => promise.answer && promise.minimum > physical +
(player.shipped ? revealable : player.reserves + recovery))) return null;
const possiblePhysical = physical + (player.shipped ? revealable : player.reserves + recovery);
if (live.some(promise => promise.answer && promise.minimum > possiblePhysical) ||
positiveWhole.some(promise => !evaluateMovementExpression(promise.claim,
movementExpressionTerritories(promise.claim).map(territory => ({ territory,
maximum: Math.max(promise.progress.find(entry => entry.territory === territory)?.maximum ?? 0,
Math.min(20, possiblePhysical)) }))))) return null;
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
player.fremenMovementBlocked, player.ixMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
@@ -62,11 +74,13 @@ export function findMovementCompletion(source: Game, actor: string,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
live.map(promise => [promise.territory, promise.minimum, promise.answer])]);
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress])]);
if (failed.has(key)) return null;
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
Number(live.some(p => p.answer && p.territory === a.id)) ||
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
if (needsMove && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
@@ -90,12 +104,20 @@ export function findMovementCompletion(source: Game, actor: string,
const total = keys.reduce((sum, key) => sum + player.forces[key], 0);
const carriesMarker = marker?.location.territory === origin && !!player.noFieldEvent;
const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)];
amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) -
Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || a - b);
const helpsWhole = (expression: MovementExpression, amount: number): boolean => 'terms' in expression
? expression.terms.some(term => helpsWhole(term, amount))
: expression.territory === destination.id && amount >= expression.minimum;
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id && amount >= p.minimum) ||
positiveWhole.some(promise => helpsWhole(promise.claim, amount));
amounts.sort((a, b) => Number(helps(b)) - Number(helps(a)) || a - b);
for (const amount of amounts) {
const event = { territory: destination.id, amount };
if (!movementEventAllowed(live, event)) continue;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
let projectedWhole: MovementExpressionPromise[];
try { projectedWhole = recordMovementExpressionEvent(whole, actor, game.turn, event); }
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
if (!mayMoveAgain && liveMovementExpressionPromises(projectedWhole, actor, game.turn).some(promise => promise.answer)) continue;
const markers: Partial<Action>[] = carriesMarker
? [...(amount ? [{}] : []), { noField: marker!.tokenId, event: player.noFieldEvent }] : [{}];
for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) for (const selected of markers) {
@@ -103,19 +125,26 @@ export function findMovementCompletion(source: Game, actor: string,
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
// The projection owns the exact physical amount (markers excluded).
// Keep promise progress on detached states only.
let settledWhole: MovementExpressionPromise[];
try { settledWhole = recordMovementExpressionEvent(whole, actor, game.turn, next.event); }
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
next.game.movementPromises = structuredClone(settled);
const result = search(next.game, settled, [...actions, action]);
if (retainWhole) next.game.movementExpressionPromises = structuredClone(settledWhole);
const result = search(next.game, settled, settledWhole, [...actions, action]);
if (result) return result;
}
}
}
}
for (const prepared of adapter.preparations(game, actor)) {
const result = search(prepared.game, live, [...actions, ...prepared.actions]);
const result = search(prepared.game, live, whole, [...actions, ...prepared.actions]);
if (result) return result;
}
failed.add(key); return null;
};
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
return search(initial, promises, []);
if (retainWhole) initial.movementExpressionPromises = structuredClone([...expressions]);
return search(initial, promises, expressions, []);
}
+2
View File
@@ -6,6 +6,8 @@ import type { Game } from './engine';
export type MovementClaim = { territory: string; minimum: number };
export type MovementEvent = { territory: string; amount: number };
export type MovementPromise = MovementClaim & {
/** New answers share accepted order with whole expressions; older saves omit it. */
event?: string;
turn: number;
player: string;
asker: string;
+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-movement-expression',
title: 'Truthtrance: combined ground moves',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Combine future physical ground moves with full AND/OR while preserving whole No, actual entitlements and maximum qualifying group sizes.',
steps: [
'Choose Combine ground moves with AND/OR and add printed Arrakis destination/minimum conditions. AND requires all children; OR at least one. The editor supports sixteen conditions and depth four as software input limits.',
'Each condition asks for one future qualifying physical ground group after this answer and before your Movement opportunity ends. Smaller moves never add together. Repeated same-destination conditions share the largest actual future group; earlier moves are excluded.',
'No negates the whole expression. No to moving to A AND B may permit either first move while forbidding completing both. No to A OR B forbids either qualifying move. A Yes becomes fulfilled only when actual committed moves make the full expression true.',
'The native owned-action search retains all alternatives after real progress. Several required moves need actual remaining entitlements or owned Hajr/Ornithopter preparations; the question grants none. Native range, storms, physical counters, elite allocations, shipment preparation and opponent responses remain authoritative.',
'Only completed ordinary ground movement records progress. Shipment, concealed No-Field markers, HMS relocation and Ambassador relocation do not fulfill a condition. Physical advisors and elites retain their native physical counting. A pending range response records no movement until its real commitment.',
'Persistent own maximum progress, private feasible answers and an exact event/phase guide connect to real native actions and all four minimal profiles. The guide may prepare cards, forces or payments at their original windows. Another legal OR route may be chosen manually. Wire force records retain exact native contents.',
'Supported native Basic/Advanced phases0–5 retain their existing current-turn opportunity and owned preparation scope. Finishing movement cannot evade Yes; No closes at the actual owner opportunity end. Current-fact/cross-family expressions, optional modules and complete-mode acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-movement', 'truthtrance-shipment', 'truthtrance-revival-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Strict whole-tree parsing, immutable maximum progress and joint native scalar/expression reachability preserve owned multi-move entitlements and whole No.', evidence: ['game/movement-expression-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full expression editor, guarded preview/Ask, target-only answers, own progress and exact phase/event guide are connected.', evidence: ['components/movement-expression-fields.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and follow owned native movement/preparation guides. Strategic initiation/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-movement-expression-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Source composition, physical event meaning, opportunity/deadline, preparation and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pure/search, controlled editor/guide and original Basic/Advanced/Hajr/Charity/Fremen cases. Wider composition and browser/deployed acceptance remain open.', evidence: ['tests/movement-expression-promises.test.ts', 'tests/fixture-truth-movement-expression.ts', 'tests/truth-movement-expression-runtime.test.ts', 'tests/truth-movement-expression-controls.test.tsx'] },
],
},
{
id: 'truthtrance-movement',
title: 'Truthtrance: ground-movement promises',
@@ -3545,6 +3570,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 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.',
'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.',
@@ -3565,6 +3591,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-movement-expression',
'truthtrance-bid',
'truthtrance-revival',
'truthtrance-leader-revival',
+22
View File
@@ -1,3 +1,4 @@
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
type BribeExpressionClaim } from './bribe-expression-promises';
import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises';
@@ -67,6 +68,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
@@ -309,6 +311,14 @@ 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 === 'movementExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
'Supply the current turn, complete movement expression and respondent.');
const claim = parseMovementExpressionClaim({ turn: v.turn, claim: v.claim });
check(claim.turn === g.turn && movementPromiseTargetAvailable(g, v.target as string),
'Choose a supported unused ground-movement opportunity with pending decisions finished.');
return { kind: 'movementExpression', target: v.target as string, ...claim };
}
if (v.kind === 'movement') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
'Supply one ground-movement destination, minimum and respondent.');
@@ -534,6 +544,7 @@ export function truthQuestionText(
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
if (q.kind === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
if (q.kind === 'shipment')
return `Will you ${shipmentClaimText(q)} this turn?`;
@@ -576,6 +587,8 @@ export function resolveTruthAction(
asker: string,
mixed?: MixedShipmentExpression,
) => 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[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
@@ -719,6 +732,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 === 'movementExpression')
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported whole ground-movement question.');
if (w.question.kind === 'bribeExpression')
check(effects.bribeExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole bribe expression compatible with earlier answers.');
@@ -776,6 +792,12 @@ export function resolveTruthAction(
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
q.kind === 'mixedShipment' ? q.mixed : undefined);
}
if (q.kind === 'movementExpression') {
check(effects.movementExpressionAnswers && effects.bindMovementExpression &&
effects.movementExpressionAnswers(g, p, q).includes(a.answer),
'Choose a whole movement answer compatible with owned actions and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovementExpression(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'movement') {
check(effects.movementAnswers && effects.bindMovement && effects.movementAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with legal ground movement and earlier promises.');