Support scheduled travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m8s

This commit is contained in:
Quintine committed 2026-10-29 08:19:52 +11:00
1 parent e1e02fc1a1
commit 1e81ce8ad8
13 files changed
+767 -56

No files matched your search

+157 -32
View File
@@ -8,7 +8,7 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
FactionSwapError, type FactionSwap } from './faction-swap';
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, publicDealTravelDueTurn, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
import { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
@@ -5968,7 +5968,7 @@ function publicDealIntegrity(g: Game): void {
const deal = g.deals?.find(entry => entry.id === row.sourceDeal);
const term = deal?.travel;
requireRule(deal && term && ['active', 'released'].includes(deal.status) &&
row.player === publicDealTravelActor(deal) && row.asker === deal.from && row.turn === deal.turn &&
row.player === publicDealTravelActor(deal) && row.asker === deal.from && row.turn === publicDealTravelDueTurn(deal) &&
row.answer === term.answer && row.mixed.kind === 'action' &&
row.mixed.action.kind === term.kind && row.mixed.action.territory === term.territory &&
row.mixed.action.minimum === term.minimum && row.mixed.action.sector === undefined &&
@@ -18125,7 +18125,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);
reconcileTravelPromises(trial, p.id, action, g.phase, g);
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
reconcileRevivalPromises(trial, { actor: p.id, action });
@@ -22333,7 +22333,7 @@ function guardThumperKnownEncounter(g: Game, actor: string) {
const trial = structuredClone(g);
projectAfterKnownThumper(trial);
const action: Action = { type: 'card' };
reconcileTravelPromises(trial, actor, action, 1);
reconcileTravelPromises(trial, actor, action, 1, g);
reconcileBidPromises(trial, { actor, action });
reconcileRevivalPromises(trial, { actor, action });
reconcileBribePromises(trial, { actor, action });
@@ -22713,7 +22713,7 @@ function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
if (pass && trial.turnActionPromises) trial.turnActionPromises = ownTurnActionWindowClosure(trial, actor, ['amal']);
projectTurnActionOpeningEnd(trial);
const action: Action = { type: 'card' };
reconcileTravelPromises(trial, actor, action, g.phase);
reconcileTravelPromises(trial, actor, action, g.phase, g);
reconcileBidPromises(trial, { actor, action });
reconcileRevivalPromises(trial, { actor, action });
reconcileBribePromises(trial, { actor, action });
@@ -23174,7 +23174,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
g.turnActionPromises.push(promise);
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase, before?: Game) {
const suspendedKull = g.pendingTreacheryDiscard?.continuation.kind === 'kullSuccessDiscard' ||
g.pendingTreacheryDiscard?.continuation.kind === 'kullCounterDiscard';
if (g.pendingKull || suspendedKull) return;
@@ -23217,7 +23217,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
}
for (const promise of g.travelPromises ?? []) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
(promise.turn < g.turn || promise.turn === g.turn && g.phase > 5 || g.status !== 'playing')) promise.released = true;
for (const promise of [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])])
if (!promise.fulfilled && !promise.released && (promise.turn !== g.turn || !bribePromiseTurn(g))) {
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
@@ -23246,15 +23246,42 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
turnActionPromises: [] as TurnActionPromise[] };
const priorTrial = before?.turn === g.turn ? { ...before,
shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
turnActionPromises: [] as TurnActionPromise[] } : undefined;
for (const entry of g.travelPromiseOrder!) {
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
turnActions: 'turnActionPromises' } as const)[entry.kind];
const promise = g[key]?.[entry.index];
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
if (!promise || promise.player !== p.id || promise.turn !== g.turn) continue;
const previous = before?.[key]?.[entry.index];
const hasPrevious = !!priorTrial && !!previous && previous.player === p.id && previous.turn === g.turn &&
!previous.fulfilled && !previous.released;
if (promise.fulfilled || promise.released) {
if (hasPrevious) (priorTrial[key] as (typeof previous)[]).push(previous);
continue;
}
// Each key and row came from the same discriminated accepted-order entry.
(trial[key] as (typeof promise)[]).push(promise);
if (hasPrevious) (priorTrial[key] as (typeof previous)[]).push(previous);
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise) && !voluntary && g.phase < 5) {
trial.travelPromises.pop();
if (hasPrevious) priorTrial!.travelPromises.pop();
continue;
}
if (turnActionCompletion(trial, p.id)) continue;
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise)) {
// A missing early witness is not proof that later income or another
// player's action cannot make the agreed journey possible.
const witnessed = voluntary && hasPrevious && turnActionCompletion(priorTrial!, p.id);
requireRule(!witnessed, 'Your action would remove a known legal completion of an accepted public travel agreement.');
trial.travelPromises.pop();
if (hasPrevious) priorTrial!.travelPromises.pop();
continue;
}
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted whole turn-action commitment.');
promise.released = true;
log(g, 'sourceDeal' in promise && promise.sourceDeal
@@ -23423,6 +23450,11 @@ function travelRule<T>(operation: () => T): T {
function travelTargetAvailable(g: Game, actor: string): boolean {
return movementPromiseTargetAvailable(g, actor);
}
/** A later-turn deal has no resource witness when it is first accepted. */
function isFutureDealTravel(g: Game, promise: TravelPromise): boolean {
const deal = promise.sourceDeal ? g.deals?.find(entry => entry.id === promise.sourceDeal) : undefined;
return !!deal?.travel && publicDealTravelDueTurn(deal) > deal.turn;
}
function appendTravelOrder(g: Game, kind: NonNullable<Game['travelPromiseOrder']>[number]['kind'], index: number) {
g.travelPromiseOrder?.push({ kind, index });
}
@@ -23513,29 +23545,51 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
* travel constraint, not a Truthtrance question or a fabricated card play. */
function bindPublicDealTravel(g: Game, deal: PublicDeal) {
const term = deal.travel, player = getPlayer(g, publicDealTravelActor(deal));
requireRule(term && deal.turn === g.turn && !g.advanced && g.expansions.length === 0 &&
nativePromiseScopeSupported(g) && travelTargetAvailable(g, player.id) && !g.truthtrance &&
!hasBribePromises(g, player.id) &&
!g.deals?.some(other => other.payment?.kind === 'deferredBribe' && other.paymentStatus === 'pending'),
'This travel agreement needs a supported quiet Basic opportunity in its offer turn.');
const due = publicDealTravelDueTurn(deal);
requireRule(term && due >= g.turn && !g.advanced && g.expansions.length === 0 && nativePromiseScopeSupported(g),
'This travel agreement needs a supported Basic table and an unexpired turn.');
const action = { kind: term.kind, territory: term.territory, minimum: term.minimum };
const claim: TravelClaim = { turn: g.turn, mixed: { kind: 'action', action } };
requireRule(travelTruthAnswers(g, player, claim).includes(term.answer ? 'yes' : 'no'),
'This travel agreement cannot be accepted at the current opportunity.');
reopenMovementPreparation(g, player, 'deal');
const claim: TravelClaim = { turn: due, mixed: { kind: 'action', action } };
if (due > g.turn) {
requireRule(!g.deals?.some(other => other.payment?.kind === 'deferredBribe' &&
other.paymentStatus === 'pending' && other.payment.payer === player.id && other.payment.dueTurn === due),
'This traveler already has a payment due in the same turn.');
// These are public, physical contradictions, not a forecast of future
// funds: there is one shipment, and a required action cannot also be
// forbidden at an equal or lower threshold in that same territory.
const conflicts = liveTravelPromises(g.travelPromises ?? [], player.id, due).some(row => {
if (!row.sourceDeal || row.mixed.kind !== 'action') return false;
const prior = row.mixed.action;
if (prior.kind !== action.kind) return false;
if (action.kind === 'ship' && row.answer && term.answer && prior.territory !== action.territory) return true;
if (prior.territory !== action.territory || row.answer === term.answer) return false;
const required = row.answer ? prior.minimum : action.minimum;
const forbidden = row.answer ? action.minimum : prior.minimum;
return required >= forbidden;
});
requireRule(!conflicts, 'This future travel term conflicts with an already accepted public travel agreement.');
}
if (due === g.turn) {
requireRule(travelTargetAvailable(g, player.id) && !g.truthtrance && !hasBribePromises(g, player.id) &&
!g.deals?.some(other => other.payment?.kind === 'deferredBribe' && other.paymentStatus === 'pending'),
'This travel agreement needs a supported quiet Basic opportunity in its due turn.');
requireRule(travelTruthAnswers(g, player, claim).includes(term.answer ? 'yes' : 'no'),
'This travel agreement cannot be accepted at the current opportunity.');
reopenMovementPreparation(g, player, 'deal');
}
initializeTravelOrder(g);
(g.travelPromises ??= []).push({ ...claim, event: crypto.randomUUID(), sourceDeal: deal.id,
player: player.id, asker: deal.from, answer: term.answer, compiled: { kind: 'action', action },
shipment: { pending: g.phase < 5 || shipmentAvailable(g, player), event: null },
shipment: { pending: due > g.turn || g.phase < 5 || shipmentAvailable(g, player), event: null },
progress: [], movementClosed: false, fulfilled: false, released: false });
appendTravelOrder(g, 'travel', g.travelPromises.length - 1);
}
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
if (g.turnActionPromises) { reconcileTurnActionPromises(g, actor, action, sourcePhase); return; }
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase, before?: Game) {
if (g.turnActionPromises) { reconcileTurnActionPromises(g, actor, action, sourcePhase, before); return; }
if (!g.travelPromises?.length) return;
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
(promise.turn < g.turn || promise.turn === g.turn && g.phase > 5 || g.status !== 'playing')) promise.released = true;
if (g.phase < 0 || g.phase > 5) return;
initializeTravelOrder(g);
for (const p of g.players) {
@@ -23558,18 +23612,54 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc
(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[] = [];
const priorShipments: ShipmentPromise[] = [], priorMovements: MovementPromise[] = [],
priorExpressions: MovementExpressionPromise[] = [], priorTravel: TravelPromise[] = [];
for (const entry of g.travelPromiseOrder!) {
const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises
: entry.kind === 'movementExpression' ? g.movementExpressionPromises : g.travelPromises;
const promise = ledger?.[entry.index];
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
if (!promise || promise.player !== p.id || promise.turn !== g.turn) continue;
const priorLedger = entry.kind === 'shipment' ? before?.shipmentPromises : entry.kind === 'movement' ? before?.movementPromises
: entry.kind === 'movementExpression' ? before?.movementExpressionPromises : before?.travelPromises;
const previous = priorLedger?.[entry.index];
const hasPrevious = before?.turn === g.turn && !!previous && previous.player === p.id &&
previous.turn === g.turn && !previous.fulfilled && !previous.released;
if (promise.fulfilled || promise.released) {
if (hasPrevious) {
if (entry.kind === 'shipment') priorShipments.push(previous as ShipmentPromise);
else if (entry.kind === 'movement') priorMovements.push(previous as MovementPromise);
else if (entry.kind === 'movementExpression') priorExpressions.push(previous as MovementExpressionPromise);
else priorTravel.push(previous as TravelPromise);
}
continue;
}
if (entry.kind === 'shipment') shipments.push(promise as ShipmentPromise);
else if (entry.kind === 'movement') movements.push(promise as MovementPromise);
else if (entry.kind === 'movementExpression') expressions.push(promise as MovementExpressionPromise);
else travel.push(promise as TravelPromise);
if (hasPrevious) {
if (entry.kind === 'shipment') priorShipments.push(previous as ShipmentPromise);
else if (entry.kind === 'movement') priorMovements.push(previous as MovementPromise);
else if (entry.kind === 'movementExpression') priorExpressions.push(previous as MovementExpressionPromise);
else priorTravel.push(previous as TravelPromise);
}
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise) && !voluntary && g.phase < 5) {
travel.pop();
if (hasPrevious) priorTravel.pop();
continue;
}
const trial = { ...g, shipmentPromises: shipments, movementPromises: movements,
movementExpressionPromises: expressions, travelPromises: travel };
if (travelCompletion(trial, p.id)) continue;
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise)) {
const prior = before && { ...before, shipmentPromises: priorShipments, movementPromises: priorMovements,
movementExpressionPromises: priorExpressions, travelPromises: priorTravel };
const witnessed = voluntary && hasPrevious && prior && travelCompletion(prior, p.id);
requireRule(!witnessed, 'Your action would remove a known legal completion of an accepted public travel agreement.');
travel.pop();
if (hasPrevious) priorTravel.pop();
continue;
}
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted travel commitment.');
promise.released = true;
log(g, 'sourceDeal' in promise && promise.sourceDeal
@@ -23579,20 +23669,20 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc
}
}
function projectedTravelPromises(g: Game, actor: string) {
return (g.travelPromises ?? []).filter(promise => promise.player === actor).map(promise => ({
return (g.travelPromises ?? []).filter(promise => promise.player === actor && promise.turn <= g.turn).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn, mixed: structuredClone(promise.mixed),
answer: promise.answer, fulfilled: promise.fulfilled, released: promise.released,
...(promise.sourceDeal ? {sourceDeal: promise.sourceDeal} : {}),
}));
}
function projectedDealTravelStatus(g: Game): Record<string, 'pending' | 'fulfilled' | 'void' | 'released'> {
const status: Record<string, 'pending' | 'fulfilled' | 'void' | 'released'> = {};
function projectedDealTravelStatus(g: Game): Record<string, 'scheduled' | 'pending' | 'fulfilled' | 'void' | 'released'> {
const status: Record<string, 'scheduled' | 'pending' | 'fulfilled' | 'void' | 'released'> = {};
for (const deal of g.deals ?? []) {
if (!deal.travel || !['active', 'released'].includes(deal.status)) continue;
const receipt = g.travelPromises?.find(row => row.sourceDeal === deal.id);
if (!receipt) continue; // Integrity validation reports malformed saved records.
status[deal.id] = receipt.fulfilled ? 'fulfilled' : receipt.releasedByDeal ? 'released'
: receipt.released ? 'void' : 'pending';
: receipt.released ? 'void' : receipt.turn > g.turn ? 'scheduled' : 'pending';
}
return status;
}
@@ -24435,6 +24525,37 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
log(g, `${p.name}'s ${quote.normal + quote.elite} new allied-shipment visitors left in ${quote.territory} went to Tanks. This is the provisional Basic no-departure policy, not a publisher ruling.`);
}
/** A later-turn agreement can be accepted before its funding exists. If no
* native completion is available when its owner's actual turn is being closed,
* release only the unsupported future terms in accepted order. Other existing
* obligations keep their normal checks. A planner witness is not a prediction
* of future income, so this is deliberately delayed until the actual close. */
function releaseUnwitnessedFutureTravelAtClose(g: Game, player: Player) {
const due = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)
.filter(row => isFutureDealTravel(g, row));
if (!due.length || travelCompletion(g, player.id)) return;
initializeTravelOrder(g);
const trial = { ...g, shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
turnActionPromises: [] as TurnActionPromise[], travelPromiseOrder: undefined };
for (const entry of g.travelPromiseOrder!) {
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
turnActions: 'turnActionPromises' } as const)[entry.kind];
const promise = g[key]?.[entry.index];
if (!promise || promise.player !== player.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
const future = entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise);
const prior = future && travelCompletion(trial, player.id);
(trial[key] as (typeof promise)[]).push(promise);
if (future && prior && !travelCompletion(trial, player.id)) {
trial.travelPromises.pop();
promise.released = true;
log(g, `${player.name} has no witnessed legal completion for a due public travel agreement as their travel turn closes; that term is no longer binding.`);
}
}
}
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
@@ -30202,7 +30323,7 @@ function guardCharityOrderFinish(g: Game, player: string, frame: CharityOrder, a
closed.ready.push(player);
closed.active = frame.remaining[0] ?? null;
const voluntary = { actor: player, action };
reconcileTravelPromises(closed, player, action, g.phase);
reconcileTravelPromises(closed, player, action, g.phase, g);
reconcileRevivalPromises(closed, voluntary);
reconcileBribePromises(closed, voluntary);
reconcileShipmentPromises(closed, voluntary);
@@ -36806,7 +36927,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);
reconcileTravelPromises(trial, p.id, action, g.phase, g);
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
return true;
@@ -42431,8 +42552,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
let next: PublicDeal;
if (action.type === 'dealRespond') {
requireRule(typeof action.accept === 'boolean', 'Choose whether to accept or decline.');
requireRule(!action.accept || !current.travel || current.turn === g.turn,
'The travel agreement was offered for an earlier turn; decline or withdraw it.');
requireRule(!action.accept || !current.travel || publicDealTravelDueTurn(current) >= g.turn,
'The travel agreement’s due turn has passed; decline or withdraw it.');
requireRule(!action.accept || current.payment?.kind !== 'deferredBribe' || current.payment.dueTurn >= g.turn,
'The deferred payment deadline has passed; decline or withdraw this offer.');
next = publicDealRule(() => respondPublicDeal(current, publicDealSeats(g), id, action.accept as boolean));
@@ -42454,7 +42575,10 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
}
g.deals = deals.map((deal, position) => position === index ? next : deal);
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'deferredBribe') {
const payer = getPlayer(g,next.payment.payer);
const payer = getPlayer(g,next.payment.payer), dueTurn = next.payment.dueTurn;
requireRule(!(g.travelPromises ?? []).some(row => row.sourceDeal && row.player === payer.id &&
row.turn === dueTurn && !row.fulfilled && !row.released),
'The payer already has a public travel commitment in the payment’s due turn.');
requireRule(next.payment.dueTurn !== g.turn ||
!liveTravelPromises(g.travelPromises ?? [], payer.id, g.turn).some(row => !!row.sourceDeal),
'Finish the current public travel commitment before accepting a payment due this turn.');
@@ -42669,7 +42793,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
const pledgeVoluntary = initiator === id ? voluntary : undefined;
reconcilePledgeCommitments(g, pledgeVoluntary);
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileTravelPromises(g, promiseActor, action, state.phase, state);
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
reconcileEarlyRicheseTruth(g, voluntary);
@@ -47120,6 +47244,7 @@ function applyActionInner(
return g;
}
if (t === 'endMovement') {
if (g.phase === 5 && g.active === id) releaseUnwitnessedFutureTravelAtClose(g, p);
requireRule(!hasMovementPromises(g, id, true),
'Complete your accepted ground-movement promises before ending movement.');
finishShipmentPromises(g, p, null);
+16 -2
View File
@@ -24,6 +24,8 @@ export type PublicDealTravel = {
answer: boolean;
/** Present only in a multi-party term; the selected recipient accepts last. */
actor?: string;
/** Absent on existing terms: their original offer turn is the due turn. */
dueTurn?: number;
};
export type PublicDeal = {
id: string;
@@ -93,6 +95,10 @@ export function publicDealAcceptVotes(deal: PublicDeal): string[] {
export function publicDealTravelActor(deal: PublicDeal): string {
return deal.travel?.actor ?? deal.to;
}
/** Call after ledger validation; older travel records are due on their offer turn. */
export function publicDealTravelDueTurn(deal: PublicDeal): number {
return deal.travel?.dueTurn ?? deal.turn;
}
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
return new Set(votes).size === votes.length &&
votes.every(vote => parties.includes(vote)) &&
@@ -123,12 +129,18 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
const keys = object(value) ? Object.keys(value).sort().join(',') : '';
requireDeal(object(value) && (keys === 'answer,kind,minimum,territory' ||
keys === 'actor,answer,kind,minimum,territory' && id(value.actor)) &&
keys === 'answer,dueTurn,kind,minimum,territory' ||
keys === 'actor,answer,kind,minimum,territory' ||
keys === 'actor,answer,dueTurn,kind,minimum,territory') &&
(!Object.hasOwn(value, 'actor') || id(value.actor)) &&
(!Object.hasOwn(value, 'dueTurn') || typeof value.dueTurn === 'number' &&
Number.isSafeInteger(value.dueTurn) && value.dueTurn > 0) &&
typeof value.answer === 'boolean', 'Choose an exact shipment or movement term and require or avoid it.');
try {
const claim = parseTravelActionClaim({ kind: value.kind, territory: value.territory, minimum: value.minimum });
return { kind: claim.kind, territory: claim.territory, minimum: claim.minimum, answer: value.answer,
...(keys.startsWith('actor,') ? {actor: value.actor as string} : {}) };
...(Object.hasOwn(value, 'actor') ? {actor: value.actor as string} : {}),
...(Object.hasOwn(value, 'dueTurn') ? {dueTurn: value.dueTurn as number} : {}) };
} catch (error) {
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
throw error;
@@ -175,6 +187,8 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
if (Object.hasOwn(deal, 'travel')) {
const travel = parsePublicDealTravel(deal.travel);
requireDeal(travel.dueTurn === undefined || travel.dueTurn >= (deal.turn as number),
'Choose a travel turn no earlier than the offer turn.');
requireDeal(!Object.hasOwn(deal, 'paymentStatus') && (multi
? !!travel.actor && travel.actor !== deal.from && parties.includes(travel.actor) && !payment
: !Object.hasOwn(travel, 'actor') && (!payment || payment.kind === 'immediateBribe' &&
+1 -1
View File
@@ -303,7 +303,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one current-turn reserve shipment or ground move of at least a selected number of physical forces to a named territory. In a group offer the other recipients accept first and the named traveler accepts last; the game checks that traveler’s own current ability on final consent. The actual future action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. The current-turn planner and mutual release apply, with unsupported combinations still in development.',
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one reserve shipment or ground move of at least a selected number of physical forces to a named territory in the current or a later turn. In a group offer the other recipients accept first and the named traveler accepts last. A current-turn term is checked on acceptance; a later-turn term is scheduled without reserving present forces or spice. The actual action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. A bounded due-turn search protects witnessed completions; when the travel turn closes without a witness, the game provisionally voids that term. Unsupported combinations remain in development.',
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility. Free text alone pays no spice. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
],