Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 07:45:17 +11:00
1 parent ac1f32c859
commit 82c639c977
13 files changed
+777 -28

No files matched your search

+88 -7
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, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, 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';
@@ -5963,6 +5963,22 @@ function publicDealRule<T>(operation: () => T): T {
}
function publicDealIntegrity(g: Game): void {
if (g.deals !== undefined) publicDealRule(() => validatePublicDeals(g.deals, g.players));
const linked = (g.travelPromises ?? []).filter(row => row.sourceDeal !== undefined);
for (const row of linked) {
const deal = g.deals?.find(entry => entry.id === row.sourceDeal);
const term = deal?.travel;
requireRule(deal && term && ['active', 'released'].includes(deal.status) &&
row.player === deal.to && row.asker === deal.from && row.turn === deal.turn &&
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 &&
(deal.status !== 'released' || row.fulfilled || row.released) &&
(!row.releasedByDeal || deal.status === 'released') &&
linked.filter(other => other.sourceDeal === deal.id).length === 1,
'The public travel deal lost its original accepted commitment.');
}
for (const deal of g.deals ?? []) if (deal.travel && ['active', 'released'].includes(deal.status))
requireRule(linked.some(row => row.sourceDeal === deal.id), 'The accepted public travel deal is missing its commitment.');
}
function publicDealObligationRule<T>(operation: () => T): T {
try { return operation(); }
@@ -17003,6 +17019,9 @@ function hasBribePromises(g: Game, player: string, positive = false): boolean {
...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)]
.some(promise => !positive || promise.answer);
}
function hasPublicDealTravel(g: Game, player: string): boolean {
return liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(row => !!row.sourceDeal);
}
function recordBribePayment(g: Game, player: string, recipient: string, amount: number) {
if (amount === 0) return;
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
@@ -17140,6 +17159,7 @@ function bribeCompatibleLaterPromises(g: Game, p: Player,
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
bribeAllied(g, p, claim.recipient) || !g.players.some(player => player.id === claim.recipient)) return [];
if (hasPublicDealTravel(g, p.id)) return ['unknown'];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
@@ -17153,6 +17173,7 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim)
.some(recipient => recipient === p.id || bribeAllied(g, p, recipient) || !g.players.some(player => player.id === recipient))) return [];
if (hasPublicDealTravel(g, p.id)) return ['unknown'];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => {
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
@@ -17200,6 +17221,7 @@ function compileMixedBribe(g: Game, p: Player, claim: MixedBribeClaim, answer: b
}
function mixedBribeTruthAnswers(g: Game, p: Player, claim: MixedBribeClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !bribeQuestionWindow(g)) return [];
if (hasPublicDealTravel(g, p.id)) return ['unknown'];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedBribe(g, p, claim, answer);
@@ -23235,7 +23257,9 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
if (turnActionCompletion(trial, p.id)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted whole turn-action commitment.');
promise.released = true;
log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
log(g, 'sourceDeal' in promise && promise.sourceDeal
? `${p.name} can no longer fulfill an accepted public travel agreement; that term is no longer binding.`
: `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
}
if (nexus && g.ready.includes(p.id) && liveTurnActionPromises(g.turnActionPromises!, p.id, g.turn)
.some(promise => promise.alliance?.source === nexus.source && !promise.alliance.closed) &&
@@ -23485,6 +23509,27 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
fulfilled: compiled.kind === 'constant', released: false });
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
/** A deal is a separately consented public source for the same physical
* travel constraint, not a Truthtrance question or a fabricated card play. */
function bindPublicDealTravel(g: Game, deal: PublicDeal) {
const term = deal.travel, player = getPlayer(g, deal.to);
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 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');
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 },
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; }
if (!g.travelPromises?.length) return;
@@ -23527,7 +23572,9 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc
if (travelCompletion(trial, p.id)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted travel commitment.');
promise.released = true;
log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
log(g, 'sourceDeal' in promise && promise.sourceDeal
? `${p.name} can no longer fulfill an accepted public travel agreement; that term is no longer binding.`
: `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
}
}
}
@@ -23535,8 +23582,20 @@ function projectedTravelPromises(g: Game, actor: string) {
return (g.travelPromises ?? []).filter(promise => promise.player === actor).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'> = {};
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';
}
return status;
}
function projectedTravelGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)[0];
const lot = promise && g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
@@ -23837,10 +23896,10 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
}
return answers.length ? answers : ['unknown'];
}
function reopenMovementPreparation(g: Game, p: Player) {
function reopenMovementPreparation(g: Game, p: Player, source: 'truthtrance' | 'deal' = 'truthtrance') {
if (((g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
g.ready = g.ready.filter(id => id !== p.id);
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
log(g, `${p.name} has a new ${source === 'deal' ? 'public deal' : 'Truthtrance'} movement commitment and must confirm readiness again after any needed preparation.`);
}
}
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
@@ -42335,7 +42394,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
const deals = g.deals ?? [];
if (action.type === 'dealOffer') {
const keys = Object.keys(action).sort().join(',');
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type') && typeof action.target === 'string';
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
keys === 'target,terms,travel,type') && typeof action.target === 'string';
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type') && Array.isArray(action.targets) &&
action.targets.every(target => typeof target === 'string');
const hasPayment = Object.hasOwn(action, 'payment');
@@ -42345,13 +42405,18 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
const payment = hasPayment ? action.payment as PublicDealPayment : undefined;
const event = crypto.randomUUID();
const seats = publicDealSeats(g);
const offer = publicDealRule(() => single
let offer = publicDealRule(() => single
? hasPayment
? createPublicDeal(seats, id, action.target as string, action.terms as string, g.turn, event, payment!)
: createPublicDeal(seats, id, action.target as string, action.terms as string, g.turn, event)
: hasPayment
? createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event, payment!)
: createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event));
if (Object.hasOwn(action, 'travel')) {
requireRule(single && !hasPayment && !g.advanced && g.expansions.length === 0 &&
nativePromiseScopeSupported(g), 'Choose a two-player travel agreement in a supported Basic table.');
offer = publicDealRule(() => setPublicDealTravel(offer, action.travel, seats));
}
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
g.deals = [...deals, offer];
} else {
@@ -42365,6 +42430,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.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));
@@ -42372,9 +42439,22 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
if (current.status === 'offered' && next.status === 'active' && next.travel)
bindPublicDealTravel(g, next);
if (current.status === 'active' && next.status === 'released' && next.travel) {
const receipt = g.travelPromises?.find(row => row.sourceDeal === next.id);
requireRule(receipt, 'The public travel deal lost its accepted commitment.');
if (!receipt.fulfilled && !receipt.released) {
receipt.released = true;
receipt.releasedByDeal = true;
}
}
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);
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.');
requireRule(next.payment.dueTurn >= g.turn &&
(next.payment.dueTurn > g.turn || publicDealCompletion(g,payer)),
'The agreed payment needs an unexpired deadline and, when due now, a legal funded completion.');
@@ -47938,6 +48018,7 @@ export function viewGame(state: Game, id: string, now?: number) {
host: g.host,
status: g.status,
deals: g.deals ?? [],
dealTravelStatus: projectedDealTravelStatus(g),
deferredDealPaymentOptions: projectedDeferredDealPaymentOptions(g,id),
factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null,