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,
|
||||
|
||||
+42
-1
@@ -1,3 +1,5 @@
|
||||
import { parseTravelActionClaim, TravelPromiseError } from './travel-expression-promises';
|
||||
|
||||
/** Written terms remain player-managed. Typed payment terms use the native
|
||||
* bribe rules; older two-seat records omit participants and acceptVotes. */
|
||||
export type PublicDealPayment = {
|
||||
@@ -13,6 +15,13 @@ export type PublicDealPayment = {
|
||||
dueTurn: number;
|
||||
};
|
||||
export type PublicDealPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
||||
/** The recipient commits to one native action (or its absence) during the offer turn. */
|
||||
export type PublicDealTravel = {
|
||||
kind: 'ship' | 'move';
|
||||
territory: string;
|
||||
minimum: number;
|
||||
answer: boolean;
|
||||
};
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
from: string;
|
||||
@@ -28,6 +37,7 @@ export type PublicDeal = {
|
||||
payment?: PublicDealPayment;
|
||||
/** Present only for accepted or released deferred payments. */
|
||||
paymentStatus?: PublicDealPaymentStatus;
|
||||
travel?: PublicDealTravel;
|
||||
};
|
||||
|
||||
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null; allies?: readonly string[] };
|
||||
@@ -102,6 +112,19 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
||||
amount: value.amount, dueTurn: value.dueTurn };
|
||||
}
|
||||
|
||||
/** Parse only the exact two-seat deal form, without a sector or an extra deadline. */
|
||||
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
|
||||
requireDeal(object(value) && Object.keys(value).sort().join(',') === 'answer,kind,minimum,territory' &&
|
||||
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 };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate persisted ledger shape and lifecycle without applying current
|
||||
* alliance restrictions retroactively to existing agreements. */
|
||||
export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
|
||||
@@ -109,7 +132,7 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
|
||||
const ids = new Set<string>();
|
||||
for (const deal of value) {
|
||||
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment' && key !== 'paymentStatus').sort().join(',') : '';
|
||||
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment' && key !== 'paymentStatus' && key !== 'travel').sort().join(',') : '';
|
||||
const multi = keys === 'acceptVotes,from,id,participants,releaseVotes,status,terms,to,turn';
|
||||
requireDeal(object(deal) && (multi || keys === 'from,id,releaseVotes,status,terms,to,turn') &&
|
||||
id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
|
||||
@@ -138,6 +161,11 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
parties = [deal.from, deal.to];
|
||||
participants(players, parties);
|
||||
}
|
||||
if (Object.hasOwn(deal, 'travel')) {
|
||||
requireDeal(!multi && !Object.hasOwn(deal, 'payment') && !Object.hasOwn(deal, 'paymentStatus'),
|
||||
'A travel term requires a two-party deal without a payment term.');
|
||||
parsePublicDealTravel(deal.travel);
|
||||
}
|
||||
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||
if (payment?.kind === 'deferredBribe') {
|
||||
@@ -189,6 +217,19 @@ export function createMultipartyPublicDeal(
|
||||
...paymentTerm };
|
||||
}
|
||||
|
||||
/** Attach a travel term while constructing a fresh two-party offer. */
|
||||
export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: readonly PublicDealSeat[]): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'participants') &&
|
||||
!Object.hasOwn(deal, 'acceptVotes') && !Object.hasOwn(deal, 'travel') &&
|
||||
!Object.hasOwn(deal, 'payment') && !Object.hasOwn(deal, 'paymentStatus'),
|
||||
'Only a fresh two-party offer without payment can add a travel term.');
|
||||
notAllied(participants(players, [deal.from, deal.to]));
|
||||
const result = { ...deal, travel: parsePublicDealTravel(value) };
|
||||
validatePublicDeals([result], players);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
const parties = publicDealParticipants(deal);
|
||||
|
||||
+4
-3
@@ -303,15 +303,16 @@ 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, a two-person deal can instead specify that the accepting 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. 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.',
|
||||
'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.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and deferred deal payments, including later deadlines, use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and a structured current-turn shipment or movement term with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection, Bureaucrat responses and legal autopilot continuation of a previously accepted travel term work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,6 +19,10 @@ export type MovementSectorProgress = { territory: string; sector: number; maximu
|
||||
export type TravelBranchLeaf = TravelActionClaim & { answer: boolean };
|
||||
export type TravelPromise = TravelClaim & {
|
||||
event: string;
|
||||
/** Public-deal provenance; absent on original Truthtrance answers. */
|
||||
sourceDeal?: string;
|
||||
/** A mutual deal release, distinct from an externally impossible term. */
|
||||
releasedByDeal?: true;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
@@ -279,7 +283,9 @@ export function* travelBranches(promise: TravelPromise): Generator<TravelBranchL
|
||||
|
||||
function settle<T extends TravelPromise>(promise: T, update: T): T {
|
||||
const satisfied = travelPromiseValue(update);
|
||||
check(promise.answer || !satisfied, 'This travel action conflicts with your whole Truthtrance travel No answer.');
|
||||
check(promise.answer || !satisfied, promise.sourceDeal
|
||||
? 'This travel action conflicts with your accepted public deal.'
|
||||
: 'This travel action conflicts with your whole Truthtrance travel No answer.');
|
||||
return promise.answer && satisfied ? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
@@ -356,7 +362,8 @@ export function finishTravelTurn<T extends TravelPromise>(promises: readonly T[]
|
||||
readPromise(promise);
|
||||
const closed = { ...promise, movementClosed: true, shipment: { ...promise.shipment, pending: false } };
|
||||
check(promise.released || travelPromiseValue(closed) === promise.answer,
|
||||
'Complete your whole Truthtrance travel Yes answer before ending movement.');
|
||||
promise.sourceDeal ? 'Complete your accepted public travel deal before ending movement.'
|
||||
: 'Complete your whole Truthtrance travel Yes answer before ending movement.');
|
||||
return { ...closed, fulfilled: !promise.released };
|
||||
});
|
||||
}
|
||||
@@ -369,7 +376,10 @@ export function validateTravelPromises(promises: readonly TravelPromise[], playe
|
||||
for (const promise of promises) {
|
||||
const compiled = readPromise(promise);
|
||||
const satisfied = evaluate(compiled, promise);
|
||||
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
|
||||
check(identifier(promise.event) && !events.has(promise.event) &&
|
||||
(promise.sourceDeal === undefined || identifier(promise.sourceDeal)) &&
|
||||
(promise.releasedByDeal === undefined || promise.releasedByDeal === true && !!promise.sourceDeal && promise.released) &&
|
||||
identifier(promise.player) && identifier(promise.asker) &&
|
||||
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
|
||||
(promise.released || (promise.answer || !satisfied) &&
|
||||
(!promise.answer || promise.fulfilled === satisfied) &&
|
||||
|
||||
Reference in new issue
Block a user