Support group travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m13s
Verify and publish checkpoint container / container (push) Successful in 11m13s
This commit is contained in:
1 parent
82d857b667
commit
e1e02fc1a1
10 files changed
+432
-56
No files matched your search
+10
-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, setPublicDealTravel, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, 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 === deal.to && row.asker === deal.from && row.turn === deal.turn &&
|
||||
row.player === publicDealTravelActor(deal) && 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 &&
|
||||
@@ -23512,7 +23512,7 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
|
||||
/** 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);
|
||||
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) &&
|
||||
@@ -42396,7 +42396,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
const keys = Object.keys(action).sort().join(',');
|
||||
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
|
||||
keys === 'target,terms,travel,type' || keys === 'payment,target,terms,travel,type') && typeof action.target === 'string';
|
||||
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type') && Array.isArray(action.targets) &&
|
||||
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type' ||
|
||||
keys === 'targets,terms,travel,type') && Array.isArray(action.targets) &&
|
||||
action.targets.every(target => typeof target === 'string');
|
||||
const hasPayment = Object.hasOwn(action, 'payment');
|
||||
requireRule((single || multiple) && typeof action.terms === 'string' &&
|
||||
@@ -42413,8 +42414,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
? 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 && !g.advanced && g.expansions.length === 0 &&
|
||||
nativePromiseScopeSupported(g), 'Choose a two-player travel agreement in a supported Basic table.');
|
||||
requireRule(!g.advanced && g.expansions.length === 0 && nativePromiseScopeSupported(g),
|
||||
'Choose a 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.');
|
||||
@@ -42439,8 +42440,10 @@ 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)
|
||||
if (current.status === 'offered' && next.status === 'active' && next.travel) {
|
||||
requireRule(id === publicDealTravelActor(next), 'The traveler must give the final consent.');
|
||||
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.');
|
||||
|
||||
+36
-15
@@ -15,12 +15,15 @@ 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. */
|
||||
/** One invited recipient commits to one native action (or its absence).
|
||||
* Old two-party records identify that recipient through `to`. */
|
||||
export type PublicDealTravel = {
|
||||
kind: 'ship' | 'move';
|
||||
territory: string;
|
||||
minimum: number;
|
||||
answer: boolean;
|
||||
/** Present only in a multi-party term; the selected recipient accepts last. */
|
||||
actor?: string;
|
||||
};
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
@@ -86,6 +89,10 @@ export function publicDealAcceptVotes(deal: PublicDeal): string[] {
|
||||
return deal.status === 'active' || deal.status === 'released'
|
||||
? [deal.from, deal.to] : [deal.from];
|
||||
}
|
||||
/** Call after ledger validation; legacy two-party terms need no new field. */
|
||||
export function publicDealTravelActor(deal: PublicDeal): string {
|
||||
return deal.travel?.actor ?? deal.to;
|
||||
}
|
||||
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
|
||||
return new Set(votes).size === votes.length &&
|
||||
votes.every(vote => parties.includes(vote)) &&
|
||||
@@ -112,13 +119,16 @@ 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. */
|
||||
/** Parse an exact travel claim; the multi-party actor is validated against the deal. */
|
||||
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
|
||||
requireDeal(object(value) && Object.keys(value).sort().join(',') === 'answer,kind,minimum,territory' &&
|
||||
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)) &&
|
||||
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 };
|
||||
return { kind: claim.kind, territory: claim.territory, minimum: claim.minimum, answer: value.answer,
|
||||
...(keys.startsWith('actor,') ? {actor: value.actor as string} : {}) };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
||||
throw error;
|
||||
@@ -164,10 +174,17 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||
if (Object.hasOwn(deal, 'travel')) {
|
||||
requireDeal(!multi && !Object.hasOwn(deal, 'paymentStatus') &&
|
||||
(!payment || payment.kind === 'immediateBribe' && payment.payer === deal.from && payment.recipient === deal.to),
|
||||
'A travel term requires a two-party deal with no payment or an immediate bribe from the proposer to the recipient.');
|
||||
parsePublicDealTravel(deal.travel);
|
||||
const travel = parsePublicDealTravel(deal.travel);
|
||||
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' &&
|
||||
payment.payer === deal.from && payment.recipient === deal.to)),
|
||||
'Choose an invited traveler; payment is available only for a two-party proposer-to-recipient immediate bribe.');
|
||||
// The actor's final acceptance is checked privately. An unfinished offer
|
||||
// cannot already contain that vote, even after a decline or withdrawal.
|
||||
if (multi && deal.status !== 'active' && deal.status !== 'released')
|
||||
requireDeal(!(deal.acceptVotes as string[]).includes(travel.actor!),
|
||||
'The committed traveler accepts after all other invited participants.');
|
||||
}
|
||||
if (payment?.kind === 'deferredBribe') {
|
||||
const status = deal.paymentStatus;
|
||||
@@ -218,15 +235,16 @@ export function createMultipartyPublicDeal(
|
||||
...paymentTerm };
|
||||
}
|
||||
|
||||
/** Attach a travel term while constructing a fresh two-party offer. */
|
||||
/** Attach a travel term while constructing a fresh 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, 'paymentStatus') && (!deal.payment ||
|
||||
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to),
|
||||
'Only a fresh two-party offer with no payment or an immediate proposer-to-recipient bribe can add a travel term.');
|
||||
notAllied(participants(players, [deal.from, deal.to]));
|
||||
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'travel') &&
|
||||
!Object.hasOwn(deal, 'paymentStatus') && (deal.participants
|
||||
? !!deal.acceptVotes && deal.acceptVotes.length === 1 && deal.acceptVotes[0] === deal.from && !deal.payment
|
||||
: !Object.hasOwn(deal, 'acceptVotes') && (!deal.payment ||
|
||||
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to)),
|
||||
'Only a fresh offer may add a travel term with a supported payment.');
|
||||
notAllied(participants(players, publicDealParticipants(deal)));
|
||||
const result = { ...deal, travel: parsePublicDealTravel(value) };
|
||||
validatePublicDeals([result], players);
|
||||
return result;
|
||||
@@ -243,6 +261,9 @@ export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDeal
|
||||
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
||||
const alreadyAccepted = publicDealAcceptVotes(deal);
|
||||
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
|
||||
if (deal.travel && actor === publicDealTravelActor(deal))
|
||||
requireDeal(parties.every(seat => seat === actor || alreadyAccepted.includes(seat)),
|
||||
'The committed traveler accepts after all other invited participants.');
|
||||
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
|
||||
const active = accepts.length === parties.length;
|
||||
return { ...deal, acceptVotes: accepts, status: active ? 'active' : 'offered', releaseVotes: [],
|
||||
|
||||
+2
-2
@@ -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, 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.',
|
||||
'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.',
|
||||
'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.',
|
||||
],
|
||||
@@ -313,7 +313,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ 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'] },
|
||||
{ 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. Group travel has actual multi-seat consent, physical travel and release coverage. 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-group-travel-runtime.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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user