Combine current-turn payments with shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 21:09:49 +11:00
1 parent 8dbec05844
commit 0d72a33ee5
18 files changed
+2591 -7

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 === 'turnActions') {
const answer = g.truthTurnActionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'travel') {
const answer = g.truthTravelAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
@@ -4260,6 +4264,9 @@ export function botActions(g: GameView): Action[] {
if (g.homeworldTupileBlocked) return [];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
if (g.turnActionGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.turnActionGuide.action];
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.movementExpressionPromiseGuide.action];
+285 -5
View File
@@ -1,3 +1,6 @@
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf } from './turn-action-promises';
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';
@@ -2165,8 +2168,9 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
travelPromises?: TravelPromise[];
turnActionPromises?: TurnActionPromise[];
/** Accepted order shared by phase-five travel, shipment and movement answers. */
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel'; index: number }[];
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel' | 'bribe' | 'bribeExpression' | 'turnActions'; index: number }[];
mixedTraitorQuestions?: (MixedTraitorClaim & {
event: string; turn: number; player: string; asker: string; answer: boolean;
binding: boolean; fulfilled: boolean; released: boolean;
@@ -6948,7 +6952,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(g.travelPromiseOrder ? { travelPromiseOrder: g.travelPromiseOrder, travelPromises: g.travelPromises } : {}),
...(next.record.question.kind === 'travel' ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(['travel', 'turnActions'].includes(next.record.question.kind) ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(g.turnActionPromises ? { turnActionPromises: g.turnActionPromises, bribePromises: g.bribePromises,
bribeExpressionPromises: g.bribeExpressionPromises, bribePromiseSequence: g.bribePromiseSequence } : {}),
...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}),
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
@@ -15282,6 +15288,8 @@ function hasBribePromises(g: Game, player: string, positive = false): boolean {
}
function recordBribePayment(g: Game, player: string, recipient: string, amount: number) {
if (amount === 0) return;
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionPayment(g.turnActionPromises!, player, g.turn, { recipient, amount }));
bribePromiseRule(() => {
if (g.bribePromises?.length) g.bribePromises = recordPromisedBribe(g.bribePromises, player, g.turn, { recipient, amount });
if (g.bribeExpressionPromises?.length) g.bribeExpressionPromises = recordBribeExpressionPayment(
@@ -15301,6 +15309,8 @@ function bribeQuestionWindow(g: Game): boolean {
function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? [],
expressions = g.bribeExpressionPromises ?? []): Action[] | null {
if (!bribePromiseTurn(g)) return null;
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
return turnActionCompletion({ ...g, bribePromises: promises, bribeExpressionPromises: expressions }, p.id)?.payments ?? null;
for (const required of bribePromiseRule(() => bribeCompletionCandidates(promises, expressions, p.id, g.turn))) {
if (!required.length) return [];
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
@@ -15360,6 +15370,7 @@ function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean,
const promise: BribePromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 };
(g.bribePromises ??= []).push(promise);
(g.bribePromiseSequence ??= []).push(promise.event);
if (g.turnActionPromises) appendTravelOrder(g, 'bribe', g.bribePromises!.length - 1);
reopenBribePayer(g, p, answer);
}
function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) {
@@ -15368,6 +15379,7 @@ function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClai
const promise: BribeExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer, progress: [] };
(g.bribeExpressionPromises ??= []).push(promise);
(g.bribePromiseSequence ??= []).push(promise.event);
if (g.turnActionPromises) appendTravelOrder(g, 'bribeExpression', g.bribeExpressionPromises!.length - 1);
reopenBribePayer(g, p, answer);
}
function compileMixedBribe(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean) {
@@ -15419,6 +15431,7 @@ function projectedMixedBribePromises(g: Game, player: string) {
});
}
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
if (g.turnActionPromises) return; // Joint accepted-order reconciliation owns these rows.
type Promise = BribePromise | BribeExpressionPromise;
const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])]
.filter(promise => !promise.fulfilled && !promise.released);
@@ -15476,12 +15489,13 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
if (hasMovementPromises(trial, p.id, true) &&
!groundMovementCompletion(trial, p.id)) return false;
reconcileRevivalPromises(trial, voluntary);
return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
return liveTurnActionPromises(trial.turnActionPromises ?? [], p.id, trial.turn).length
? !!turnActionCompletion(trial, p.id) : !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
} catch (error) { if (error instanceof BribeExpressionPromiseError || error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
}
function projectedBribes(g: Game, p: Player): BribeOptions {
const available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g);
const constrained = hasBribePromises(g, p.id) ||
const constrained = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length > 0 || 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 ||
hasMovementPromises(g, p.id, true) ||
@@ -18657,6 +18671,234 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
throw error;
}
}
function turnActionRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
}
function turnActionTransportOpen(g: Game, actor: string): boolean {
return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
}
function initializeTurnActionOrder(g: Game) {
initializeTravelOrder(g);
const payments = [
...(g.bribePromises ?? []).map((promise, index) => ({ kind: 'bribe' as const, index, event: promise.event })),
...(g.bribeExpressionPromises ?? []).map((promise, index) => ({ kind: 'bribeExpression' as const, index, event: promise.event })),
];
const sequence = g.bribePromiseSequence ?? [];
payments.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event));
for (const { kind, index } of payments)
if (!g.travelPromiseOrder!.some(entry => entry.kind === kind && entry.index === index))
g.travelPromiseOrder!.push({ kind, index });
}
type TurnActionCompletion = MovementCompletion & { payments: Action[] };
/** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string): TurnActionCompletion | null {
const records = [
...liveTravelPromises(source.travelPromises ?? [], actor, source.turn).map(promise =>
() => travelRule(() => travelBranches(promise))),
...liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).map(promise =>
() => turnActionRule(() => turnActionBranches(promise))),
];
const search = (index: number, leaves: TurnActionBranchLeaf[]): TurnActionCompletion | null => {
if (index < records.length) {
for (const branch of records[index]()) {
const result = search(index + 1, [...leaves, ...branch]);
if (result) return result;
}
return null;
}
const trial = structuredClone(source);
trial.truthtrance = null;
delete trial.turnActionPromises;
delete trial.travelPromises;
delete trial.travelPromiseOrder;
const owner = getPlayer(trial, actor);
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
leaves.forEach((leaf, i) => {
const metadata = { event: `turn-action-witness:${i}`, player: actor, turn: source.turn,
asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
if (leaf.kind === 'payment') trial.bribePromises!.push({ ...metadata,
recipient: leaf.recipient, minimum: leaf.minimum, progress: leaf.progress });
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum });
else trial.movementPromises!.push({ ...metadata, territory: leaf.territory, minimum: leaf.minimum });
});
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
// A quoted payment follows only a currently owned modal continuation;
// permanent restrictions such as Inflation Double remain authoritative.
const ownedInvoice = trial.decision?.kind === 'auctionPayment' && trial.decision.player === actor;
const ownedBox = trial.decision?.kind === 'nullentropy' && trial.pendingNullentropy?.player === actor;
const paymentWindow = ownedInvoice || ownedBox ? { ...trial, decision: null,
pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial;
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, owner) ||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
!trial.players.some(player => player.id === payment.recipient))) continue;
const funded = structuredClone(trial), payer = getPlayer(funded, actor);
try {
for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount);
payer.spice -= total;
const voluntary = { actor, action: { type: 'bribe' } };
for (const payment of required)
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
reconcileBattlePromises(funded, voluntary);
reconcileBidPromises(funded, voluntary);
reconcileRevivalPromises(funded, voluntary);
const transport = [
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
];
const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] };
if (completion) return { ...completion, payments: required.map(payment =>
({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
};
return turnActionRule(() => travelRule(() => search(0, [])));
}
function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: boolean,
event: string, asker: string): TurnActionPromise {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
const open = turnActionTransportOpen(g, p.id);
return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
}
function turnActionAnswerSource(g: Game, player: string): Game {
return g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
}
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id);
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
if (turnActionCompletion(trial, p.id)) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: boolean, asker: string) {
requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole turn-action answer compatible with current funding and native actions.');
const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker);
g.ready = turnActionAnswerSource(g, p.id).ready;
g.turnActionPromises ??= [];
initializeTurnActionOrder(g);
g.turnActionPromises.push(promise);
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
turnActionRule(() => validateTurnActionPromises(g.turnActionPromises!, g.players.map(player => player.id)));
for (const p of g.players) {
for (const turn of new Set(g.turnActionPromises!.filter(promise => promise.player === p.id &&
!promise.fulfilled && !promise.released && (promise.turn !== g.turn || g.status !== 'playing')).map(promise => promise.turn))) {
const finished = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, turn));
requireRule(actor !== p.id || !g.turnActionPromises!.some((promise, index) =>
!promise.released && !promise.fulfilled && finished[index].released),
'You cannot voluntarily close an unfulfilled whole Truthtrance answer.');
g.turnActionPromises = finished;
}
if (g.phase > 5) g.turnActionPromises = turnActionRule(() =>
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
}
for (const promise of g.travelPromises ?? []) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
for (const promise of [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])])
if (!promise.fulfilled && !promise.released && (promise.turn !== g.turn || !bribePromiseTurn(g))) {
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
if (promise.answer) promise.released = true; else promise.fulfilled = true;
}
if (!bribePromiseTurn(g) || g.truthtrance) return;
initializeTurnActionOrder(g);
for (const p of g.players) {
const early = g.phase < 5 || sourcePhase < 5;
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive',
'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
action.type === 'ready' && [2, 4, 7, 8].includes(sourcePhase)));
if (g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard || g.pendingAmbassador ||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove || g.pendingAdvisorMatching ||
g.pendingAutomaticAdvisorFlips?.length || g.phase === 3 && !g.currentAuctionSale &&
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome)) continue;
const trial = { ...g, shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
turnActionPromises: [] as TurnActionPromise[] };
for (const entry of g.travelPromiseOrder!) {
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
turnActions: 'turnActionPromises' } as const)[entry.kind];
const promise = g[key]?.[entry.index];
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
// Each key and row came from the same discriminated accepted-order entry.
(trial[key] as (typeof promise)[]).push(promise);
if (turnActionCompletion(trial, p.id)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted whole turn-action commitment.');
promise.released = true;
log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
}
}
}
function projectedTurnActionPromises(g: Game, player: string) {
return (g.turnActionPromises ?? []).filter(promise => promise.player === player).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 projectedTurnActionGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
if (!promise || g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
g.decision && !ownedBox && !ownedInvoice) return null;
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
if (!action && g.phase <= 5) {
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
if (lot && bidRequired(g.bidPromises ?? [], player.id, lot)) action = projectedBidPromiseGuide(g, player.id)?.action;
const revival = [
...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn),
...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn),
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
].some(row => row.answer);
if (!action && revival && !ownedBox) action = (projectedRevivalExpressionPromiseGuide(g, player.id) ??
projectedLeaderRevivalPromiseGuide(g, player.id) ?? projectedRevivalPromiseGuide(g, player.id))?.action;
if (!action && g.phase < 5) {
const current = completion.early?.find(step => step.phase === g.phase)?.action;
if (g.phase === 3) {
if (ownedInvoice) action = current?.type === 'decision' ? current : { type: 'decision', karama: false };
else if (g.auction?.active === player.id) action = { type: 'passBid' };
} else if ([2, 4].includes(g.phase) && !g.ready.includes(player.id) &&
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id))) action = current ?? { type: 'ready' };
} else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed)
action = completion.actions[0] ?? { type: 'endMovement' };
}
if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' };
if (!action) return null;
const packet = { ...structuredClone(action) };
for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key];
return { event: promise.event, action: { ...packet, turnActionPromiseEvent: promise.event, turnActionPromisePhase: g.phase } };
}
function travelRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
@@ -18688,6 +18930,8 @@ 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 {
if (liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).length)
return turnActionCompletion({ ...source, movementPromises: [...movements], movementExpressionPromises: [...expressions], shipmentPromises: [...shipments] }, actor);
const travel = liveTravelPromises(source.travelPromises ?? [], actor, source.turn);
const search = (index: number, leaves: TravelBranchLeaf[]): MovementCompletion | null => {
if (index < travel.length) {
@@ -18746,6 +18990,7 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
if (g.turnActionPromises) { reconcileTurnActionPromises(g, actor, action, sourcePhase); return; }
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 &&
@@ -18827,6 +19072,8 @@ function projectedTravelGuide(g: Game, player: Player): { event: string; action:
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
if (liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).length)
return turnActionCompletion({ ...source, movementPromises: [...promises], movementExpressionPromises: [...expressions] }, actor);
return liveTravelPromises(source.travelPromises ?? [], actor, source.turn).length
? travelCompletion(source, actor, promises, expressions) : groundMovementCompletionNative(source, actor, promises, expressions);
}
@@ -18975,7 +19222,10 @@ 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) ||
liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer);
liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer) ||
// Whole turn answers share resource/preflight checks, but may retain a
// payment alternative after transport: they do not force movement at end.
!positive && liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length > 0;
}
function movementExpressionIntegrity(g: Game) {
try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); }
@@ -19140,6 +19390,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.turnActionPromises) projected.turnActionPromises = turnActionRule(() =>
recordTurnActionMovement(projected.turnActionPromises!, p.id, g.turn, event));
if (projected.travelPromises) projected.travelPromises = travelRule(() =>
recordTravelMovement(projected.travelPromises!, p.id, g.turn, event));
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
@@ -19150,6 +19402,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
}
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
if (move.source === 'ambassador') return;
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionMovement(g.turnActionPromises!, p.id, g.turn, groundMovementEvent(move)));
if (g.travelPromises) g.travelPromises = travelRule(() =>
recordTravelMovement(g.travelPromises!, p.id, g.turn, groundMovementEvent(move)));
const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? [];
@@ -19622,6 +19876,10 @@ 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.turnActionPromises) {
g.turnActionPromises = turnActionRule(() => closeTurnActionTransport(g.turnActionPromises!, id, g.turn));
requireRule(!!turnActionCompletion(g, id), 'Complete your whole Truthtrance transport or retain a funded payment alternative.');
}
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);
@@ -27657,6 +27915,8 @@ function finishShipmentPromises(
shipment: { territory: string; amount: number } | null,
) {
checkShipmentPromises(g, p, shipment);
if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
if (g.phase === 5 && 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(
@@ -27679,6 +27939,8 @@ 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.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, null));
if (g.phase === 5 && 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;
@@ -36357,6 +36619,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.turnActionPromiseEvent !== undefined || action.turnActionPromisePhase !== undefined) {
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
'This turn-action guide is stale or belongs to another opportunity.');
}
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
const guide = projectedTravelGuide(state, getPlayer(state, id));
requireRule(guide && action.travelPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
@@ -37104,6 +37371,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
turnActionAnswers: turnActionTruthAnswers,
bindTurnAction: bindTurnActionTruth,
travelAnswers: travelTruthAnswers,
bindTravel: bindTravelTruth,
mixedTraitorAnswers: mixedTraitorTruthAnswers,
@@ -40745,6 +41014,12 @@ function applyActionInner(
if (t === 'ready') {
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
if (bribeWindowCloses && g.turnActionPromises) {
const closed = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn));
requireRule(!g.turnActionPromises.some((promise, index) => promise.player === p.id && promise.turn === g.turn &&
!promise.fulfilled && !promise.released && closed[index].released),
'Complete your whole Truthtrance payment answer before closing this turn’s payments.');
}
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
: 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.');
@@ -40897,6 +41172,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),
truthTurnActionTargets: g.players.filter(p => turnActionTargetAvailable(g, p.id)).map(p => p.id),
truthTurnActionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'turnActions' &&
g.truthtrance.question.target === id ? turnActionTruthAnswers(g, me, g.truthtrance.question) : null,
turnActionPromises: projectedTurnActionPromises(g, id),
turnActionGuide: projectedTurnActionGuide(g, me),
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,
+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-turn-actions',
title: 'Truthtrance: combined payments, shipment and movements',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine ordinary future payments and native physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine this turn’s payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
'Only actual ordinary bribes paid by the respondent after answering add payment progress. Payments to the same recipient accumulate. Shipment fees, gifts and later receipt of incoming bribes do not count. Native recipient restrictions, costs and receipt timing remain.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts'] },
],
},
{
id: 'truthtrance-travel',
title: 'Truthtrance: combined shipment and ground movements',
@@ -3815,6 +3837,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-turn-actions',
'truthtrance-travel',
'truthtrance-movement-expression',
'truthtrance-mixed-movement',
+30
View File
@@ -1,4 +1,6 @@
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
type TurnActionClaim } from './turn-action-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';
@@ -79,6 +81,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'turnActions'; target: string } & TurnActionClaim)
| ({ kind: 'travel'; target: string } & TravelClaim)
| ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim)
| ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim)
@@ -372,6 +375,20 @@ 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 === 'turnActions') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete turn-action question and a supported quiet native window.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseTurnActionLeaf(input);
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
return action;
}, { requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES });
return { kind: 'turnActions', target: v.target as string, turn: g.turn, mixed };
}
if (v.kind === 'travel') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' &&
Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && v.turn === g.turn,
@@ -681,6 +698,9 @@ 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 === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => action.kind === 'payment' ? `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}` :
`you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)} before your Shipment and Movement turn ends`)}?`;
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,
@@ -754,6 +774,8 @@ export function resolveTruthAction(
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;
turnActionAnswers?: (g: Game, p: Player, claim: TurnActionClaim) => TruthAnswer[];
bindTurnAction?: (g: Game, p: Player, claim: TurnActionClaim, 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[];
@@ -924,6 +946,9 @@ export function resolveTruthAction(
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 === 'turnActions')
check(effects.turnActionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole turn-action question compatible with native opportunities and earlier answers.');
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.');
@@ -1053,6 +1078,11 @@ export function resolveTruthAction(
'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 === 'turnActions') {
check(effects.turnActionAnswers && effects.bindTurnAction && effects.turnActionAnswers(g, p, q).includes(a.answer),
'Choose a whole turn-action answer compatible with current facts, native actions and earlier commitments.');
if (a.answer !== 'unknown') effects.bindTurnAction(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.');
+408
View File
@@ -0,0 +1,408 @@
import { TERRITORIES } from './board';
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
type CompiledMixedAction,
type MixedActionExpression,
} from './mixed-action-question';
import {
parseTravelActionClaim,
TravelPromiseError,
type TravelActionClaim,
type TravelBranchLeaf,
type TravelEvent,
} from './travel-expression-promises';
import type { TruthFact } from './truthtrance';
export const TURN_ACTION_MAX_LEAVES = 16;
export const TURN_ACTION_MAX_DEPTH = 4;
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number };
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
export type TurnActionPayment = { recipient: string; amount: number };
export type TurnActionBranchLeaf = TravelBranchLeaf |
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean };
export type TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
/** Facts are frozen by the game-aware compiler when this answer is accepted. */
compiled: CompiledMixedAction<TurnActionLeaf>;
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest physical ground group after this answer, never a sum of groups. */
progress: { territory: string; maximum: number }[];
/** Actual completed ordinary payer actions after this answer. */
payments: TurnActionPayment[];
transportClosed: boolean;
paymentClosed: boolean;
fulfilled: boolean;
released: boolean;
};
export class TurnActionError extends Error {
constructor(message: string) {
super(message);
this.name = 'TurnActionError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new TurnActionError(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 whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const physical = (value: unknown): value is number => whole(value) && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Public leaves carry only their native destination/recipient and minimum. */
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
check(object(value), 'Choose one future reserve shipment, ground movement or ordinary bribe payment.');
try {
if (value.kind !== 'payment') return parseTravelActionClaim(value);
check(keys(value, 'kind,minimum,recipient'), 'Supply only the ordinary payment recipient and minimum.');
const claim = parseBribeClaim({ turn: 1, recipient: value.recipient, minimum: value.minimum });
return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum };
} catch (error) {
if (error instanceof TravelPromiseError || error instanceof BribePromiseError)
throw new TurnActionError(error.message);
throw error;
}
}
/** The engine owns game-aware fact decoding; retain and bound its original tree. */
function storedFact(value: unknown, depth = 0): TruthFact {
check(object(value) && identifier(value.kind) && depth <= TURN_ACTION_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 <= TURN_ACTION_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: TurnActionLeaf): string {
return JSON.stringify(action.kind === 'payment'
? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]);
}
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TurnActionLeaf> {
let leaves = 0;
const read = (input: unknown, depth: number): CompiledMixedAction<TurnActionLeaf> => {
check(object(input) && depth <= TURN_ACTION_MAX_DEPTH,
'Retain the bounded private answer-time turn-action expression.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= TURN_ACTION_MAX_LEAVES,
'Retain the private answer-time AND/OR turn-action groups.');
return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) };
}
check(++leaves <= TURN_ACTION_MAX_LEAVES, 'Retain at most sixteen private turn-action 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 future action.');
const action = parseTurnActionLeaf(input.action);
check(originals.has(actionKey(action)), 'A private future-action 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: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
check(object(promise) && keys(promise,
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn') &&
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
'Retain the complete original turn-action commitment and its metadata.');
let mixed: MixedActionExpression<TurnActionLeaf>;
try {
mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTurnActionLeaf, {
requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES,
});
} catch (error) {
if (error instanceof MixedActionQuestionError) throw new TurnActionError(error.message);
throw error;
}
const originals = new Set<string>();
const destinations = new Set<string>();
const recipients = new Set<string>();
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
} 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.transportClosed === 'boolean' &&
typeof promise.paymentClosed === 'boolean' && !(promise.fulfilled && promise.released),
'Retain a definite whole turn-action 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.transportClosed || !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 seenDestinations = 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) && !seenDestinations.has(entry.territory),
'Retain unique named movement destinations and actual physical group maxima.');
seenDestinations.add(entry.territory);
}
check(Array.isArray(promise.payments), 'Retain this answer’s cumulative ordinary payments.');
const seenRecipients = new Set<string>();
for (const payment of promise.payments) {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
recipients.has(payment.recipient) && whole(payment.amount) && !seenRecipients.has(payment.recipient),
'Retain unique named recipients and safe whole cumulative ordinary payment amounts.');
seenRecipients.add(payment.recipient);
}
return compiled;
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'payment') return (promise.payments.find(payment =>
payment.recipient === action.recipient)?.amount ?? 0) >= action.minimum;
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<TurnActionLeaf>, promise: TurnActionPromise): 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));
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship'
? !promise.shipment.pending : promise.transportClosed;
}
function relevantClosed(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
if (node.kind === 'constant') return true;
if (node.kind === 'action') return actionClosed(node.action, promise);
return node.terms.every(term => relevantClosed(term, promise));
}
/** Uses only this answer interval's actual events and private frozen facts. */
export function turnActionValue(promise: TurnActionPromise): boolean {
return evaluate(readPromise(promise), promise);
}
/** Whether the original question names payments, including folded alternatives. */
export function turnActionHasPayments(promise: TurnActionPromise): boolean {
readPromise(promise);
const visit = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' : node.kind !== 'fact' && node.terms.some(visit);
return visit(promise.mixed);
}
export function liveTurnActionPromises<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
check(Array.isArray(promises) && identifier(player) && positive(turn),
'Choose the turn-action respondent and turn.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
return left.kind === right.kind && (left.kind === 'payment'
? right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress
: right.kind !== 'payment' && left.territory === right.territory);
}
/** Called within one record only: payment progress belongs to that interval. */
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => sameAction(other, leaf) && 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 => sameAction(other, leaf) && other.answer);
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
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; native ledgers retain costs, entitlements and feasibility. */
export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnActionBranchLeaf[]> {
const compiled = readPromise(promise);
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: CompiledMixedAction<TurnActionLeaf>, answer: boolean): Generator<TurnActionBranchLeaf[]> {
if (node.kind === 'constant') { if (node.value === answer) yield []; return; }
if (node.kind === 'action') {
const action = node.action;
const achieved = actionValue(action, promise);
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
yield [action.kind === 'payment' ? { ...action, answer,
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }
: { ...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: TurnActionBranchLeaf[]): Generator<TurnActionBranchLeaf[]> {
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 TurnActionPromise>(promise: T, update: T): T {
const compiled = readPromise(update);
const satisfied = evaluate(compiled, update);
check(promise.answer || !satisfied, 'This action conflicts with your whole Truthtrance turn-action No answer.');
return promise.answer && satisfied || !promise.answer && relevantClosed(compiled, update)
? { ...update, fulfilled: true } : update;
}
/** Exactly one original completed future reserve shipment or actual skip. */
export function recordTurnActionShipment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent | null): T[] {
if (event !== null) validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
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);
}
/** Only an ordinary physical ground group counts, never a shipment or marker. */
export function recordTurnActionMovement<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent): T[] {
validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.transportClosed || event.amount === 0) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'move' && node.action.territory === event.territory
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) 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);
}
/** Call only at the actual completed ordinary payer action, never at receipt. */
export function recordTurnActionPayment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, payment: TurnActionPayment): T[] {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
'Retain the exact recipient and positive safe whole ordinary payment.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.paymentClosed) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' && node.action.recipient === payment.recipient
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) continue;
const old = promise.payments.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
const amount = old + payment.amount;
check(Number.isSafeInteger(amount), 'The cumulative ordinary payment exceeds the safe integer range.');
const payments = promise.payments.map(entry => ({ ...entry }));
const entry = payments.find(candidate => candidate.recipient === payment.recipient);
if (entry) entry.amount = amount;
else payments.push({ recipient: payment.recipient, amount });
updates.set(promise, settle(promise, { ...promise, payments }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Close transport only; a later ordinary payment can still complete the tree.
* The native joint solver, rather than this ledger, rejects own illegal closure. */
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
return settle(promise, { ...promise, transportClosed: true,
shipment: { ...promise.shipment, pending: false } });
});
}
/** Actual turn expiry closes every family. The engine owns own-expiry preflight;
* an externally impossible unsatisfied answer becomes a released terminal row. */
export function finishTurnActionTurn<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
const closed = { ...promise, transportClosed: true, paymentClosed: true,
shipment: { ...promise.shipment, pending: false } };
const fulfilled = turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
}
/** Structure/progress only: root retains fact authority, nonally targeting and
* native payment/force legality. Impossible live Yes routes are not released here. */
export function validateTurnActionPromises(promises: readonly TurnActionPromise[], players: readonly string[]): void {
check(Array.isArray(promises) && Array.isArray(players), 'Retain the turn-action commitments and participants.');
const events = new Set<string>();
for (const promise of promises) {
const compiled = readPromise(promise);
const satisfied = evaluate(compiled, promise);
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
: node.kind === 'fact' || node.terms.every(recipientsValid);
check(!events.has(promise.event) && players.includes(promise.player) && players.includes(promise.asker) &&
promise.player !== promise.asker && recipientsValid(promise.mixed) &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
(!(promise.transportClosed && promise.paymentClosed) || promise.fulfilled)),
'The turn-action commitment lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}