Support earlier combined shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 20:17:37 +11:00
1 parent d4e82c74b1
commit 8dbec05844
18 files changed
+1321 -46

No files matched your search

+2 -1
View File
@@ -4263,7 +4263,8 @@ export function botActions(g: GameView): Action[] {
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.movementExpressionPromiseGuide.action];
if (g.travelPromiseGuide && g.status === 'playing' && g.phase === 5 && g.active === g.me &&
if (g.travelPromiseGuide && g.status === 'playing' && g.phase >= 0 && g.phase <= 5 &&
(g.phase < 5 || g.active === g.me) &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.travelPromiseGuide.action];
if (g.movementPromisePolicy && g.status === 'playing' &&
+58 -17
View File
@@ -15708,6 +15708,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
// The detached accepted declaration satisfies its own Bid Yes before
// another promise checks the remaining purse; it is not a second bid.
trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number);
reconcileTravelPromises(trial, p.id, action, g.phase);
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
reconcileRevivalPromises(trial, { actor: p.id, action });
@@ -18661,7 +18662,7 @@ function travelRule<T>(operation: () => T): T {
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
}
function travelTargetAvailable(g: Game, actor: string): boolean {
return g.phase === 5 && movementPromiseTargetAvailable(g, actor);
return movementPromiseTargetAvailable(g, actor);
}
function appendTravelOrder(g: Game, kind: NonNullable<Game['travelPromiseOrder']>[number]['kind'], index: number) {
g.travelPromiseOrder?.push({ kind, index });
@@ -18717,10 +18718,18 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer
if (compiled.kind === 'constant' && compiled.value !== answer) continue;
const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false };
const trial = { ...g, travelPromises: [...(g.travelPromises ?? []), promise] };
if (travelCompletion(trial, p.id) && bribeCompatibleLaterPromises(trial, p, {})) answers.push(answer ? 'yes' : 'no');
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
// Candidate witnesses must include the new row, which has no accepted-order
// slot until the real answer binds. The authoritative order stays untouched.
const trial = { ...answerSource, travelPromises: [...(g.travelPromises ?? []), promise], travelPromiseOrder: undefined };
const lot = bidOpportunity(trial);
if (travelCompletion(trial, p.id) && revivalCompatibleLaterPromises(trial, p, {}) &&
bribeCompatibleLaterPromises(trial, p, {}) &&
(!lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p)))
answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
@@ -18729,30 +18738,39 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
'Choose a whole travel answer compatible with the original shipment and ground movements.');
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
reopenMovementPreparation(g, p);
initializeTravelOrder(g);
(g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer,
compiled, shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
fulfilled: compiled.kind === 'constant', released: false });
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
function reconcileTravelPromises(g: Game, actor?: string, action?: Action) {
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
if (!g.travelPromises?.length) return;
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
if (g.phase !== 5) return;
if (g.phase < 0 || g.phase > 5) return;
initializeTravelOrder(g);
for (const p of g.players) {
if (!liveTravelPromises(g.travelPromises, p.id, g.turn).length) continue;
const voluntary = actor === p.id && action?.type !== 'passResponse';
if (g.truthtrance || g.phaseOpening || g.pendingFremenMove?.order.player === p.id ||
const early = g.phase < 5 || sourcePhase < 5;
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive',
'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
action.type === 'ready' && [2, 4].includes(sourcePhase)));
if (g.truthtrance || !voluntary && g.phaseOpening || g.pendingFremenMove?.order.player === p.id ||
g.pendingIxMove?.player === p.id || g.pendingChoamMove?.player === p.id ||
g.pendingAdvisorMatching?.intent.kind === 'movement' && g.pendingAdvisorMatching.intent.order.player === p.id ||
g.response?.kind === 'advisorFlip' && g.response.owner === p.id ||
g.pendingAutomaticAdvisorFlips?.some(source => source.owner === p.id ||
source.producer === 'groundMovement' && source.mover === p.id)) continue;
if (!voluntary && (g.response || g.decision || g.pendingAmbassador || g.pendingTerrorEntry ||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard)) continue;
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard ||
g.phase === 3 && !g.currentAuctionSale &&
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome))) continue;
const shipments: ShipmentPromise[] = [], movements: MovementPromise[] = [], expressions: MovementExpressionPromise[] = [], travel: TravelPromise[] = [];
for (const entry of g.travelPromiseOrder!) {
const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises
@@ -18780,9 +18798,31 @@ function projectedTravelPromises(g: Game, actor: string) {
}
function projectedTravelGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)[0];
const policy = promise && g.phase === 5 && g.active === player.id ? projectedGroundMovementPolicy(g, player) : null;
const lot = promise && g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
const needsBid = !!lot && bidRequired(g.bidPromises ?? [], player.id, lot);
const requiredBid = needsBid ? projectedBidPromiseGuide(g, player.id) : null;
const needsRevival = !!promise && g.phase <= 4 && [
...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn),
...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn),
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
].some(record => record.answer);
const requiredRevival = !requiredBid && needsRevival
? projectedRevivalExpressionPromiseGuide(g, player.id) ?? projectedLeaderRevivalPromiseGuide(g, player.id) ??
projectedRevivalPromiseGuide(g, player.id) : null;
const prior = requiredBid ?? requiredRevival;
const paidBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
if (promise && (needsBid || needsRevival) && !paidBox) {
if (!prior) return null;
const action = { ...structuredClone(prior.action) };
delete action.bidPromiseEvent;
delete action.revivalExpressionPromiseEvent;
delete action.leaderRevivalPromiseEvent;
delete action.revivalPromiseEvent;
return { event: promise.event, action: { ...action, travelPromiseEvent: promise.event, travelPromisePhase: g.phase } };
}
const policy = promise && (g.phase < 5 || g.active === player.id) ? projectedGroundMovementPolicy(g, player) : null;
const action = policy?.[0];
return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event } } : null;
return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event, travelPromisePhase: g.phase } } : null;
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
@@ -27617,7 +27657,7 @@ function finishShipmentPromises(
shipment: { territory: string; amount: number } | null,
) {
checkShipmentPromises(g, p, shipment);
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
for (const promise of liveShipmentPromises(
g.shipmentPromises ?? [],
@@ -27639,7 +27679,7 @@ function ownGuildStopPromiseBlock(g: Game, p: Player): string | null {
* the respondent's promise cannot veto the opponent's printed power. */
function settleStoppedShipmentPromises(g: Game, p: Player, actor: string) {
if (actor === p.id) checkShipmentPromises(g, p, null);
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
for (const promise of liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn)) {
if (matchesShipment(promise, null) === promise.answer) promise.fulfilled = true;
else {
@@ -31016,6 +31056,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival)
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
try {
const action = { type: 'ready' };
reconcileTravelPromises(trial, p.id, action, g.phase);
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
return true;
@@ -36316,9 +36357,9 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This alliance commitment guide is stale or belongs to another Nexus choice.');
}
if (action.travelPromiseEvent !== undefined) {
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
const guide = projectedTravelGuide(state, getPlayer(state, id));
requireRule(guide && movementGuideActionMatches(action, guide.action),
requireRule(guide && action.travelPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
'This travel guide is stale or belongs to another opportunity.');
}
if (action.movementExpressionPromiseEvent !== undefined) {
@@ -36351,7 +36392,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
'This revival commitment guide is stale or belongs to another native opportunity.');
}
const g = applyActionInner(state, id, action);
reconcileTravelPromises(g, id, action);
reconcileTravelPromises(g, id, action, state.phase);
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
+6 -6
View File
@@ -3191,20 +3191,20 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine future native reserve shipment and physical ground-movement conditions in one whole answer, optionally with current facts.',
steps: [
'During Shipment and Movement, choose Combine this turn’s shipment and ground movements and an active or unused waiting native respondent. Full AND/OR groups can include ship, move and optional current-fact clauses. Earlier-phase and other cross-action questions remain unfinished.',
'Each shipment condition refers to the same one future original reserve shipment after answering. A used or skipped shipment does not count. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.',
'From Storm through Shipment and Movement, choose Combine this turn’s shipment and ground movements for a native respondent with an upcoming or active opportunity. Full AND/OR groups can include ship, move and optional current-fact clauses. Other action families and wider compositions remain unfinished.',
'Each shipment condition refers to the same one future original reserve shipment after answering. A current-turn shipment used before answering does not count; last turn’s shipment does not close this turn’s upcoming opportunity. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.',
'Each movement minimum requires one actual future physical ground group to that destination. Smaller moves never add together. Original owned Hajr, Ornithopter, card preparation, types, routes and movement allowances stay authoritative.',
'Yes requires the whole expression; OR preserves legal alternatives. No negates the whole question and stays binding through your actual movement end. A qualifying Yes completes immediately. Original legal actions must leave a possible completion; an opposing completed effect may release impossibility.',
'Current facts freeze at the actual answer and impose no promise to keep the same holdings. Unknown facts require an aggregate guaranteed without guessing; constant answers need no future action. Separate frozen values and private branch choices are not published.',
'Review the exact native next step or use another legal manual route. Private own history persists after the interval. Four minimal profiles participate legally; complete modes, broader compositions and final deployed acceptance remain unfinished.',
'Review the entitled current-phase preparation, bid pass or owned invoice, then the native shipment/movement step; other legal manual routes remain. A definite answer uniformly reopens concurrent Charity or legacy Revival readiness, including constants. Completed ordered Advanced Revival stays complete. Prior Revival and bribe payments retain their funding. Private own history persists; four minimal profiles participate legally. Complete modes, broader compositions and final deployed acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-shipment', 'truthtrance-movement-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Private signed branches share the original shipment and movement completion search, recording distinct native physical receipts and accepted-order release.', evidence: ['game/travel-expression-promises.ts', 'game/mixed-action-question.ts', 'game/movement-shipment-preparation.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/travel-expression-promises.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx', 'tests/truth-travel-early-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts', 'tests/truth-travel-early-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Single-shipment, physical group maxima, whole No, snapshot, native order and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TRAVEL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts', 'tests/fixture-truth-travel-early.ts', 'tests/truth-travel-early-runtime.test.ts', 'tests/truth-travel-early-revival.test.ts', 'tests/truth-travel-early-bid.test.ts', 'tests/truth-travel-early-controls.test.tsx'] },
],
},
{
+2 -2
View File
@@ -374,8 +374,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
}
if (v.kind === 'travel') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' &&
g.phase === 5 && v.turn === g.turn,
'Choose a current Shipment and Movement turn for this combined travel question.');
Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && v.turn === g.turn,
'Choose this turn’s upcoming or active Shipment and Movement for this combined travel question.');
const respondent = g.players.find(player => player.id === v.target)!;
return { kind: 'travel', target: v.target as string, turn: g.turn,
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseTravelActionClaim,