Allow travelers to pay in two-party travel deals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 17:39:06 +11:00
1 parent c1ffa1c28a
commit 3987c5c16b
9 files changed
+90 -21

No files matched your search

+4 -4
View File
@@ -72,7 +72,7 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
(groupTravel ? !!payment || travelKeys !== 'actor,answer,kind,minimum,territory' ||
typeof travel.actor !== 'string' || !targets.includes(travel.actor) :
travelKeys !== 'answer,kind,minimum,territory' ||
!!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0])) ||
!!payment && payment.kind !== 'immediateBribe') ||
(Object.hasOwn(travel, 'dueTurn') && (!Number.isSafeInteger(travelDeadline) || (travelDeadline as number) < 1 ||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || (travelDeadline as number) < currentTurn)))) ||
!['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) ||
@@ -227,7 +227,7 @@ export function PublicDeals({ game, act, busy }: {
...(selected.length > 1 ? {actor:travelActor} : {}),
...(travelTurn > game.turn ? {dueTurn:travelTurn} : {})} : undefined;
const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' &&
payment.payer === me?.id && selected.length === 1 && payment.recipient === selected[0];
selected.length === 1 && paymentPeopleAndAmountValid;
const offer = legalSelection && paymentValid && (!includeTravel || travelDueValid)
? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null;
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
@@ -308,8 +308,8 @@ export function PublicDeals({ game, act, busy }: {
{includeTravel && <fieldset>
<legend>Travel term</legend>
<p className="fine">This term concerns one physical action during the selected turn. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.</p>
<p className="fine">A two-party travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening and can use it after collection. Group travel terms cannot include a payment.</p>
{!combinedPaymentValid && <p role="alert">For two-party travel, choose an immediate payment from the proposer to the selected recipient. Group travel cannot include payment.</p>}
<p className="fine">A two-party travel term may include an immediate payment in either direction. The payment recipient collects their share at the next Mentat opening and can use it after collection. Group travel terms cannot include a payment.</p>
{!combinedPaymentValid && <p role="alert">For two-party travel, choose an immediate payment between the two participants. Group travel cannot include payment.</p>}
{selected.length > 1 && <label>Committed traveler
<select value={selected.includes(travelActor) ? travelActor : ''} disabled={disabled}
onChange={event => setTravelActor(event.target.value)}>
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Reverse paid travel — 29 October. Prototyped / Partial.** Two-party Basic
travel offers may pair the travel with an immediate bribe from the traveler
to the proposer. Final consent and payment are atomic, and current-turn travel
must remain affordable after payment. Native tests cover a completed shipment
and rollback on insufficient remaining funds. Eleven related files pass 76
cases; deferred and group-paid travel, broader interactions and deployed
verification remain open. The named-Ghola guide also now credits pending
native revival income before clearing its response in the detached forecast.
[Contract](PUBLIC_DEALS.md#reverse-immediate-payment-with-travel--29-october-2026).
**Truthtrance named Ghola leader — 29 October. Prototyped / Partial.** A
provisional question binds an actual named leader return with Ghola, distinct
from ordinary revival or returning forces. Private human and bot guidance uses
+17 -3
View File
@@ -1,5 +1,19 @@
# Public deal records
## Reverse immediate payment with travel — 29 October 2026
**Prototyped / Partial.** A two-party Basic classic offer can pair a travel term with an immediate bribe from the traveler to the
proposer as well as in the existing proposer-to-traveler direction. The
traveler's final acceptance must atomically establish the travel obligation
and settle the payment: an unavailable payment or incompatible due-turn
travel completion leaves the offer and balances unchanged. The payment uses
the native bribe and its deferred recipient collection; it does not invent an
additional travel resource or foreign consent. Current- and later-turn travel
retain their respective feasibility policies. Deferred payments paired with
travel and multi-party paid travel remain outside this prototype. Native action tests cover payment, completed travel and rollback when the
payment would make a current-turn shipment unaffordable. Broader interactions
and deployed verification remain open.
## Structured current-turn travel — 29 October 2026
**Prototyped / Partial.** In a quiet Basic classic table, an offer between two
@@ -17,12 +31,12 @@ can decline, and release still requires every party. This actor-last online
ordering is provisional and does not restrict the printed scope of agreements.
A two-party offer can also pair that term with an immediate ordinary bribe
from the proposer to the traveler. Final acceptance binds the travel and
between the proposer and traveler in either direction. Final acceptance binds the travel and
settles the payment together; if either cannot proceed, neither takes effect.
The payer loses the spice immediately, and the recipient's share becomes
spendable at the next Mentat opening, so it cannot fund this turn's travel.
Later mutual release does not refund the settled bribe. Reverse payments,
deferred payments in the same offer and multi-party paid travel remain open.
Later mutual release does not refund the settled bribe. Deferred payments in
the same offer and multi-party paid travel remain open.
Each receipt retains its public-deal identity, independent of Truthtrance
history and cards. Unanimous release frees only that agreement; completion,
+1 -1
View File
@@ -37529,9 +37529,9 @@ function projectedGholaLeaderGuide(g: Game, player: string): { action: Action }
try {
const trial = applyActionInner(g, player, action, 'movementPreparation');
if (!trial.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled)) continue;
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
const owner = getPlayer(trial, player);
owner.spice += pendingBattleRevivalIncome(trial, owner);
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
reconcileRevivalPromises(trial, { actor: player, action });
if (turnActionCompletion(trial, player)) return { action };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
+3 -4
View File
@@ -191,9 +191,8 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
'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' &&
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.');
: !Object.hasOwn(travel, 'actor') && (!payment || payment.kind === 'immediateBribe')),
'Choose an invited traveler; payment is available only as a two-party 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')
@@ -256,7 +255,7 @@ export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: r
!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)),
deal.payment.kind === 'immediateBribe')),
'Only a fresh offer may add a travel term with a supported payment.');
notAllied(participants(players, publicDealParticipants(deal)));
const result = { ...deal, travel: parsePublicDealTravel(value) };
+1
View File
@@ -304,6 +304,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 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.',
'Two-party travel can include an immediate payment in either direction. Final consent and payment are atomic, and current-turn travel must remain feasible after the payer spends. Deferred and multi-party paid travel remain unfinished.',
'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.',
],
+41 -2
View File
@@ -117,11 +117,50 @@ void test('unanimous release ends the pending travel obligation without refundin
assert.equal(player(game, f.target).bribes, escrow);
});
void test('paid travel excludes reversed, deferred and multiparty payment variants', () => {
void test('paid travel excludes deferred and multiparty payment variants', () => {
const f = fixture(), travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const base = { type: 'dealOffer', target: f.target, terms: 'Travel.', travel } satisfies Action;
reject(f.game, f.asker, { ...base, payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: 1 } });
reject(f.game, f.asker, { ...base, payment: { kind: 'deferredBribe', payer: f.asker, recipient: f.target, amount: 1, dueTurn: f.game.turn } });
reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel,
payment: { kind: 'immediateBribe', payer: f.asker, recipient: f.target, amount: 1 } });
});
void test('the travelling recipient can pay the proposer at final consent and then complete shipment', () => {
const f = fixture(), payerBefore = player(f.game, f.target).spice,
recipientBefore = player(f.game, f.asker).bribes;
assert.ok(payerBefore >= f.shipmentCost + 1);
const payment = { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: 1 };
const travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const offered = act(f.game, f.asker, { type: 'dealOffer', target: f.target,
terms: 'Payment for agreed travel.', payment, travel });
const deal = offered.deals!.at(-1)!;
assert.equal(player(offered, f.target).spice, payerBefore);
const accepted = accept(offered, f.target, deal.id);
assert.equal(accepted.deals!.find(row => row.id === deal.id)!.status, 'active');
assert.equal(player(accepted, f.target).spice, payerBefore - 1);
assert.equal(player(accepted, f.asker).bribes, recipientBefore + 1);
assert.equal(accepted.travelPromises!.filter(row => row.sourceDeal === deal.id).length, 1);
assert.equal(status(accepted, f.target, deal.id), 'pending');
const shipped = settle(act(accepted, f.target, f.shipmentAction));
assert.equal(status(shipped, f.target, deal.id), 'fulfilled');
assert.equal(player(shipped, f.target).spice, payerBefore - 1 - f.shipmentCost);
assert.equal(player(shipped, f.asker).bribes, recipientBefore + 1);
assert.deepEqual(inventory(shipped), f.originalCards);
});
void test('reverse payment that consumes the travel funds rolls back the final consent atomically', () => {
const f = fixture(), cash = player(f.game, f.target).spice,
recipientEscrow = player(f.game, f.asker).bribes;
assert.ok(cash >= f.shipmentCost);
const payment = { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: cash };
const travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const offered = act(f.game, f.asker, { type: 'dealOffer', target: f.target,
terms: 'Payment for agreed travel.', payment, travel });
const deal = offered.deals!.at(-1)!;
reject(offered, f.target, { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(offered.deals!.find(row => row.id === deal.id)!.status, 'offered');
assert.equal(player(offered, f.target).spice, cash);
assert.equal(player(offered, f.asker).bribes, recipientEscrow);
assert.equal(offered.travelPromises?.some(row => row.sourceDeal === deal.id) ?? false, false);
assert.deepEqual(inventory(offered), f.originalCards);
});
+9 -2
View File
@@ -53,7 +53,9 @@ void test('the offer packet retains legacy two-party terms and validates an expl
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}), null);
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
assert.equal(publicDealOfferAction(['b'], 'Terms', {...payment,payer:'b',recipient:'a'}, 'a', 2, travel), null);
const reverse = {...payment,payer:'b',recipient:'a'};
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', reverse, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment:reverse, travel});
assert.equal(publicDealOfferAction(['b'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
@@ -142,7 +144,12 @@ void test('the authoring controls compose a proposer payment with recipient trav
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true);
ui.select(0,'immediateBribe'); ui.select(1,'b'); ui.select(2,'a');
assert.equal(button()?.props.disabled,true);
assert.equal(button()?.props.disabled,false);
const reverseForm = ui.render().find(node => node.type === 'form'); assert.ok(reverseForm);
(reverseForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Travel for spice',
payment:{kind:'immediateBribe',payer:'b',recipient:'a',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
});
void test('a payment can also be added after selecting the travel term', () => {
+4 -5
View File
@@ -172,8 +172,8 @@ void test('multiparty travel rejects forged actors, early votes, payments and no
assert.equal(respondPublicDeal(acceptedOther, seats, 'b', true).status, 'active');
});
void test('a proposer-to-recipient immediate payment can accompany travel through the complete deal lifecycle', () => {
const payment = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 3 } as const;
for (const [payer, recipient] of [['a', 'b'], ['b', 'a']] as const) void test(`a ${payer}-to-${recipient} immediate payment can accompany travel through the complete deal lifecycle`, () => {
const payment = { kind: 'immediateBribe', payer, recipient, amount: 3 } as const;
const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
const offered = setPublicDealTravel(original, term, seats);
assert.equal(original.travel, undefined);
@@ -193,11 +193,10 @@ void test('a proposer-to-recipient immediate payment can accompany travel throug
assert.equal(released.status, 'released');
});
void test('paid travel rejects reverse, deferred, multiparty and forged persisted payment shapes', () => {
const reverse = { kind: 'immediateBribe', payer: 'b', recipient: 'a', amount: 1 } as const;
void test('paid travel rejects deferred, multiparty and forged persisted payment shapes', () => {
const deferred = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } as const;
const paid = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 } as const;
for (const payment of [reverse, deferred]) {
for (const payment of [deferred]) {
const offer = createPublicDeal(seats, 'a', 'b', 'Terms', 3, `invalid-${payment.kind}`, payment);
assert.throws(() => setPublicDealTravel(offer, term, seats), PublicDealError);
assert.throws(() => validatePublicDeals([{ ...offer, travel: term }], seats), PublicDealError);