Combine current battle decisions with turn commitments

This commit is contained in:
Quintine committed 2026-10-26 23:11:48 +11:00
1 parent 1a79fd3131
commit f76c8dfb93
15 files changed
+1380 -66

No files matched your search

+165 -8
View File
@@ -1,7 +1,8 @@
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
closeTurnActionRevival, settleTurnActionAlliance, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } 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';
@@ -600,6 +601,7 @@ import {
} from './charity';
import {
PlanClaimError,
matchesPlanClaim,
respectsBattlePromises,
type PlanClaim,
type BattlePromise,
@@ -15358,7 +15360,9 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?
}
function bribeCompatibleLaterPromises(g: Game, p: Player,
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'movementExpressionPromises' | 'battle'>): boolean {
return !hasBribePromises(g, p.id, true) || !!bribePromiseCompletion({ ...g, ...later }, p);
return !hasBribePromises(g, p.id, true) &&
!liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
!!bribePromiseCompletion({ ...g, ...later }, p);
}
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
@@ -18711,6 +18715,78 @@ function turnActionTargetAvailable(g: Game, _actor: string): boolean {
function turnActionTransportOpen(g: Game, actor: string): boolean {
return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
}
/** Read only the original predicates while the native sealed cards are still
* held. Receipts contain booleans, never copies of private hands or plans. */
function turnActionPlanValues(g: Game, actor: string, expressions: readonly TurnActionClaim['mixed'][]): TurnActionPlanValue[] {
const battle = g.battle, plan = battle?.plans[actor];
requireRule(battle?.event && plan, 'Retain the actual original submitted battle plan.');
const owner = getPlayer(g, actor), sealed = { ...plan, kwisatz: !!plan.kwisatz };
const values = new Map<string, TurnActionPlanValue>();
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
const action = node.action;
if (action.kind !== 'battlePlan' || action.battle !== battle.event || action.territory !== battle.territory) return;
const value = matchesPlanClaim(action.claim, sealed, owner.hand);
requireRule(typeof value === 'boolean', 'Evaluate every original predicate against the actual native plan.');
values.set(JSON.stringify(action.claim), { claim: structuredClone(action.claim), value });
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
expressions.forEach(visit);
return [...values.values()];
}
function recordTurnActionRevealedPlans(g: Game) {
const battle = g.battle;
if (!g.turnActionPromises || !battle?.event || !battle.revealed) return;
for (const actor of [battle.attacker, battle.defender]) {
const records = liveTurnActionPromises(g.turnActionPromises, actor, g.turn).filter(promise => {
const combat = promise.combat;
return combat && combat.event === battle.event && combat.territory === battle.territory &&
combat.plan && !combat.plan.closed;
});
if (!records.length || !battle.plans[actor]) continue;
const values = turnActionPlanValues(g, actor, records.map(promise => promise.mixed));
g.turnActionPromises = turnActionRule(() => recordTurnActionPlan(g.turnActionPromises!, actor,
g.turn, battle.event!, battle.territory, values));
}
}
/** A disappeared original opportunity is absence, never a synthetic zero plan
* or Decline. Declaration effects can later change; only the native hook
* records a declaration receipt. */
function reconcileTurnActionCombat(g: Game) {
if (!g.turnActionPromises) return;
recordTurnActionRevealedPlans(g);
g.turnActionPromises = g.turnActionPromises.map(promise => {
const combat = promise.combat;
if (!combat || promise.fulfilled || promise.released) return promise;
const current = g.status === 'playing' && g.turn === promise.turn && g.phase === 6 &&
g.battle?.event === combat.event && g.battle.territory === combat.territory;
const context = current && combat.traitor && !combat.traitor.closed
? traitorTruthContext(g, promise.player) : null;
const lostTraitor = combat.traitor && !combat.traitor.closed && (!context ||
context.submitted !== undefined || context.beneficiary !== combat.traitor.beneficiary ||
context.target !== combat.traitor.target || context.leader !== combat.traitor.leader);
return !current || lostTraitor
? turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0] : promise;
});
}
function turnActionTraitorChoices(g: Game, actor: string, choices: boolean[]): boolean[] {
if (!liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise =>
promise.combat?.traitor && !promise.combat.traitor.closed)) return choices;
const context = traitorTruthContext(g, actor), battle = g.battle;
if (!context || context.submitted !== undefined || !battle?.event) return choices;
return choices.filter(call => {
try {
const promises = turnActionRule(() => recordTurnActionTraitor(g.turnActionPromises!, actor,
g.turn, battle.event!, battle.territory, call));
const trial = { ...g, turnActionPromises: promises, battle: { ...battle,
traitorCalls: { ...battle.traitorCalls, [actor]: call },
traitorDecisionPromises: traitorDecisionRule(() =>
settleTraitorDecisionPromises(context, battle.traitorDecisionPromises ?? [], call)),
} };
return !!turnActionCompletion(trial, actor);
} catch (error) { if (error instanceof RuleError) return false; throw error; }
});
}
function initializeTurnActionOrder(g: Game) {
initializeTravelOrder(g);
const payments = [
@@ -18799,7 +18875,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
return !!turnActionCompletion(source, player.id);
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[] };
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: 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, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
@@ -18826,6 +18902,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
let invalidCombat = false, requestedTraitor = false;
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 };
@@ -18842,7 +18919,27 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
else if (leaf.kind === 'leader') (trial.leaderRevivalPromises ??= []).push({ ...metadata, leader: leaf.leader });
else if (leaf.kind === 'alliance') (trial.alliancePromises ??= []).push({ ...metadata,
source: leaf.source, partner: leaf.partner });
else if (leaf.kind === 'battlePlan') {
const battle = trial.battle;
if (!battle || battle.event !== leaf.battle || battle.territory !== leaf.territory ||
![battle.attacker, battle.defender].includes(actor)) invalidCombat = true;
else (battle.truthPromises ??= []).push({ event: metadata.event, player: actor,
asker: metadata.asker, claim: leaf.claim, answer: leaf.answer });
} else if (leaf.kind === 'traitorCall') {
const context = traitorTruthContext(trial, actor);
if (!context || context.submitted !== undefined || context.battle !== leaf.battle ||
trial.battle?.territory !== leaf.territory || context.beneficiary !== leaf.beneficiary ||
context.target !== leaf.target || context.leader !== leaf.leader) invalidCombat = true;
else {
requestedTraitor = true;
(trial.battle!.traitorDecisionPromises ??= []).push({ event: metadata.event,
turn: trial.turn, battle: context.battle, player: actor, asker: metadata.asker,
beneficiary: context.beneficiary, target: context.target, leader: context.leader,
answer: leaf.answer ? leaf.call : !leaf.call });
}
}
});
if (invalidCombat) return null;
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
@@ -18882,6 +18979,21 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
for (const payment of required)
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
reconcileBattlePromises(funded, voluntary);
const battle = funded.battle;
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer) : undefined;
if (needsPlan && !battlePlan) continue;
const traitorContext = traitorTruthContext(funded, actor);
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
promise.player === actor && !promise.fulfilled && !promise.released);
let traitor: Action | undefined;
if (needsTraitor) {
if (!traitorContext || traitorContext.submitted !== undefined) continue;
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
if (!choices.length) continue;
traitor = { type: 'traitorCall', call: choices[0] };
}
reconcileBidPromises(funded, voluntary);
reconcileRevivalPromises(funded, voluntary);
const lot = bidOpportunity(funded);
@@ -18904,6 +19016,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
if (completion) {
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
}
@@ -18919,11 +19032,13 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
const open = turnActionTransportOpen(g, p.id);
let bid = false, revival = false, alliance = false;
let bid = false, revival = false, alliance = false, plan = false, traitor = false;
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
if (node.action.kind === 'bid') bid = true;
if (node.action.kind === 'alliance') alliance = true;
if (node.action.kind === 'battlePlan') plan = true;
if (node.action.kind === 'traitorCall') traitor = true;
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
@@ -18937,12 +19052,24 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 ||
g.phase === 4 && !!g.revivalOrder && !g.revivalOrder.remaining.includes(p.id) } : null,
} : undefined;
return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
const battle = plan || traitor ? g.battle : null;
requireRule(!plan && !traitor || !!battle?.event, 'Retain the original current battle.');
const context = traitor ? traitorTruthContext(g, p.id) : null;
requireRule(!traitor || context && context.submitted === undefined, 'Retain the original unsubmitted Traitor decision.');
const combat: TurnActionCombat | undefined = battle?.event ? { event: battle.event, territory: battle.territory,
plan: plan ? { closed: false, values: null } : null,
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
closed: false, call: null } : null } : undefined;
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
...(combat ? { combat } : {}),
...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
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 };
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
return promise;
}
function turnActionAnswerSource(g: Game, player: string, alliance = false): Game {
return g.phase === 1 && alliance || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
@@ -18953,7 +19080,9 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [], consent = { value: false };
for (const answer of [true, false]) {
const promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id);
let promise: TurnActionPromise;
try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); }
catch (error) { if (error instanceof RuleError) continue; throw error; }
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
@@ -18973,6 +19102,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
reconcileTurnActionCombat(g);
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 &&
@@ -19083,6 +19213,15 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
} else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed)
action = completion.actions[0] ?? { type: 'endMovement' };
}
if (!action && g.phase === 6 && g.battle) {
const battle = g.battle;
if (completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
(!battle.preLeader || battle.preLeader.closed) && !battle.preparation &&
(!battle.fullPlan || battle.plans[battle.fullPlan.target] || battle.fullPlan.target === player.id))
action = completion.battlePlan.actions[0] ?? { type: 'battlePlan', ...completion.battlePlan.plan };
if (!action && completion.traitor && battle.revealed && battle.traitorCalls[player.id] === undefined)
action = completion.traitor;
}
if (!action && g.phase === 1 && allianceQuestionWindow(g) && !g.ready.includes(player.id))
action = completion.alliance?.[0] ?? { type: 'ready' };
if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' };
@@ -28854,7 +28993,8 @@ function traitorDecisionRule<T>(operation: () => T): T {
}
function traitorTruthAnswers(g: Game, p: Player): TruthAnswer[] {
const context = traitorTruthContext(g, p.id);
return context ? traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? []))
return context ? turnActionTraitorChoices(g, p.id,
traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? [])))
.map(call => call ? 'yes' as const : 'no' as const) : [];
}
function bindTraitorTruth(g: Game, p: Player, answer: boolean, asker: string, event?: string) {
@@ -28950,6 +29090,12 @@ function reconcileTraitorTruth(g: Game, actor?: string, action?: Action) {
reconcileMixedTraitorQuestions(g);
}
function traitorReturnPromiseBlock(g: Game, p: Player, returned: readonly string[]): string | null {
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).some(promise =>
promise.combat?.traitor && !promise.combat.traitor.closed)) {
const trial = structuredClone(g);
getPlayer(trial, p.id).traitors = p.traitors.filter(card => !returned.includes(card));
if (!turnActionCompletion(trial, p.id)) return 'Keep the identity needed by your whole Traitor declaration answer, or retain another legal alternative.';
}
const promises = g.battle?.traitorDecisionPromises;
if (!promises?.some(promise => promise.player === p.id && promise.answer && !promise.fulfilled && !promise.released)) return null;
const context = traitorTruthContext(g, p.id, p.traitors.filter(card => !returned.includes(card)));
@@ -40992,6 +41138,7 @@ function applyActionInner(
revealPlayerNoField(g, owner, 'battle');
}
b.revealed = true;
recordTurnActionRevealedPlans(g);
reconcileMixedBattleQuestions(g);
for (const plan of Object.values(b.plans)) {
const l = g.players
@@ -41029,6 +41176,15 @@ function applyActionInner(
}
if (action.call && b.traitorDeclarationVersion)
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
if (g.turnActionPromises && b.event) {
requireRule(!liveTurnActionPromises(g.turnActionPromises, id, g.turn).some(promise => {
const combat = promise.combat, stage = combat?.traitor;
return combat && combat.event === b.event && combat.territory === b.territory && stage && !stage.closed &&
(!context || stage.beneficiary !== context.beneficiary || stage.target !== context.target || stage.leader !== context.leader);
}), 'Retain the original Traitor beneficiary, target and opposing leader.');
g.turnActionPromises = turnActionRule(() => recordTurnActionTraitor(g.turnActionPromises!, id,
g.turn, b.event!, b.territory, action.call as boolean));
}
b.traitorCalls[id] = action.call;
if (action.call && g.moritaniAssassinate?.owner === id) {
requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.');
@@ -42642,7 +42798,8 @@ export function viewGame(state: Game, id: string) {
: {},
traitorSubmitted: Object.keys(b.traitorCalls),
traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context
? traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? [])) : null; })(),
? turnActionTraitorChoices(g, id,
traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? []))) : null; })(),
ownTraitorPromises: (b.traitorDecisionPromises ?? []).filter(promise => promise.player === id && !isMixedTraitorEvent(g, promise.event)),
traitorVoters: traitorVoters(g, b),
preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice'
+7 -5
View File
@@ -3189,9 +3189,9 @@ export const RULE_TOPICS: RuleTopic[] = [
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined alliances, bids, Revival, payments and transport',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine final current-Nexus membership, ordinary current-lot bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
summary: 'Combine current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine this turn’s alliances, bids, Revival, payments and transport. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native current-lot bid, ordinary Revival, funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
'Choose Combine the current battle with this turn’s alliances, bids, Revival, 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 native battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
@@ -3199,6 +3199,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'An ordinary-bid clause names the current public lot and counts the maximum new accepted declaration after answering. Earlier bids, summed bids and later purchase invoices do not count. A pass can reset after another bid, so No stays binding through reentry until that original physical lot closes.',
'Ordinary force Revival clauses accumulate future total, normal or elite physical returns. Personal native leader clauses include eligible Advanced Atreides Kwisatz. Prior returns, Ghola, foreign leaders and special returns do not count. Original costs, slots, cycles and actual return controls remain.',
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
'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.',
@@ -3206,11 +3208,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native final-alliance, bid, Revival, 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: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native battle, Traitor, final-alliance, bid, Revival, 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', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts'] },
{ 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', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-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', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts'] },
{ 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', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts'] },
],
},
{
+20
View File
@@ -384,6 +384,24 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
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 === 'battlePlan' || action.kind === 'traitorCall') {
const battle = g.battle;
check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 &&
action.battle === battle.event && action.territory === battle.territory,
'Choose this original current battle event and territory.');
if (action.kind === 'battlePlan') {
check([battle.attacker, battle.defender].includes(respondent.id), 'Ask an actual combatant about their own current native plan.');
action.claim = parseBattleActionClaim(action.claim, battleLeaderIdentities(g).map(leader => leader.id));
} else {
const voters = [battle.attacker, battle.defender];
if (battle.ecazOccupy?.lead) for (const id of [battle.ecazOccupy.ecaz, battle.ecazOccupy.ally])
if (!voters.includes(id)) voters.push(id);
const harkonnen = g.players.find(player => player.faction === 'harkonnen');
if (harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id)) voters.push(harkonnen.id);
check(battle.revealed && voters.includes(respondent.id) && battle.traitorCalls[respondent.id] === undefined,
'Choose an actual current revealed, unsubmitted native Traitor voter.');
}
}
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.');
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
@@ -713,6 +731,8 @@ export function truthQuestionText(
action => {
if (action.kind === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
if (action.kind === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
if (action.kind === 'traitorCall') return `you actually declare ${action.call ? 'Reveal' : 'Decline'} in this original battle in ${locationName(action.territory)} (later prevention does not change the declaration)`;
if (action.kind === 'alliance') return `you will ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)} (final membership; temporary offers or pairs do not settle this clause)`;
if (action.kind === 'forces' || action.kind === 'leader') return `you ${revivalExpressionText(action,
id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your ordinary Revival opportunity ends this turn`;
+174 -8
View File
@@ -4,6 +4,8 @@ import { BidPromiseError, parseBidClaim } from './bid-promises';
import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises';
import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question';
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
import { PlanClaimError } from './battle-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
@@ -23,7 +25,9 @@ export const TURN_ACTION_MAX_LEAVES = 16;
export const TURN_ACTION_MAX_DEPTH = 4;
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string };
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean };
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
export type TurnActionPayment = { recipient: string; amount: number };
export type TurnActionBranchLeaf = TravelBranchLeaf |
@@ -31,13 +35,23 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
{ kind: 'bid'; lot: number; source: string; minimum: number; answer: boolean } |
{ kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } |
{ kind: 'leader'; leader: string; answer: boolean } |
{ kind: 'alliance'; partner: string; source: string; answer: boolean };
{ kind: 'alliance'; partner: string; source: string; answer: boolean } |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
beneficiary: string; target: string; leader: string | null };
export type TurnActionPreparation = {
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
};
export type TurnActionBid = { lot: number; source: string; amount: number };
export type TurnActionAlliance = { source: string; partner: string | null; closed: boolean };
export type TurnActionPlanValue = { claim: MixedBattleActionClaim; value: boolean };
export type TurnActionCombat = {
event: string;
territory: string;
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
};
export type TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
@@ -58,6 +72,7 @@ export type TurnActionPromise = TurnActionClaim & {
preparation?: TurnActionPreparation;
/** Final membership is recorded only by the original Nexus closing event. */
alliance?: TurnActionAlliance;
combat?: TurnActionCombat;
};
export class TurnActionError extends Error {
@@ -84,10 +99,27 @@ const physical = (value: unknown): value is number => whole(value) && value <= 2
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Identity membership is decoded by Truthtrance; here retain one strict atom. */
function parseCombatClaim(value: unknown): MixedBattleActionClaim {
try {
return parseBattleActionClaim(value, object(value) && value.kind === 'leader' && identifier(value.leader)
? [value.leader] : []);
} catch (error) { if (error instanceof PlanClaimError) throw new TurnActionError(error.message); throw error; }
}
const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseCombatClaim(claim));
/** Public leaves carry only their native destination/recipient and minimum. */
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.');
try {
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.');
if (value.kind === 'battlePlan') return { kind: 'battlePlan', battle: value.battle,
territory: value.territory, claim: parseCombatClaim(value.claim) };
check(typeof value.call === 'boolean', 'Choose the actual Reveal or Decline declaration.');
return { kind: 'traitorCall', battle: value.battle, territory: value.territory, call: value.call };
}
if (value.kind === 'alliance') {
check(keys(value, 'kind,partner'), 'Supply only one final Nexus alliance partner.');
const claim = parseAllianceClaim({ turn: 1, partner: value.partner });
@@ -126,6 +158,8 @@ function storedFact(value: unknown, depth = 0): TruthFact {
}
function actionKey(action: TurnActionLeaf): string {
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]);
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
@@ -170,6 +204,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn'.split(',');
if ('preparation' in promise) fields.push('preparation');
if ('alliance' in promise) fields.push('alliance');
if ('combat' in promise) fields.push('combat');
check(keys(promise, fields.sort().join(',')) &&
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
'Retain the complete original turn-action commitment and its metadata.');
@@ -188,6 +223,9 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const lots = new Set<number>();
let hasRevival = false;
let hasAlliance = false;
const combatSources = new Set<string>();
const planClaims = new Set<string>();
let hasTraitor = false;
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
@@ -196,12 +234,47 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
if (node.action.kind === 'bid') lots.add(node.action.lot);
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
if (node.action.kind === 'alliance') hasAlliance = true;
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall')
combatSources.add(JSON.stringify([node.action.battle, node.action.territory]));
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
if (node.action.kind === 'traitorCall') hasTraitor = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
check(!lots.size || !!promise.preparation?.bid, 'Retain the original private lot binding for every ordinary bid leaf.');
check(!hasRevival || !!promise.preparation?.revival, 'Retain this answer’s ordinary Revival interval.');
check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.');
check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.');
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
if ('combat' in promise) {
const combat = promise.combat;
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
identifier(combat.territory) && [...combatSources].every(source => source === JSON.stringify([combat.event, combat.territory])),
'Retain one original current combat event and territory.');
if (combat.plan !== null) {
check(object(combat.plan) && keys(combat.plan, 'closed,values') && typeof combat.plan.closed === 'boolean' &&
(combat.plan.closed || combat.plan.values === null), 'Retain an open plan or an actual closed plan receipt.');
if (combat.plan.values !== null) {
check(Array.isArray(combat.plan.values) && combat.plan.values.length === planClaims.size,
'Retain every distinct original plan predicate exactly once.');
const seen = new Set<string>();
for (const entry of combat.plan.values) {
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual native plan values.');
const key = planKey(entry.claim);
check(planClaims.has(key) && !seen.has(key), 'Retain only unique original plan predicates in the actual receipt.');
seen.add(key);
}
}
}
if (combat.traitor !== null) {
const traitor = combat.traitor;
check(object(traitor) && keys(traitor, 'beneficiary,call,closed,leader,target') && identifier(traitor.beneficiary) &&
identifier(traitor.target) && traitor.beneficiary !== traitor.target &&
(traitor.leader === null || identifier(traitor.leader)) && typeof traitor.closed === 'boolean' &&
(traitor.call === null || typeof traitor.call === 'boolean') && (traitor.closed || traitor.call === null),
'Retain the public original Traitor context and actual declaration only after it closes.');
}
}
if ('alliance' in promise) {
const alliance = promise.alliance;
check(object(alliance) && keys(alliance, 'closed,partner,source') && identifier(alliance.source) &&
@@ -257,6 +330,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed &&
promise.combat.traitor.call !== null && promise.combat.traitor.call === action.call;
if (action.kind === 'alliance') return !!promise.alliance?.closed && promise.alliance.partner === action.partner;
if (action.kind === 'bid') return (promise.preparation?.bid?.maximum ?? 0) >= action.minimum;
if (action.kind === 'forces') return (promise.preparation?.revival?.[action.counter] ?? 0) >= action.minimum;
@@ -276,6 +353,8 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
if (action.kind === 'alliance') return promise.alliance!.closed;
if (action.kind === 'bid') return promise.preparation!.bid!.closed;
if (action.kind === 'forces' || action.kind === 'leader') return promise.preparation!.revival!.closed;
@@ -315,6 +394,11 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
if (left.kind !== right.kind) return false;
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle &&
left.territory === right.territory && planKey(left.claim) === planKey(right.claim);
if (left.kind === 'traitorCall') return right.kind === 'traitorCall' && left.battle === right.battle &&
left.territory === right.territory && left.call === right.call && left.beneficiary === right.beneficiary &&
left.target === right.target && left.leader === right.leader;
if (left.kind === 'forces') return right.kind === 'forces' && left.counter === right.counter && left.progress === right.progress;
if (left.kind === 'bid') return right.kind === 'bid' && left.lot === right.lot && left.source === right.source;
if (left.kind === 'payment') return right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress;
@@ -325,7 +409,14 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
if (leaf.kind === 'leader' || leaf.kind === 'alliance') {
if (leaf.kind === 'traitorCall') {
const prior = merged.find(other => other.kind === 'traitorCall' && other.battle === leaf.battle &&
other.territory === leaf.territory && other.beneficiary === leaf.beneficiary && other.target === leaf.target && other.leader === leaf.leader);
if (prior?.kind === 'traitorCall' && (prior.answer ? prior.call : !prior.call) !== (leaf.answer ? leaf.call : !leaf.call)) return null;
if (!prior) merged.push({ ...leaf });
continue;
}
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan') {
const prior = merged.find(other => sameAction(other, leaf));
if (prior && prior.answer !== leaf.answer) return null;
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
@@ -334,12 +425,12 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
continue;
}
const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer);
if (same && same.kind !== 'leader' && same.kind !== 'alliance')
if (same && 'minimum' in 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.kind !== 'leader' && no.kind !== 'leader' && yes.kind !== 'alliance' && no.kind !== 'alliance' &&
if (yes && no && 'minimum' in yes && 'minimum' in 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;
@@ -358,7 +449,10 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
const achieved = actionValue(action, promise);
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }];
else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
else if (action.kind === 'traitorCall') {
const { beneficiary, target, leader } = promise.combat!.traitor!;
yield [{ ...action, beneficiary, target, leader, answer }];
} else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
else if (action.kind === 'forces') yield [{ ...action, progress: promise.preparation!.revival![action.counter], answer }];
else if (action.kind === 'payment') yield [{ ...action, answer,
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }];
@@ -391,6 +485,73 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
? { ...update, fulfilled: true } : update;
}
/** A union receipt may serve several answers; each stored row retains exactly
* its original distinct atomic predicates and no unrelated native plan values. */
export function recordTurnActionPlan<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: string, territory: string, values: readonly TurnActionPlanValue[]): T[] {
check(identifier(event) && identifier(territory) && Array.isArray(values), 'Retain the actual original battle-plan event and predicate values.');
const receipt = new Map<string, TurnActionPlanValue>();
for (const entry of values) {
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual plan predicate values.');
const claim = parseCombatClaim(entry.claim), key = planKey(claim);
check(!receipt.has(key), 'Retain each actual plan predicate once.');
receipt.set(key, { claim, value: entry.value });
}
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const combat = promise.combat;
if (!combat?.plan || combat.plan.closed || combat.event !== event || combat.territory !== territory) continue;
const claims = new Set<string>();
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action' && node.action.kind === 'battlePlan') claims.add(planKey(node.action.claim));
else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit);
};
visit(promise.mixed);
const actual = [...claims].map(key => {
const value = receipt.get(key);
check(value, 'Retain the actual value of every original plan predicate.');
return { claim: { ...value.claim }, value: value.value };
});
updates.set(promise, settle(promise, { ...promise, combat: { ...combat, plan: { closed: true, values: actual } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** The actual native declaration is independent of later prevention/effect. */
export function recordTurnActionTraitor<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: string, territory: string, call: boolean): T[] {
check(identifier(event) && identifier(territory) && typeof call === 'boolean',
'Retain the actual original battle and Reveal or Decline declaration.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const combat = promise.combat;
if (!combat?.traitor || combat.traitor.closed || combat.event !== event || combat.territory !== territory) continue;
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
traitor: { ...combat.traitor, closed: true, call } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Closing without the original native event records absence, never a zero
* dial or Decline. Existing closed receipts and terminal rows stay untouched. */
export function closeTurnActionCombat<T extends TurnActionPromise>(promises: readonly T[], turn: number,
event: string): T[] {
check(Array.isArray(promises) && positive(turn) && identifier(event), 'Choose the original combat opportunity.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.map(promise => {
if (promise.turn !== turn || promise.fulfilled || promise.released) return promise;
readPromise(promise);
const combat = promise.combat;
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed)) return promise;
return settle(promise, { ...promise, combat: { ...combat,
plan: combat.plan && !combat.plan.closed ? { closed: true, values: null } : combat.plan,
traitor: combat.traitor && !combat.traitor.closed ? { ...combat.traitor, closed: true, call: null } : combat.traitor,
} });
});
}
/** Only the exact original Nexus closure records final membership. Temporary
* proposals/pairs never call this hook. A newly impossible whole No releases;
* the engine rejects own voluntary closure by inspecting that detached result. */
@@ -569,7 +730,9 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
} } : {}) };
const fulfilled = !(promise.alliance && !promise.alliance.closed) && turnActionValue(closed) === promise.answer;
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
promise.combat.traitor && !promise.combat.traitor.closed);
const fulfilled = !(promise.alliance && !promise.alliance.closed) && !unresolvedCombat && turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
}
@@ -585,7 +748,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
const allClosed = promise.transportClosed && promise.paymentClosed &&
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
(!promise.alliance || promise.alliance.closed);
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
(!promise.combat?.traitor || promise.combat.traitor.closed);
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
@@ -593,6 +757,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
check(!events.has(promise.event) && players.includes(promise.player) && players.includes(promise.asker) &&
promise.player !== promise.asker && recipientsValid(promise.mixed) &&
(!promise.alliance || promise.alliance.partner === null || players.includes(promise.alliance.partner)) &&
(!promise.combat?.traitor || players.includes(promise.combat.traitor.beneficiary) &&
players.includes(promise.combat.traitor.target) && promise.combat.traitor.target !== promise.player) &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&