diff --git a/components/public-deals.tsx b/components/public-deals.tsx
index dc27ff24..b1e3a8a4 100644
--- a/components/public-deals.tsx
+++ b/components/public-deals.tsx
@@ -60,7 +60,8 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
!Number.isSafeInteger(payment.amount) || payment.amount < 1 ||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 ||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null;
- if (travel && (targets.length !== 1 || !proposer || targets[0] === proposer || !!payment ||
+ if (travel && (targets.length !== 1 || !proposer || targets[0] === proposer ||
+ !!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0]) ||
Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' ||
!['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) ||
!Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 ||
@@ -111,6 +112,7 @@ export function PublicDealList({ game, act, disabled }: {
{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}
{deal.terms}
{payment?.kind === 'immediateBribe' && Immediate payment term: {name(payment.payer)} pays {payment.amount} spice to {name(payment.recipient)} on final acceptance. The payer loses the gross amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before that share is allocated. Releasing the deal later does not refund the payment.
}
+ {payment?.kind === 'immediateBribe' && travel && The recipient cannot use this payment to fund the current travel: their share is collected at the next Mentat opening.
}
{payment?.kind === 'deferredBribe' && Deferred payment term: {name(payment.payer)} pays exactly {payment.amount} spice to {name(payment.recipient)} on or before the end of turn {payment.dueTurn}. The recipient collects their share at the next Mentat opening; a Bureaucrat may divert two spice to the bank. {paymentStatus ? `Payment status: ${paymentStatus}.` : 'Payment becomes binding on final acceptance.'}
}
{travel && Current-turn travel term: {name(deal.to)} {deal.status === 'offered' || deal.status === 'declined' || deal.status === 'withdrawn' ? 'would' : 'agrees to'} {travel.answer ? 'make' : 'avoid'} {travel.kind === 'ship' ? 'a reserve shipment' : 'a ground movement'} of at least {travel.minimum} physical forces in one action to {travelDestination ?? travel.territory} during turn {deal.turn}. {travelStatus ? `Travel status: ${travelStatus}.` : deal.status === 'offered' ? 'The term becomes binding on final acceptance.' : deal.status === 'declined' || deal.status === 'withdrawn' ? 'The term did not become binding.' : 'Travel status is unavailable.'}
}
{deal.status === 'offered' &&
@@ -194,6 +196,8 @@ export function PublicDeals({ game, act, busy }: {
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
minimum:Number(travelMinimum), answer:travelAnswer} : undefined;
+ const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' &&
+ payment.payer === me?.id && selected.length === 1 && payment.recipient === selected[0];
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null;
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
const mayOffer = game.status === 'playing' && me && !me.bot;
@@ -222,7 +226,7 @@ export function PublicDeals({ game, act, busy }: {
})}
- setIncludePayment(event.target.checked)} />
Include a spice payment
@@ -264,14 +268,16 @@ export function PublicDeals({ game, act, busy }: {
{paymentKind === 'deferredBribe' && !dueValid &&
Choose a whole due turn no earlier than turn {game.turn}.
}
}
- setIncludeTravel(event.target.checked)} />
Include a current-turn travel term
- {!includeTravel && !includePayment && selected.length !== 1 && Select exactly one human recipient to add a travel term.
}
+ {!includeTravel && selected.length !== 1 && Select exactly one human recipient to add a travel term.
}
{includeTravel &&
Current-turn travel term
- The selected recipient is the committed player. This term concerns one physical action during turn {game.turn}; it cannot be combined with a payment or additional recipients. Written terms remain player-managed.
+ The selected recipient is the committed player. This term concerns one physical action during turn {game.turn} and cannot include additional recipients. Written terms remain player-managed.
+ A travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening, so it cannot fund this turn’s shipment or movement.
+ {!combinedPaymentValid && For a travel term, choose an immediate payment from the proposer to the selected recipient, or remove the payment.
}
This initial preview supports Basic games with classic factions and no expansions or optional modules. Final acceptance needs a quiet native window before or during an eligible Shipment and Movement opportunity. The server checks whether the term can still be honored; an unavailable term cannot be accepted.
Travel action
setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 72e76b4e..0b21f11d 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
+**Paid current-turn travel deals — 29 October. Prototyped / Partial.** A
+two-human Basic classic offer can pair the recipient's travel term with an
+immediate bribe from the proposer. Final consent binds the existing native
+travel receipt and settles payment atomically; collection remains at Mentat,
+so incoming spice cannot fund this travel. Native tests cover rollback,
+collection, release without refund, invalid combinations and the existing
+timing policy for a later pledge. The 27-file related batch passes 168 cases,
+and the separate pledge interaction passes one case. Type checking, lint and
+the production build pass. Reverse or deferred payments in the same offer,
+multi-party travel, wider interactions and deployed acceptance remain open.
+[Scope](PUBLIC_DEALS.md#structured-current-turn-travel--29-october-2026).
+
**Structured public travel deals — 29 October. Prototyped / Partial.** A
two-human Basic classic offer can require or forbid its recipient's one
current-turn physical reserve shipment or ground move to a named territory.
@@ -226,9 +238,12 @@ completed request, a possible expected denial, not proof of an authorization
failure; the exception's cause was not recorded. Acceptance diagnostics now
distinguish request exceptions from the last completed response and report only
sanitized details; the security checks are unchanged. Two focused diagnostic
-cases pass. Run #86 succeeded; run #87 was canceled before starting and run
-#88 for the Auditor checkpoint succeeded with the updated diagnostics. Run #89
-was canceled before starting and run #90 was running at the latest check.
+cases pass. Run #101 succeeded. Run #103 built the container but failed one
+integration test after a revoked-seat POST: the following restored-seat GET
+lost its connection before receiving a response. Unread request-body handling
+on a reused connection is a hypothesis, not a confirmed cause. Run #104 for
+`82c639c` was running at the latest check; publication after the #103 failure
+was skipped. A disposable-container keep-alive reproduction remains open.
**Truthtrance ally funding — 29 October. Prototyped / Partial.**
Supported quiet Basic Bidding and Shipment/Movement questions can bind a future
diff --git a/docs/PUBLIC_DEALS.md b/docs/PUBLIC_DEALS.md
index 6306466d..e5e8b239 100644
--- a/docs/PUBLIC_DEALS.md
+++ b/docs/PUBLIC_DEALS.md
@@ -12,6 +12,14 @@ count. The existing native travel planner enforces the accepted term and
provides the owner's next legal action; it grants no extra transport. A later
autopilot handover uses that same guide.
+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
+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.
+
Each receipt retains its public-deal identity, independent of Truthtrance
history and cards. Unanimous release frees only that agreement; completion,
mutual release and a term made impossible are shown separately. A bounded
diff --git a/game/engine.ts b/game/engine.ts
index 1e1fa570..83bf6798 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -42395,7 +42395,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
if (action.type === 'dealOffer') {
const keys = Object.keys(action).sort().join(',');
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
- keys === 'target,terms,travel,type') && typeof action.target === 'string';
+ 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) &&
action.targets.every(target => typeof target === 'string');
const hasPayment = Object.hasOwn(action, 'payment');
@@ -42413,7 +42413,7 @@ 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 && !hasPayment && !g.advanced && g.expansions.length === 0 &&
+ requireRule(single && !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));
}
diff --git a/game/public-deals.ts b/game/public-deals.ts
index 0db15cb2..a00e1f3a 100644
--- a/game/public-deals.ts
+++ b/game/public-deals.ts
@@ -161,13 +161,14 @@ 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 (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);
+ }
if (payment?.kind === 'deferredBribe') {
const status = deal.paymentStatus;
requireDeal(deal.status === 'active' ? status === 'pending' || status === 'paid' || status === 'void' :
@@ -222,8 +223,9 @@ export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: r
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.');
+ !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]));
const result = { ...deal, travel: parsePublicDealTravel(value) };
validatePublicDeals([result], players);
diff --git a/tests/public-deals-paid-travel-pledge.test.ts b/tests/public-deals-paid-travel-pledge.test.ts
new file mode 100644
index 00000000..6a16a1e3
--- /dev/null
+++ b/tests/public-deals-paid-travel-pledge.test.ts
@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { actTruthAlliance as act, advanceTruthAlliance as advance, createTruthAllianceFixture,
+ truthAllianceClean as clean, truthAllianceInventory as inventory, truthAlliancePlayer as player } from './fixture-truth-alliance';
+import { declareTruthBribe } from './fixture-truth-bribe';
+
+/** The original Basic H/A/E setup, real worm/Nexus alliance and later Bidding
+ * from the shared fixture. The offer, Truthtrance question, answer and final
+ * consent are all real actions; no wallet, pledge or commitment is staged. */
+void test('a previously offered paid travel deal can release a later unaffordable pledge on recipient consent', () => {
+ const f = createTruthAllianceFixture({ consent: 'allied' });
+ let game = advance(f.game, state => state.phase === 3 && !!state.auction && clean(state));
+ assert.equal(player(game, f.target).ally, f.partner);
+ assert.equal(player(game, f.partner).ally, f.target);
+ assert.equal(player(game, f.asker).ally, null);
+ const minimum = 4, initialCash = player(game, f.target).spice;
+ const amount = initialCash - minimum + 1;
+ assert.ok(amount > 0);
+ game = act(game, f.target, { type: 'dealOffer', target: f.asker,
+ terms: 'Immediate payment for a current-turn travel restriction.',
+ payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount },
+ travel: { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: false } });
+ const offer = game.deals?.at(-1); assert.ok(offer);
+ game = declareTruthBribe(f, game).game;
+ assert.ok(viewGame(game, f.asker).truthPledgeTargets.includes(f.target));
+ game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target,
+ turn: game.turn, phase: game.phase, ally: f.partner, minimum } });
+ assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ const pledge = game.pledgePromises?.at(-1); assert.ok(pledge);
+ assert.ok(!pledge.fulfilled && !pledge.released);
+
+ // A new voluntary payment cannot evade the later pledge, but the original
+ // offer already consented to payment before that answer was made.
+ const before = structuredClone(game);
+ assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount }));
+ assert.deepEqual(game, before);
+ const incoming = player(game, f.asker).bribes;
+ const next = act(game, f.asker, { type: 'dealRespond', deal: offer.id, accept: true });
+ assert.equal(next.deals?.find(deal => deal.id === offer.id)?.status, 'active');
+ assert.equal(player(next, f.target).spice, initialCash - amount);
+ assert.equal(player(next, f.asker).bribes, incoming + amount);
+ assert.deepEqual(next.aid[f.target], before.aid[f.target]);
+ assert.equal(next.pledgePromises?.at(-1)?.released, true);
+ assert.ok(!next.pledgePromises?.at(-1)?.fulfilled);
+ assert.equal(next.travelPromises?.filter(row => row.sourceDeal === offer.id).length, 1);
+ assert.equal(viewGame(next, f.asker).dealTravelStatus[offer.id], 'pending');
+ assert.deepEqual(inventory(next), f.originalCards);
+});
diff --git a/tests/public-deals-paid-travel-runtime.test.ts b/tests/public-deals-paid-travel-runtime.test.ts
new file mode 100644
index 00000000..f2fe8873
--- /dev/null
+++ b/tests/public-deals-paid-travel-runtime.test.ts
@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { actTruthTravel as act, advanceTruthTravel as advance, createTruthTravelFixture as fixture,
+ settleTruthTravel as settle, truthTravelClean as clean, truthTravelInventory as inventory,
+ truthTravelPlayer as player } from './fixture-truth-travel';
+
+type Term = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean };
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before, 'Failed consent and action packets preserve the source table.');
+}
+function offer(game: Game, proposer: string, target: string, travel: Term, amount: number) {
+ const payment = { kind: 'immediateBribe', payer: proposer, recipient: target, amount };
+ const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Payment for agreed travel.', payment, travel });
+ const deal = next.deals?.at(-1); assert.ok(deal);
+ return { game: next, id: deal.id, payment, travel };
+}
+function accept(game: Game, target: string, id: string) {
+ return act(game, target, { type: 'dealRespond', deal: id, accept: true });
+}
+function status(game: Game, viewer: string, id: string) {
+ return viewGame(game, viewer).dealTravelStatus[id];
+}
+
+/** Native Basic Atreides/Emperor game at the real pre-shipment or shipped
+ * phase-five boundary. Only original unopened deals/Spice and scalar lotteries
+ * are selected by the shared TruthTravel fixture; all agreements and payments
+ * below are real game actions. */
+void test('final consent atomically pays the proposer bribe and creates the travel obligation', () => {
+ const f = fixture(), initial = f.game;
+ const beforePayer = player(initial, f.asker).spice, beforeCash = player(initial, f.target).spice,
+ beforeEscrow = player(initial, f.target).bribes;
+ assert.ok(beforePayer >= 3);
+ const offered = offer(initial, f.asker, f.target,
+ { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3);
+ assert.equal(player(offered.game, f.asker).spice, beforePayer);
+ assert.equal(player(offered.game, f.target).bribes, beforeEscrow);
+ const game = accept(offered.game, f.target, offered.id);
+ assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
+ assert.equal(player(game, f.asker).spice, beforePayer - 3);
+ assert.equal(player(game, f.target).spice, beforeCash);
+ assert.equal(player(game, f.target).bribes, beforeEscrow + 3);
+ assert.equal(game.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
+ assert.equal(status(game, f.target, offered.id), 'pending');
+ reject(game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
+ const next = settle(act(game, f.target, f.shipmentAction));
+ assert.equal(status(next, f.target, offered.id), 'fulfilled');
+ assert.equal(player(next, f.target).spice, beforeCash - f.shipmentCost);
+ assert.equal(player(next, f.target).bribes, beforeEscrow + 3);
+ assert.deepEqual(inventory(next), f.originalCards);
+
+ const collection = advance(next, state => state.turn === initial.turn && state.phase === 7 && clean(state));
+ const cashBeforeMentat = player(collection, f.target).spice;
+ assert.equal(player(collection, f.target).bribes, beforeEscrow + 3);
+ const mentat = advance(collection, state => state.turn === initial.turn && state.phase === 8 && clean(state));
+ assert.equal(player(mentat, f.target).spice, cashBeforeMentat + beforeEscrow + 3);
+ assert.equal(player(mentat, f.target).bribes, 0);
+ assert.deepEqual(inventory(mentat), f.originalCards);
+});
+
+void test('a payer who spends after offering cannot partly activate or pay the deal', () => {
+ const f = fixture(), payerCash = player(f.game, f.asker).spice; assert.ok(payerCash >= 1);
+ const offered = offer(f.game, f.asker, f.target,
+ { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, payerCash);
+ const spent = act(offered.game, f.asker, { type: 'bribe', target: f.target, amount: 1 });
+ assert.equal(player(spent, f.asker).spice, payerCash - 1);
+ const before = structuredClone(spent);
+ reject(spent, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
+ assert.deepEqual(spent, before);
+ assert.equal(spent.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
+ assert.equal(spent.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
+ assert.deepEqual(inventory(spent), f.originalCards);
+});
+
+void test('an infeasible promised shipment rolls back payment and final acceptance', () => {
+ const f = fixture({ window: 'shipped' }), before = f.game;
+ const offered = offer(before, f.asker, f.target,
+ { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1);
+ const payer = player(offered.game, f.asker).spice, cash = player(offered.game, f.target).spice,
+ escrow = player(offered.game, f.target).bribes;
+ reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
+ assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
+ assert.equal(player(offered.game, f.asker).spice, payer);
+ assert.equal(player(offered.game, f.target).spice, cash);
+ assert.equal(player(offered.game, f.target).bribes, escrow);
+ assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
+});
+
+void test('the recipient cannot use incoming Mentat escrow to fund the promised shipment', () => {
+ const f = fixture(), cash = player(f.game, f.target).spice; assert.ok(cash >= 2);
+ const spent = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: cash - 1 });
+ assert.equal(player(spent, f.target).spice, 1);
+ assert.ok(player(spent, f.asker).spice >= 3);
+ const offered = offer(spent, f.asker, f.target,
+ { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3);
+ reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
+ assert.equal(player(offered.game, f.target).spice, 1);
+ assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
+ assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
+});
+
+void test('unanimous release ends the pending travel obligation without refunding payment', () => {
+ const f = fixture(), offered = offer(f.game, f.asker, f.target,
+ { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1);
+ let game = accept(offered.game, f.target, offered.id);
+ const paid = player(game, f.asker).spice, escrow = player(game, f.target).bribes;
+ game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
+ assert.equal(status(game, f.target, offered.id), 'pending');
+ game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
+ assert.equal(status(game, f.target, offered.id), 'released');
+ assert.equal(player(game, f.asker).spice, paid);
+ assert.equal(player(game, f.target).bribes, escrow);
+ game = settle(act(game, f.target, f.currentEnd));
+ assert.equal(player(game, f.asker).spice, paid);
+ assert.equal(player(game, f.target).bribes, escrow);
+});
+
+void test('paid travel excludes reversed, 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 } });
+});
diff --git a/tests/public-deals-travel-controls.test.tsx b/tests/public-deals-travel-controls.test.tsx
index 76e58bd2..53de15b8 100644
--- a/tests/public-deals-travel-controls.test.tsx
+++ b/tests/public-deals-travel-controls.test.tsx
@@ -1,7 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
+import * as jsxRuntime from 'react/jsx-runtime';
+import ts from 'typescript';
+import * as board from '../game/board';
+import * as catalog from '../game/catalog';
+import * as coalitionAlliance from '../game/coalition-alliance';
+import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import { TravelPromises } from '../components/travel-promises';
import type { Action, GameView } from '../game/engine';
@@ -29,7 +37,11 @@ void test('the offer packet retains the original recipient action and validates
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
- assert.equal(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel), 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);
+ 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);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
for (const invalid of [
@@ -43,6 +55,94 @@ void test('the offer packet retains the original recipient action and validates
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
});
+void test('the combined offer explains payment timing and the recipient can accept or decline', () => {
+ const offered = {...base, deals:[{...deal,status:'offered',payment}], dealTravelStatus:{},
+ bribeOptions:{blocked:null}} as unknown as GameView;
+ const html = markup(offered);
+ assert.match(html, /Alice \(Atreides\) pays 2 spice to Basil \(Harkonnen\) on final acceptance/);
+ assert.match(html, /recipient collects their share at the next Mentat opening/);
+ assert.match(html, /cannot use this payment to fund the current travel/);
+ const sent:Action[] = [];
+ const tree = PublicDealList({game:offered, disabled:false, act(action) {sent.push(action);}});
+ const accept = elements(tree).find(node => node.props.children === 'Accept deal');
+ const decline = elements(tree).find(node => node.props.children === 'Decline deal');
+ assert.ok(accept); assert.ok(decline);
+ assert.equal(accept.props.disabled, false); assert.equal(decline.props.disabled, false);
+ (accept.props.onClick as () => void)();
+ assert.deepEqual(sent, [{type:'dealRespond',deal:'deal',accept:true}]);
+ const busyTree = PublicDealList({game:offered, disabled:true, act() {}});
+ assert.equal(elements(busyTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
+});
+
+/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
+function combinedForm() {
+ const states:unknown[] = [], sent:Action[] = [];
+ let cursor = 0;
+ const exported:{PublicDeals?:typeof PublicDeals} = {};
+ runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url),'utf8'), {
+ compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX},
+ }).outputText, {exports:exported, require(name:string) {
+ if (name === 'react/jsx-runtime') return jsxRuntime;
+ if (name === 'react') return {useState(initial:unknown) {
+ const index = cursor++;
+ if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
+ return [states[index], (next:unknown) => {states[index] = typeof next === 'function' ? next(states[index]) : next;}];
+ }, useRef:() => ({current:null}), useEffect:() => {}};
+ if (name === '@/components/ui/button') return {Button:'button'};
+ if (name === '@/game/board') return board;
+ if (name === '@/game/catalog') return catalog;
+ if (name === '@/game/coalition-alliance') return coalitionAlliance;
+ if (name === '@/game/public-deals') return publicDeals;
+ throw new Error(`Unexpected import ${name}`);
+ }});
+ assert.ok(exported.PublicDeals);
+ const game = {...base, me:'a', deals:[]} as GameView;
+ const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
+ const check = (index:number) => {
+ const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
+ assert.ok(node); assert.equal(node.props.disabled,false);
+ (node.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:true}});
+ };
+ const select = (index:number,value:string) => {
+ const node = render().filter(element => element.type === 'select')[index]; assert.ok(node);
+ (node.props.onChange as (event:{target:{value:string}}) => void)({target:{value}});
+ };
+ return {render, check, select, sent};
+}
+
+void test('the authoring controls compose a proposer payment with recipient travel and reject invalid combinations', () => {
+ const ui = combinedForm();
+ ui.check(0); // Select recipient.
+ ui.check(1); // Include payment.
+ ui.select(1,'a'); ui.select(2,'b');
+ ui.check(2); // Include travel while payment is selected.
+ ui.select(5,'carthag');
+ const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
+ (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
+ const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
+ assert.equal(button()?.props.disabled,false);
+ const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
+ (form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
+ assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Travel for spice',
+ payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1},
+ 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);
+});
+
+void test('a payment can also be added after selecting the travel term', () => {
+ const ui = combinedForm();
+ ui.check(0); ui.check(2);
+ ui.select(2,'carthag');
+ ui.check(1);
+ ui.select(1,'a'); ui.select(2,'b');
+ const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
+ (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}});
+ const submit = ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
+ assert.equal(submit?.props.disabled,false);
+});
+
void test('the public deal shows the exact recipient, travel intent, turn and authoritative status', () => {
const html = markup(base);
assert.match(html, /Current-turn travel term/);
diff --git a/tests/public-deals-travel-schema.test.ts b/tests/public-deals-travel-schema.test.ts
index f4a0690e..e42b6190 100644
--- a/tests/public-deals-travel-schema.test.ts
+++ b/tests/public-deals-travel-schema.test.ts
@@ -57,21 +57,61 @@ void test('a recipient accepts and both release an unchanged current-turn travel
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
});
-void test('travel is incompatible with multi-party, payment, replacement and malformed persisted terms', () => {
+void test('travel is incompatible with multi-party, replacement and malformed persisted terms', () => {
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
- const payment = createPublicDeal(seats, 'a', 'b', 'Terms', 3, 'paid',
- { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 });
const offered = setPublicDealTravel(fresh(), term, seats);
const active = respondPublicDeal(offered, seats, 'b', true);
- for (const deal of [multi, payment, offered, active])
+ for (const deal of [multi, offered, active])
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
for (const invalid of [
- { ...multi, travel: term }, { ...payment, travel: term },
+ { ...multi, travel: term },
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
});
+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;
+ const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
+ const offered = setPublicDealTravel(original, term, seats);
+ assert.equal(original.travel, undefined);
+ const active = respondPublicDeal(offered, seats, 'b', true);
+ const released = releasePublicDeal(releasePublicDeal(active, seats, 'a', true), seats, 'b', true);
+ for (const deal of [offered, active, released, respondPublicDeal(offered, seats, 'b', false),
+ withdrawPublicDeal(offered, seats, 'a')]) {
+ const roundtrip = JSON.parse(JSON.stringify(deal)) as PublicDeal;
+ const before = JSON.stringify(roundtrip);
+ validatePublicDeals([roundtrip], seats);
+ assert.equal(JSON.stringify(roundtrip), before);
+ assert.deepEqual(roundtrip.payment, payment);
+ assert.deepEqual(roundtrip.travel, term);
+ assert.equal(Object.hasOwn(roundtrip, 'paymentStatus'), false);
+ }
+ assert.equal(active.status, 'active');
+ 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;
+ 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]) {
+ 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);
+ }
+ const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi-paid', paid);
+ assert.throws(() => setPublicDealTravel(multi, term, seats), PublicDealError);
+ for (const invalid of [
+ { ...multi, travel: term },
+ { ...fresh(), payment: { ...paid, recipient: 'c' }, travel: term },
+ { ...fresh(), payment: { ...paid, amount: 0 }, travel: term },
+ { ...fresh(), payment: { ...paid, extra: true }, travel: term },
+ { ...fresh(), payment: undefined, travel: term },
+ { ...fresh(), payment: paid, paymentStatus: 'paid', travel: term },
+ ]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
+});
+
void test('legacy two-party and multi-party records validate without acquiring travel fields', () => {
const legacy = fresh();
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');