Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s
Verify and publish checkpoint container / container (push) Successful in 10m49s
This commit is contained in:
1 parent
ac1f32c859
commit
82c639c977
13 files changed
+777
-28
No files matched your search
+88
-7
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user