Add bids and ordinary Revival to combined turn commitments

This commit is contained in:
Quintine committed 2026-10-26 21:42:45 +11:00
1 parent 0d72a33ee5
commit 921b4fe1e3
15 files changed
+1255 -65

No files matched your search

+83 -9
View File
@@ -1,6 +1,7 @@
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf } from './turn-action-promises';
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
closeTurnActionRevival, 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';
@@ -15839,6 +15840,17 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
if (action.bidPromiseEvent !== undefined) requireRule(typeof action.bidPromiseEvent === 'string' &&
live.some(promise => promise.event === action.bidPromiseEvent), 'This bid commitment guide is stale.');
if (!opportunity) return;
if (g.turnActionPromises) {
if (amount !== undefined) g.turnActionPromises = turnActionRule(() =>
recordTurnActionBid(g.turnActionPromises!, p.id, g.turn, {
lot: opportunity.lot, source: opportunity.source, amount }));
// A pass can be reset by a later bid in this same physical lot. Close only
// the private Yes-alternative witness; keep actual No caps until lot close.
const afterPass = amount === undefined ? { ...g, turnActionPromises: turnActionRule(() =>
closeTurnActionBidding(g.turnActionPromises!, g.turn, opportunity.source, p.id)) } : g;
requireRule(!!turnActionCompletion(afterPass, p.id),
'Retain a legal whole Truthtrance completion after this ordinary bid or pass.');
}
if (amount !== undefined && live.length) {
try { g.bidPromises = recordPromisedBid(g.bidPromises ?? [], p.id, opportunity, amount); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
@@ -15861,6 +15873,9 @@ function auctionNext(g: Game) {
passed: a.passed,
});
if (next.kind !== 'bid') {
const lot = bidOpportunity(g);
if (lot && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
closeTurnActionBidding(g.turnActionPromises!, g.turn, lot.source));
if (next.kind === 'unbid') {
g.deck = [...a.cards.slice(a.index), ...g.deck];
g.auction = null;
@@ -18693,7 +18708,7 @@ function initializeTurnActionOrder(g: Game) {
if (!g.travelPromiseOrder!.some(entry => entry.kind === kind && entry.index === index))
g.travelPromiseOrder!.push({ kind, index });
}
type TurnActionCompletion = MovementCompletion & { payments: Action[] };
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan };
/** 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 {
@@ -18727,7 +18742,13 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
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 });
else if (leaf.kind === 'move') trial.movementPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum });
else if (leaf.kind === 'bid') (trial.bidPromises ??= []).push({ ...metadata,
lot: leaf.lot, source: leaf.source, minimum: leaf.minimum });
else if (leaf.kind === 'forces') (trial.revivalPromises ??= []).push({ ...metadata,
counter: leaf.counter, minimum: leaf.minimum, progress: leaf.progress });
else if (leaf.kind === 'leader') (trial.leaderRevivalPromises ??= []).push({ ...metadata, leader: leaf.leader });
});
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
@@ -18739,7 +18760,7 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
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) ||
if (!Number.isSafeInteger(total) || total > 0 && 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);
@@ -18752,14 +18773,25 @@ function turnActionCompletion(source: Game, actor: string): TurnActionCompletion
reconcileBattlePromises(funded, voluntary);
reconcileBidPromises(funded, voluntary);
reconcileRevivalPromises(funded, voluntary);
const lot = bidOpportunity(funded);
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
if (needsBid && !bid) continue;
const needsRevival = [
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
].some(promise => promise.answer);
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
if (needsRevival && !revival) continue;
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 })) };
if (completion) return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
@@ -18771,7 +18803,23 @@ 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;
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
if (node.action.kind === 'bid') bid = true;
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(claim.mixed);
const lot = bid ? bidOpportunity(g) : null;
requireRule(!bid || !!lot, 'Retain the original current ordinary auction lot.');
const preparation: TurnActionPreparation | undefined = bid || revival ? {
bid: lot ? { lot: lot.lot, source: lot.source, maximum: 0, closed: false } : null,
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,
...(preparation ? { preparation } : {}),
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 };
@@ -18814,6 +18862,14 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
'You cannot voluntarily close an unfulfilled whole Truthtrance answer.');
g.turnActionPromises = finished;
}
const lot = bidOpportunity(g);
for (const promise of liveTurnActionPromises(g.turnActionPromises!, p.id, g.turn)) {
const bid = promise.preparation?.bid;
if (bid && (!lot || lot.source !== bid.source))
g.turnActionPromises = turnActionRule(() => closeTurnActionBidding(g.turnActionPromises!, g.turn, bid.source, p.id));
}
if (g.phase > 4 || g.phase === 4 && g.revivalOrder && !g.revivalOrder.remaining.includes(p.id))
g.turnActionPromises = turnActionRule(() => closeTurnActionRevival(g.turnActionPromises!, p.id, g.turn));
if (g.phase > 5) g.turnActionPromises = turnActionRule(() =>
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
}
@@ -18830,7 +18886,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
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) ||
'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'emperorRevival', 'decision', 'passBid', 'karama', 'specialKarama'].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 ||
@@ -18875,12 +18931,18 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
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;
if (lot && g.auction?.active === player.id) action = completion.bid ??
(bidRequired(g.bidPromises ?? [], player.id, lot) ? projectedBidPromiseGuide(g, player.id)?.action : undefined);
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 && completion.revival && !ownedBox &&
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id)))
action = g.phase === 4 ? completion.revival.action
: g.phase === 2 || g.phase === 3 && ownedInvoice
? completion.revival.early.find(step => step.phase === g.phase)?.action : undefined;
if (!action && revival && !ownedBox) action = (projectedRevivalExpressionPromiseGuide(g, player.id) ??
projectedLeaderRevivalPromiseGuide(g, player.id) ?? projectedRevivalPromiseGuide(g, player.id))?.action;
if (!action && g.phase < 5) {
@@ -31272,6 +31334,8 @@ function revivalExpressionRule<T>(calculate: () => T): T {
catch (error) { if (error instanceof RevivalExpressionPromiseError) throw new RuleError(error.message); throw error; }
}
function recordRevivalExpressionEvent(g: Game, player: string, event: RevivalExpressionReturn) {
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionRevival(g.turnActionPromises!, player, g.turn, event));
if (liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn).length)
g.revivalExpressionPromises = revivalExpressionRule(() => recordRevivalExpressionReturn(
g.revivalExpressionPromises ?? [], player, g.turn, event));
@@ -41023,7 +41087,17 @@ function applyActionInner(
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.');
if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); }
if (g.phase === 4) {
if (g.turnActionPromises) {
const closed = turnActionRule(() => closeTurnActionRevival(g.turnActionPromises!, p.id, g.turn));
requireRule(!!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id),
'Complete your whole Truthtrance Revival answer or retain another legal alternative.');
// Basic No retains its cap until phase close, even if a new Truth later
// reopens concurrent Ready. Ordered Advanced finish closes it now.
if (g.revivalOrder) g.turnActionPromises = closed;
}
guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p);
}
requireRule(
[1, 2, 4, 7, 8].includes(g.phase) ||
(g.phase === 0 && g.stormPending !== null),
+10 -7
View File
@@ -3187,24 +3187,27 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined payments, shipment and movements',
title: 'Truthtrance: combined bids, Revival, payments and transport',
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.',
summary: 'Combine ordinary current-lot bids, native Revival, future payments and 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.',
'Choose Combine this turn’s 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.',
'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.',
'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.',
'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'],
related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', '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: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native 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'] },
{ 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'] },
{ 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'] },
{ 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'] },
],
},
{
+14 -2
View File
@@ -52,6 +52,7 @@ import {
type PlanClaim,
} from './battle-promises';
import { gameTerritories } from './board';
import { leaders } from './cards';
import { combatLocationName } from './combat-location';
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
import type { Action, Game, Player } from './engine';
@@ -385,6 +386,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
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.');
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && action.lot === g.auction.index + 1,
'Choose the current ordinary bid lot in a supported native auction.');
if (action.kind === 'leader') check(leaders(respondent.faction).some(leader => leader.id === action.leader) ||
action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides',
'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.');
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 };
@@ -699,8 +706,13 @@ export function truthQuestionText(
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`)}?`;
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 === '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`;
return `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,
+156 -18
View File
@@ -1,5 +1,8 @@
import { TERRITORIES } from './board';
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
import { BidPromiseError, parseBidClaim } from './bid-promises';
import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question';
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
@@ -18,11 +21,20 @@ 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 TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim;
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 };
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean } |
{ 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 };
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 TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
@@ -39,6 +51,8 @@ export type TurnActionPromise = TurnActionClaim & {
paymentClosed: boolean;
fulfilled: boolean;
released: boolean;
/** Optional only for original shipment/movement/payment records. */
preparation?: TurnActionPreparation;
};
export class TurnActionError extends Error {
@@ -67,14 +81,21 @@ const printedTerritory = (value: unknown): value is string =>
/** 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.');
check(object(value), 'Choose one future ordinary bid, Revival, reserve shipment, ground movement or bribe payment.');
try {
if (value.kind === 'bid') {
check(keys(value, 'kind,lot,minimum'), 'Supply only the current ordinary lot and positive bid minimum.');
const claim = parseBidClaim({ turn: 1, lot: value.lot, minimum: value.minimum });
return { kind: 'bid', lot: claim.lot, minimum: claim.minimum };
}
if (value.kind === 'forces' || value.kind === 'leader') return parseRevivalActionClaim(value);
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)
if (error instanceof TravelPromiseError || error instanceof BribePromiseError || error instanceof BidPromiseError ||
error instanceof RevivalExpressionPromiseError)
throw new TurnActionError(error.message);
throw error;
}
@@ -95,8 +116,11 @@ function storedFact(value: unknown, depth = 0): TruthFact {
}
function actionKey(action: TurnActionLeaf): string {
return JSON.stringify(action.kind === 'payment'
? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]);
if (action.kind === 'leader') return JSON.stringify([action.kind, action.leader]);
if (action.kind === 'forces') return JSON.stringify([action.kind, action.counter, action.minimum]);
if (action.kind === 'bid') return JSON.stringify([action.kind, action.lot, action.minimum]);
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> {
@@ -131,8 +155,9 @@ function validateEvent(event: unknown): asserts event is TravelEvent {
}
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') &&
const fields = 'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn';
check(object(promise) && (keys(promise, fields) || keys(promise,
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,preparation,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>;
@@ -147,14 +172,38 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
const originals = new Set<string>();
const destinations = new Set<string>();
const recipients = new Set<string>();
const lots = new Set<number>();
let hasRevival = false;
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);
if (node.action.kind === 'bid') lots.add(node.action.lot);
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = 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.');
if ('preparation' in promise) {
const preparation = promise.preparation;
check(object(preparation) && keys(preparation, 'bid,revival'), 'Retain only the private native preparation stages.');
if (preparation.bid !== null) {
const bid = preparation.bid;
check(object(bid) && keys(bid, 'closed,lot,maximum,source') && positive(bid.lot) && identifier(bid.source) &&
whole(bid.maximum) && typeof bid.closed === 'boolean' && [...lots].every(lot => lot === bid.lot),
'Retain one original ordinary lot, source, actual maximum accepted bid and closure.');
}
if (preparation.revival !== null) {
const revival = preparation.revival;
check(object(revival) && keys(revival, 'closed,elite,leaders,normal,total') && physical(revival.total) &&
physical(revival.normal) && physical(revival.elite) && revival.total === revival.normal + revival.elite &&
Array.isArray(revival.leaders) && revival.leaders.every(identifier) &&
new Set(revival.leaders).size === revival.leaders.length && typeof revival.closed === 'boolean',
'Retain typed actual ordinary force returns, unique personal leaders and Revival closure.');
}
}
const compiled = readCompiled(promise.compiled, originals);
check(typeof promise.answer === 'boolean' && typeof promise.fulfilled === 'boolean' &&
typeof promise.released === 'boolean' && typeof promise.transportClosed === 'boolean' &&
@@ -185,6 +234,9 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
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;
if (action.kind === 'leader') return promise.preparation?.revival?.leaders.includes(action.leader) ?? false;
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 &&
@@ -200,6 +252,8 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'bid') return promise.preparation!.bid!.closed;
if (action.kind === 'forces' || action.kind === 'leader') return promise.preparation!.revival!.closed;
return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship'
? !promise.shipment.pending : promise.transportClosed;
}
@@ -233,21 +287,30 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
}
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);
if (left.kind !== right.kind) return false;
if (left.kind === 'leader') return right.kind === 'leader' && 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;
return (right.kind === 'ship' || right.kind === 'move') && 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) {
if (leaf.kind === 'leader') {
const prior = merged.find(other => sameAction(other, leaf));
if (prior && prior.answer !== leaf.answer) return null;
if (!prior) merged.push({ ...leaf });
continue;
}
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);
if (same && same.kind !== 'leader') 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 (yes && no && yes.kind !== 'leader' && no.kind !== 'leader' && 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;
}
@@ -264,9 +327,11 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
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 }];
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.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 }];
else yield [{ ...action, answer }];
return;
}
const conjunction = node.kind === 'and' ? answer : !answer;
@@ -295,6 +360,73 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
? { ...update, fulfilled: true } : update;
}
/** Only an actually accepted ordinary declaration counts for its original lot.
* Repeated declarations retain their largest amount, never their sum or invoice. */
export function recordTurnActionBid<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TurnActionBid): T[] {
check(object(event) && keys(event, 'amount,lot,source') && positive(event.lot) && identifier(event.source) && positive(event.amount),
'Retain the original ordinary lot, private source and positive actual accepted bid.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const stage = promise.preparation?.bid;
if (!stage || stage.closed || stage.source !== event.source || stage.lot !== event.lot || event.amount <= stage.maximum) continue;
updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!,
bid: { ...stage, maximum: event.amount } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Close this physical ordinary lot, or one respondent's actual opportunity. */
export function closeTurnActionBidding<T extends TurnActionPromise>(promises: readonly T[], turn: number,
source: string, player?: string): T[] {
check(Array.isArray(promises) && positive(turn) && identifier(source) &&
(player === undefined || identifier(player)), 'Choose the original ordinary lot and optional respondent.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.map(promise => {
if (promise.turn !== turn || player !== undefined && promise.player !== player || promise.fulfilled || promise.released) return promise;
readPromise(promise);
const stage = promise.preparation?.bid;
if (!stage || stage.closed || stage.source !== source) return promise;
return settle(promise, { ...promise, preparation: { ...promise.preparation!, bid: { ...stage, closed: true } } });
});
}
/** Completed ordinary native returns only. Card/special/foreign returns must
* not call this hook, and each personal identity is recorded only once. */
export function recordTurnActionRevival<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: RevivalExpressionReturn): T[] {
check(object(event) && (event.kind === 'forces' && keys(event, 'amount,elite,kind') && physical(event.amount) &&
physical(event.elite) && event.elite <= event.amount ||
event.kind === 'leader' && keys(event, 'kind,leader') && identifier(event.leader)),
'Retain an actual typed ordinary force return or personal leader identity.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
const stage = promise.preparation?.revival;
if (!stage || stage.closed || event.kind === 'forces' && event.amount === 0 ||
event.kind === 'leader' && stage.leaders.includes(event.leader)) continue;
const revival = event.kind === 'forces' ? { ...stage, total: stage.total + event.amount,
normal: stage.normal + event.amount - event.elite, elite: stage.elite + event.elite, leaders: [...stage.leaders] } :
{ ...stage, leaders: [...stage.leaders, event.leader] };
updates.set(promise, settle(promise, { ...promise, preparation: { ...promise.preparation!, revival } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** The respondent's ordinary Revival stage closes independently of transport. */
export function closeTurnActionRevival<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 stage = promise.preparation?.revival;
if (!stage || stage.closed) return promise;
return settle(promise, { ...promise, preparation: { ...promise.preparation!, revival: { ...stage, closed: true } } });
});
}
/** 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[] {
@@ -379,7 +511,10 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
if (!live.has(promise)) return promise;
readPromise(promise);
const closed = { ...promise, transportClosed: true, paymentClosed: true,
shipment: { ...promise.shipment, pending: false } };
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
} } : {}) };
const fulfilled = turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
@@ -393,6 +528,9 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
for (const promise of promises) {
const compiled = readPromise(promise);
const satisfied = evaluate(compiled, promise);
const allClosed = promise.transportClosed && promise.paymentClosed &&
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
(!promise.preparation?.revival || promise.preparation.revival.closed);
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);
@@ -401,7 +539,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
(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)),
(!allClosed || promise.fulfilled)),
'The turn-action commitment lost its event, participants or whole-answer progress.');
events.add(promise.event);
}