Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 07:45:17 +11:00
1 parent ac1f32c859
commit 82c639c977
13 files changed
+777 -28

No files matched your search

+69 -7
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import { faction } from '@/game/catalog';
import { TERRITORIES } from '@/game/board';
import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import type { Action, GameView } from '@/game/engine';
@@ -11,6 +12,10 @@ type Deal = GameView['deals'][number];
type ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number };
type DeferredBribe = { kind: 'deferredBribe'; payer: string; recipient: string; amount: number; dueTurn: number };
type DealPayment = ImmediateBribe | DeferredBribe;
type DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean };
type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released';
type TravelDeal = Deal & { travel?: DealTravel };
type TravelProjection = GameView & { dealTravelStatus?: Record<string, DealTravelStatus> };
type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
type DeferredPaymentProjection = GameView & {
deferredDealPaymentOptions?: Record<string, { blocked: string | null }>;
@@ -38,12 +43,16 @@ export function publicDealRequests(game: GameView): string[][] {
if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' &&
pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn)
requests.push(['payment', deal.id]);
const travel = (deal as TravelDeal).travel;
if (deal.status === 'active' && travel && deal.to === game.me &&
(game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending')
requests.push(['travel', deal.id]);
return requests;
});
}
/** Keep the legacy single-recipient wire packet when only one seat is chosen. */
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string, currentTurn?: number): Action | null {
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string, currentTurn?: number, travel?: DealTravel): Action | null {
const text = terms.trim();
if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null;
if (payment && (!proposer || (payment.kind !== 'immediateBribe' && payment.kind !== 'deferredBribe') || payment.payer === payment.recipient ||
@@ -51,9 +60,15 @@ 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 ||
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 ||
typeof travel.answer !== 'boolean')) return null;
const offeredPayment = payment ? { payment } : {};
const offeredTravel = travel ? { travel } : {};
return targets.length === 1
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment }
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel }
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment };
}
@@ -82,6 +97,10 @@ export function PublicDealList({ game, act, disabled }: {
other && !allied(game, player, other)));
const payment = (deal as unknown as { payment?: DealPayment }).payment;
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
const travel = (deal as TravelDeal).travel;
const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id];
const travelExpired = !!travel && deal.turn !== game.turn;
const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name;
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null;
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn;
@@ -93,17 +112,19 @@ export function PublicDealList({ game, act, disabled }: {
<p>{deal.terms}</p>
{payment?.kind === 'immediateBribe' && <p><strong>Immediate payment term:</strong> {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.</p>}
{payment?.kind === 'deferredBribe' && <p><strong>Deferred payment term:</strong> {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.'}</p>}
{travel && <p><strong>Current-turn travel term:</strong> {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.'}</p>}
{deal.status === 'offered' && <p>
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
</p>}
{deal.status === 'offered' && !eligible && <p>Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && paymentBlocked && <p>Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && paymentExpired && <p>The payment’s due turn has passed. The offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && travelExpired && <p>The travel term’s turn has passed. The offer may still be declined or withdrawn.</p>}
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
</p>}
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked || !!paymentExpired} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked || !!paymentExpired || travelExpired} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
</div>}
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
@@ -130,6 +151,11 @@ export function PublicDeals({ game, act, busy }: {
const [selected, setSelected] = useState<string[]>([]);
const [terms, setTerms] = useState('');
const [includePayment, setIncludePayment] = useState(false);
const [includeTravel, setIncludeTravel] = useState(false);
const [travelKind, setTravelKind] = useState<'ship' | 'move'>('ship');
const [travelTerritory, setTravelTerritory] = useState('');
const [travelMinimum, setTravelMinimum] = useState('1');
const [travelAnswer, setTravelAnswer] = useState(true);
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState('');
@@ -142,6 +168,7 @@ export function PublicDeals({ game, act, busy }: {
const requests = publicDealRequests(game);
const needsResponse = requests.length > 0;
const paymentDue = requests.some(request => request[0] === 'payment');
const travelDue = requests.some(request => request[0] === 'travel');
const requestKey = JSON.stringify(requests);
const panel = useRef<HTMLDetailsElement>(null);
useEffect(() => { if (requestKey !== '[]' && panel.current) panel.current.open = true; }, [requestKey]);
@@ -165,11 +192,13 @@ export function PublicDeals({ game, act, busy }: {
parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) &&
Number.isSafeInteger(amount) && amount > 0;
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn) : null;
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
minimum:Number(travelMinimum), answer:travelAnswer} : undefined;
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;
return <details ref={panel} className="notice public-deals-panel">
<summary>Public deals{paymentDue ? ' · payment due' : needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
<summary>Public deals{paymentDue ? ' · payment due' : travelDue ? ' · travel term pending' : needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
<p>Deals are public. Every recipient must accept an offer. Players must honor valid terms; an impossible part is void, and all parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.</p>
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
{mayOffer && <form onSubmit={event => {
@@ -182,7 +211,7 @@ export function PublicDeals({ game, act, busy }: {
const checked = selected.includes(player.id);
const conflicts = !!me && (allied(game, me, player) || selectedPlayers.some(other => other.id !== player.id && allied(game, other, player)));
return <label key={player.id} className="checkbox-row">
<input type="checkbox" checked={checked} disabled={disabled || (!checked && conflicts)}
<input type="checkbox" checked={checked} disabled={disabled || (!checked && (conflicts || includeTravel && selected.length >= 1))}
onChange={event => {
const nextChecked = event.target.checked;
setSelected(current => nextChecked
@@ -193,7 +222,7 @@ export function PublicDeals({ game, act, busy }: {
})}
</fieldset>
<label className="checkbox-row">
<input type="checkbox" checked={includePayment} disabled={disabled}
<input type="checkbox" checked={includePayment} disabled={disabled || includeTravel}
onChange={event => setIncludePayment(event.target.checked)} />
Include a spice payment
</label>
@@ -234,6 +263,39 @@ export function PublicDeals({ game, act, busy }: {
{legalSelection && !paymentPeopleAndAmountValid && <p>Choose two different participants and a positive whole spice amount.</p>}
{paymentKind === 'deferredBribe' && !dueValid && <p role="alert">Choose a whole due turn no earlier than turn {game.turn}.</p>}
</fieldset>}
<label className="checkbox-row">
<input type="checkbox" checked={includeTravel} disabled={disabled || includePayment || !includeTravel && selected.length !== 1}
onChange={event => setIncludeTravel(event.target.checked)} />
Include a current-turn travel term
</label>
{!includeTravel && !includePayment && selected.length !== 1 && <p className="fine">Select exactly one human recipient to add a travel term.</p>}
{includeTravel && <fieldset>
<legend>Current-turn travel term</legend>
<p className="fine">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.</p>
<p className="fine">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.</p>
<label>Travel action
<select value={travelKind} disabled={disabled} onChange={event => setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
<option value="ship">Reserve shipment</option>
<option value="move">Ground movement</option>
</select>
</label>
<label>Commitment
<select value={travelAnswer ? 'require' : 'avoid'} disabled={disabled} onChange={event => setTravelAnswer(event.target.value === 'require')}>
<option value="require">Make this action</option>
<option value="avoid">Avoid this action</option>
</select>
</label>
<label>Destination territory
<select value={travelTerritory} disabled={disabled} onChange={event => setTravelTerritory(event.target.value)}>
<option value="">Choose a territory</option>
{TERRITORIES.map(territory => <option key={territory.id} value={territory.id}>{territory.name}</option>)}
</select>
</label>
<label>Minimum physical forces in one action
<input type="number" inputMode="numeric" min={1} max={20} step={1} value={travelMinimum} disabled={disabled}
onChange={event => setTravelMinimum(event.target.value)} />
</label>
</fieldset>}
<label>Public deal terms
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
placeholder="Describe the agreement for the table" />
+13 -7
View File
@@ -105,22 +105,28 @@ export function TravelPromises(props: TravelProps) {
const game = props.game, own = game.travelPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance shipment and movement promises" id="travel-promise-guidance">
return <section className="notice space-y-3" aria-label={own.some(promise => promise.sourceDeal)
? 'Your shipment and movement commitments' : 'Your Truthtrance shipment and movement promises'} id="travel-promise-guidance">
<h3>Your shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'travel', target: promise.player,
Turn {promise.turn}: {promise.sourceDeal ? `Public deal: agreed ${promise.answer ? 'Yes' : 'No'} to` : `You answered ${promise.answer ? 'Yes' : 'No'} to`} {truthQuestionText({ kind: 'travel', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'}
{promise.released ? promise.sourceDeal
? game.dealTravelStatus[promise.sourceDeal] === 'void'
? ' Void: this agreement is no longer possible.' : ' Released: this whole commitment is no longer binding.'
: ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.'
: promise.sourceDeal ? ' This commitment is binding until your Movement turn ends this round.'
: ' This whole answer is binding until your Movement turn ends this round.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
{own.some(promise => !promise.sourceDeal) && <p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Legal alternatives remain available; no fixed branch is selected for you.
Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>}
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer or deal acceptance.
Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves.
Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions.
Original shipment rights, movement opportunities, cards, preparation and costs still apply.
+15
View File
@@ -17,6 +17,21 @@ open mode gates.
## Current checkpoint and work
**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.
Final consent creates a deal-linked native travel commitment; public status,
mutual release, existing action guards and owner-only guidance use that same
receipt. Compatible deals can share one actual action. Native runtime covers
both actions and answers, conflicting and overlapping deals, expiry, release,
staged external loss and four autopilot profiles. The 26-file related batch
passes 157 cases; the final three new files pass 27 cases, and one selected
early-readiness case passes. Type checking, lint and production build pass.
Same-turn deferred-payment and ordinary
Truthtrance-bribe composition are fenced; Advanced, modules, future travel,
arbitrary prose, broader impossibility and deployed acceptance remain open.
[Scope](PUBLIC_DEALS.md#structured-current-turn-travel--29-october-2026).
**Storm and worm rides into the mobile stronghold — 29 October. Prototyped /
Partial.** Ordinary, Cunning and Great Maker worm rides now check the mobile
stronghold's actual pointing sector rather than treating its synthetic interior
+31
View File
@@ -1,5 +1,36 @@
# Public deal records
## Structured current-turn travel — 29 October 2026
**Prototyped / Partial.** In a quiet Basic classic table, a two-human offer
can ask its recipient to make or avoid one native reserve shipment or ground
move of at least a selected number of physical forces to a named printed
territory during the offer turn. The recipient's final acceptance binds the
public term; an offer alone does not. One matching action can satisfy several
compatible travel deals. Earlier actions and sums of smaller moves do not
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.
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
native feasibility search checks acceptance and continuation, with its existing
provisional external-impossibility policy; it is not a proof of every possible
future interaction. Same-turn deferred payments and a live travel deal cannot
be newly combined in either order. Existing simple or expression Truthtrance
bribe commitments block new travel consent; while travel is pending, a simple,
expression or mixed bribe question offers only I don't know until the two
planners can evaluate those obligations together. Optional modules, Advanced, future turns,
multi-party travel terms, arbitrary prose and other action families remain
unfinished. An unaccepted offer from an earlier turn cannot be accepted.
The publisher's [base rulebook, p. 12](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
permits nonallied public deals for future actions. This structured single-action
term and shared-event policy are provisional online representations, not
publisher restrictions on agreements. Complete rules and deployed acceptance
remain open.
## Staged group alliances — 28 October 2026
The unexposed Basic classic group prototype treats each member as allied to
+88 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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'] },
],
},
+13 -3
View File
@@ -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) &&
@@ -7,6 +7,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as board from '../game/board';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import type { Action, GameView } from '../game/engine';
@@ -99,6 +101,8 @@ function controller(game: GameView) {
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
@@ -6,6 +6,8 @@ import { isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import * as catalog from '../game/catalog';
import * as board from '../game/board';
import * as coalitionAlliance from '../game/coalition-alliance';
import * as publicDeals from '../game/public-deals';
import type { Action, GameView } from '../game/engine';
import type { PublicDeals } from '../components/public-deals';
@@ -56,6 +58,8 @@ function controller() {
};
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/coalition-alliance') return coalitionAlliance;
if (name === '@/game/public-deals') return publicDeals;
throw new Error(`Unexpected import ${name}`);
},
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
import { TravelPromises } from '../components/travel-promises';
import type { Action, GameView } from '../game/engine';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{children?:ReactNode; [key:string]:unknown}>(node)) return [];
return [node, ...elements(node.props.children)];
}
const travel = {kind:'ship', territory:'carthag', minimum:3, answer:true} as const;
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
const deal = {id:'deal', from:'a', to:'b', terms:'The agreed journey.', turn:2,
status:'active', releaseVotes:[], travel};
const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState:null,
players:[{id:'a',name:'Alice',faction:'atreides',ally:null}, {id:'b',name:'Basil',faction:'harkonnen',ally:null}],
deals:[deal], dealTravelStatus:{deal:'pending'}} as unknown as GameView;
const markup = (game:GameView, disabled = false) =>
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
void test('the offer packet retains the original recipient action and validates one exact physical term', () => {
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', travel});
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
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.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
for (const invalid of [
{...travel, minimum:0}, {...travel, minimum:21}, {...travel, minimum:1.5},
{...travel, territory:'not-a-territory'}, {...travel, answer:'yes'},
{...travel, kind:'ride'}, {...travel, extra:true},
]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2,
invalid as unknown as typeof travel), null);
// Older callers retain their original exact wire packets.
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), {type:'dealOffer', target:'b', terms:'Terms'});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
});
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/);
assert.match(html, /Basil \(Harkonnen\) agrees to make a reserve shipment of at least 3 physical forces in one action to Carthag during turn 2/);
assert.match(html, /Travel status: pending/);
const avoid = {...base, me:'a', deals:[{...deal, travel:{kind:'move',territory:'arrakeen',minimum:2,answer:false}}],
dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView;
assert.match(markup(avoid), /agrees to avoid a ground movement of at least 2 physical forces in one action to Arrakeen/);
assert.match(markup(avoid), /Travel status: fulfilled/);
for (const status of ['void','released'] as const)
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
});
void test('only the obligated recipient gets a persistent pending reminder', () => {
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
assert.deepEqual(publicDealRequests({...base,me:'a'}), []);
assert.deepEqual(publicDealRequests({...base,dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView), []);
assert.deepEqual(publicDealRequests({...base,deals:[{...deal,status:'released'}]} as unknown as GameView), []);
});
void test('an offered travel term remains actionable to the recipient until its current turn has passed', () => {
const offered = {...base,deals:[{...deal,status:'offered'}],dealTravelStatus:{}} as unknown as GameView;
const actions:Action[] = [];
const tree = PublicDealList({game:offered,disabled:false,act(action) {actions.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
assert.ok(accept); assert.equal(accept.props.disabled,false);
(accept.props.onClick as () => void)();
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:true}]);
const expired = {...offered,turn:3};
assert.match(markup(expired), /travel term’s turn has passed/);
const expiredTree = PublicDealList({game:expired,disabled:false,act() {}});
assert.equal(elements(expiredTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
assert.equal(elements(expiredTree).find(node => node.props.children === 'Decline deal')?.props.disabled,false);
});
void test('declined and withdrawn travel offers do not claim to be awaiting acceptance', () => {
for (const status of ['declined', 'withdrawn'] as const) {
const closed = {...base, deals:[{...deal, status}], dealTravelStatus:{}} as unknown as GameView;
const html = markup(closed);
assert.match(html, /The term did not become binding/);
assert.match(html, /Basil \(Harkonnen\) would make a reserve shipment/);
assert.doesNotMatch(html, /Basil \(Harkonnen\) agrees to/);
assert.doesNotMatch(html, /The term becomes binding on final acceptance/);
}
});
void test('the recipient guide distinguishes a void deal from unanimous release', () => {
// Staged public projections isolate presentation; native creation and lifecycle
// are exercised by the separate runtime integration cases.
const promise = {event:'travel-event', sourceDeal:'deal', player:'b', asker:'a', turn:2,
mixed:{kind:'action', action:{kind:'ship', territory:'carthag', minimum:3}},
answer:true, fulfilled:false, released:true};
for (const [status, expected, absent] of [
['void', /Void: this agreement is no longer possible/, /Released: this whole commitment/],
['released', /Released: this whole commitment is no longer binding/, /Void: this agreement/],
] as const) {
const game = {...base, allLeaders:[], combatLocations:[{id:'carthag',name:'Carthag'}],
travelPromises:[promise], dealTravelStatus:{deal:status}} as unknown as GameView;
const html = renderToStaticMarkup(createElement(TravelPromises,{game, busy:false, act() {}}));
assert.match(html, /Public deal: agreed Yes/);
assert.match(html, expected);
assert.doesNotMatch(html, absent);
assert.match(html, /after the answer or deal acceptance/);
assert.equal(TravelPromises({game:{...game, me:'a'}, busy:false, act() {}}), null);
}
});
void test('the real offer form explains single-recipient eligibility before a travel term is selected', () => {
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
assert.match(html, /Include a current-turn travel term/);
assert.match(html, /Select exactly one human recipient to add a travel term/);
assert.match(html, /cannot automatically enforce free-text promises/);
});
+292
View File
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthTravel as act, createTruthTravelFixture as fixture,
advanceTruthTravel as advance, settleTruthTravel as settle, truthTravelInventory as inventory,
truthTravelPlayer as player } from './fixture-truth-travel';
import { bindTruthBribe, createTruthBribeFixture } from './fixture-truth-bribe';
import { createTruthMixedBribeFixture, declareTruthMixedBribe } from './fixture-truth-mixed-bribe';
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, 'Rejected actions do not modify the table.');
}
function offer(game: Game, proposer: string, target: string, travel: Term) {
const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Agreed travel this turn.', travel });
const deal = next.deals?.at(-1); assert.ok(deal);
return { game: next, id: deal.id };
}
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];
}
function receipt(game: Game, id: string) {
const found = game.travelPromises?.find(row => row.sourceDeal === id);
assert.ok(found, 'Final consent creates a source-linked native travel receipt.');
return found;
}
/** Original Basic base33 setup and real first-turn phase-five prefix from the
* native TruthTravel fixture. Only its unopened deal/Spice order is selected;
* these tests offer and accept the agreement through real player actions. */
void test('accepted shipment Yes preserves the real shipment, forbids divergence and settles once', () => {
const f = fixture(), before = f.game;
const term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const offered = offer(before, f.asker, f.target, term);
assert.equal(offered.game.travelPromises?.length ?? 0, 0, 'An offer alone creates no commitment.');
const game = accept(offered.game, f.target, offered.id);
assert.equal(receipt(game, offered.id).player, f.target);
assert.equal(receipt(game, offered.id).asker, f.asker);
for (const viewer of [f.asker, f.target]) assert.equal(status(game, viewer, offered.id), 'pending');
reject(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 });
reject(game, f.target, f.currentEnd);
const next = settle(act(game, f.target, f.shipmentAction));
assert.equal(player(next, f.target).forces['polar_sink:0'], 1);
assert.equal(player(next, f.target).reserves, player(before, f.target).reserves - 1);
assert.equal(status(next, f.asker, offered.id), 'fulfilled');
assert.equal(status(next, f.target, offered.id), 'fulfilled');
assert.equal(next.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
assert.deepEqual(inventory(next), f.originalCards);
});
void test('a negative shipment term forbids its matching physical shipment and settles after closing', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: false });
let game = accept(offered.game, f.target, offered.id);
assert.equal(status(game, f.target, offered.id), 'pending');
reject(game, f.target, f.shipmentAction);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.target, offered.id), 'fulfilled');
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answer of [true, false]) void test(`accepted movement ${answer ? 'Yes' : 'No'} uses the original physical ground action`, () => {
const f = fixture({ window: 'shipped' });
const offered = offer(f.game, f.asker, f.target,
{ kind: 'move', territory: f.movementTerritory, minimum: 1, answer });
let game = accept(offered.game, f.target, offered.id);
assert.equal(receipt(game, offered.id).player, f.target);
assert.equal(status(game, f.target, offered.id), 'pending');
if (answer) {
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.firstMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
} else {
reject(game, f.target, f.firstMove);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(player(game, f.target).forces['imperial_basin:9'] ?? 0, 0);
}
assert.equal(status(game, f.asker, offered.id), 'fulfilled');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('both parties must release a pending obligation and only then may its target close', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'pending');
reject(game, f.target, f.currentEnd);
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'released');
assert.equal(receipt(game, offered.id).released, true);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.asker, offered.id), 'released');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a released deal cannot retain an unfulfilled live travel receipt', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(game, f.target, offered.id), 'released');
assert.equal(receipt(game, offered.id).fulfilled, false);
assert.equal(receipt(game, offered.id).released, true);
const corrupted = structuredClone(game);
receipt(corrupted, offered.id).released = false;
const before = structuredClone(corrupted);
assert.throws(() => viewGame(corrupted, f.target));
assert.deepEqual(corrupted, before);
reject(corrupted, f.target, f.currentEnd);
});
void test('travel terms reject payment combinations, multiparty packets and malformed claims', () => {
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const basic = { type: 'dealOffer', target: f.target, terms: 'Agreed travel.', travel: term } satisfies Action;
reject(f.game, f.asker, { ...basic, payment: { kind: 'deferredBribe', payer: f.target,
recipient: f.asker, amount: 1, dueTurn: f.game.turn } });
reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel: term });
reject(f.game, f.asker, { ...basic, travel: { ...term, minimum: 0 } });
reject(f.game, f.asker, { ...basic, travel: { ...term, answer: 'yes' } });
reject(f.game, f.asker, { ...basic, travel: { ...term, territory: 'not_a_territory' } });
});
void test('two compatible accepted agreements share one real qualifying shipment', () => {
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const first = offer(f.game, f.asker, f.target, term);
let game = accept(first.game, f.target, first.id);
const second = offer(game, f.asker, f.target, term);
game = accept(second.game, f.target, second.id);
assert.notEqual(receipt(game, first.id).event, receipt(game, second.id).event);
assert.equal(status(game, f.target, first.id), 'pending');
assert.equal(status(game, f.target, second.id), 'pending');
const next = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(next, f.target, first.id), 'fulfilled');
assert.equal(status(next, f.target, second.id), 'fulfilled');
assert.equal(player(next, f.target).reserves, player(f.game, f.target).reserves - 1);
assert.deepEqual(inventory(next), f.originalCards);
});
void test('final consent to a conflicting obligation rolls back without disturbing the original', () => {
const f = fixture(), yes: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
const first = offer(f.game, f.asker, f.target, yes);
let game = accept(first.game, f.target, first.id);
const second = offer(game, f.asker, f.target, { ...yes, answer: false });
game = second.game;
const before = structuredClone(game);
reject(game, f.target, { type: 'dealRespond', deal: second.id, accept: true });
assert.deepEqual(game, before);
assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered');
assert.equal(game.travelPromises!.filter(row => row.sourceDeal === second.id).length, 0);
assert.equal(status(game, f.target, first.id), 'pending');
const completed = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(completed, f.target, first.id), 'fulfilled');
});
void test('a pending travel term prevents final consent to a separate current-turn deferred bribe', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'A separate payment this turn.',
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 1, dueTurn: game.turn } });
const payment = game.deals!.at(-1)!;
assert.notEqual(payment.id, offered.id);
assert.equal(payment.status, 'offered');
const before = structuredClone(game);
reject(game, f.target, { type: 'dealRespond', deal: payment.id, accept: true });
assert.deepEqual(game, before);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.equal(game.deals!.find(deal => deal.id === payment.id)!.status, 'offered');
assert.equal(status(game, f.target, offered.id), 'pending');
assert.equal(receipt(game, offered.id).released, false);
const completed = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(completed, f.target, offered.id), 'fulfilled');
});
void test('final travel consent rejects a target with a live native Truthtrance bribe promise', () => {
const f = createTruthBribeFixture();
const term: Term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true };
// Control: the same original timing supports the travel deal by itself.
const control = offer(f.game, f.asker, f.target, term);
assert.equal(status(accept(control.game, f.target, control.id), f.target, control.id), 'pending');
const bound = bindTruthBribe(f, 'yes').game;
assert.ok(bound.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
const offered = offer(bound, f.asker, f.target, term);
const before = structuredClone(offered.game);
reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.deepEqual(offered.game, before);
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);
assert.ok(offered.game.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
});
void test('a pending public travel deal leaves simple and mixed bribe questions askable with only Unknown', () => {
const f = createTruthMixedBribeFixture();
const offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
const asking = declareTruthMixedBribe(f, accepted).game;
const simple: Action = { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
turn: asking.turn, recipient: f.asker, minimum: 3 } };
for (const [question, kind] of [[simple, 'simple'], [f.questionAction, 'mixed']] as const) {
const asked = act(asking, f.asker, question), target = viewGame(asked, f.target);
assert.deepEqual(kind === 'simple' ? target.truthBribeAnswers : target.truthMixedBribeAnswers, ['unknown']);
reject(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(asked, f.target, { type: 'truthAnswer', answer: 'no' });
const answered = act(asked, f.target, { type: 'truthAnswer', answer: 'unknown' });
assert.equal(answered.truthHistory!.at(-1)!.answer, 'unknown');
assert.equal(answered.bribePromises?.length ?? 0, 0);
assert.equal(answered.bribeExpressionPromises?.length ?? 0, 0);
assert.equal(answered.mixedBribeQuestions?.length ?? 0, 0);
assert.equal(status(answered, f.target, offered.id), 'pending');
assert.equal(receipt(answered, offered.id).released, false);
}
});
void test('an unanswered travel offer cannot acquire an obligation after its original turn', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const nextTurn = advance(offered.game, game => game.turn > f.game.turn);
assert.ok(nextTurn.turn > f.game.turn);
reject(nextTurn, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
assert.equal(nextTurn.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
assert.equal(nextTurn.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.deepEqual(inventory(nextTurn), f.originalCards);
});
void test('voluntary spice spending and premature ground movement cannot eliminate promised completion', () => {
const f = fixture({ hajr: false }), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
let game = accept(offered.game, f.target, offered.id);
reject(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 });
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(game, f.target, offered.id), 'fulfilled');
assert.deepEqual(inventory(game), f.originalCards);
const moved = fixture({ window: 'shipped', hajr: false });
const ground = offer(moved.game, moved.asker, moved.target,
{ kind: 'move', territory: moved.movementTerritory, minimum: 1, answer: true });
let awaiting = accept(ground.game, moved.target, ground.id);
reject(awaiting, moved.target, moved.secondMove);
reject(awaiting, moved.target, moved.currentEnd);
awaiting = settle(act(awaiting, moved.target, moved.firstMove));
assert.equal(status(awaiting, moved.target, ground.id), 'fulfilled');
});
void test('a staged external loss makes an impossible agreement void without retroactive consent', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
// Isolate the aftermath of a foreign resource loss; this is not claimed as
// an actual opponent action in the two-player native chronology.
const externallyChanged = structuredClone(accepted);
player(externallyChanged, f.target).spice = 0;
const reconciled = normalizeAutomaticGame(externallyChanged);
assert.equal(status(reconciled, f.target, offered.id), 'void');
assert.equal(receipt(reconciled, offered.id).released, true);
assert.equal(reconciled.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.deepEqual(inventory(reconciled), f.originalCards);
let released = act(reconciled, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(status(released, f.target, offered.id), 'void');
released = act(released, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
assert.equal(released.deals!.find(deal => deal.id === offered.id)!.status, 'released');
assert.equal(status(released, f.asker, offered.id), 'void');
assert.equal(status(released, f.target, offered.id), 'void');
assert.notEqual(receipt(released, offered.id).releasedByDeal, true);
});
void test('all four autopilot profiles follow an accepted shipment commitment through legal actions', () => {
const f = fixture(), offered = offer(f.game, f.asker, f.target,
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
const accepted = accept(offered.game, f.target, offered.id);
for (const difficulty of DIFFICULTIES) {
let game = act(accepted, f.target, { type: 'setAutopilot', difficulty });
for (let step = 0; step < 8 && status(game, f.target, offered.id) === 'pending'; step++) {
const actions = botActions(viewGame(game, f.target));
assert.ok(actions.length, `${difficulty} needs a legal continuation`);
game = settle(act(game, f.target, actions[0]));
}
assert.equal(status(game, f.target, offered.id), 'fulfilled', difficulty);
assert.deepEqual(inventory(game), f.originalCards);
}
});
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
type PublicDeal, type PublicDealSeat,
} from '../game/public-deals';
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true } as const;
const fresh = () => createPublicDeal(seats, 'a', 'b', 'Travel this turn.', 3, 'offer');
void test('travel terms accept only exact bounded native actions and an explicit answer', () => {
for (const kind of ['ship', 'move'] as const) for (const answer of [true, false]) {
for (const minimum of [1, 20]) {
const input = { kind, territory: 'polar_sink', minimum, answer };
assert.deepEqual(parsePublicDealTravel(input), input);
assert.notEqual(parsePublicDealTravel(input), input);
}
}
for (const invalid of [null, undefined, [], {},
{ ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' },
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
{ ...term, answer: 'yes' }, { ...term, answer: undefined },
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: 'a' },
]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError);
});
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
const source = fresh();
const input = { kind: 'ship' as const, territory: 'polar_sink', minimum: 1, answer: true };
const offered = setPublicDealTravel(source, input, seats);
input.minimum = 2;
assert.equal(source.travel, undefined);
assert.deepEqual(offered.travel, term);
assert.equal(offered.turn, 3);
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
const active = respondPublicDeal(offered, seats, 'b', true);
assert.equal(active.status, 'active');
assert.deepEqual(active.travel, term);
const first = releasePublicDeal(active, seats, 'a', true);
assert.equal(first.status, 'active');
const released = releasePublicDeal(first, seats, 'b', true);
assert.equal(released.status, 'released');
assert.deepEqual(released.travel, term);
const roundtrip = JSON.parse(JSON.stringify([offered, active, released])) as PublicDeal[];
// Ledger IDs must be unique; each lifecycle snapshot is validated separately.
for (const deal of roundtrip) {
const before = JSON.stringify(deal);
validatePublicDeals([deal], seats);
assert.equal(JSON.stringify(deal), before);
assert.deepEqual(deal.travel, term);
assert.equal(Object.hasOwn(deal, 'travelStatus'), false);
}
assert.deepEqual(respondPublicDeal(offered, seats, 'b', false).travel, term);
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
});
void test('travel is incompatible with multi-party, payment, 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])
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
for (const invalid of [
{ ...multi, travel: term }, { ...payment, travel: term },
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
]) 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');
const before = JSON.stringify([legacy, multi]);
validatePublicDeals([legacy, multi], seats);
assert.equal(JSON.stringify([legacy, multi]), before);
assert.equal(Object.hasOwn(legacy, 'travel'), false);
assert.equal(Object.hasOwn(multi, 'travel'), false);
});