Support scheduled travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m8s
Verify and publish checkpoint container / container (push) Successful in 11m8s
This commit is contained in:
1 parent
e1e02fc1a1
commit
1e81ce8ad8
13 files changed
+767
-56
No files matched your search
+38
-16
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor } from '@/game/public-deals';
|
||||
import { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor, publicDealTravelDueTurn } from '@/game/public-deals';
|
||||
import { areCoalitionAllies } from '@/game/coalition-alliance';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,8 +12,8 @@ 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; actor?: string };
|
||||
type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released';
|
||||
type DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean; actor?: string; dueTurn?: number };
|
||||
type DealTravelStatus = 'scheduled' | 'pending' | 'fulfilled' | 'void' | 'released';
|
||||
type TravelDeal = Deal & { travel?: DealTravel };
|
||||
type TravelProjection = GameView & { dealTravelStatus?: Record<string, DealTravelStatus> };
|
||||
type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
||||
@@ -66,16 +66,22 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
|
||||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 ||
|
||||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null;
|
||||
const groupTravel = !!travel && targets.length > 1;
|
||||
const travelKeys = travel ? Object.keys(travel).filter(key => key !== 'dueTurn').sort().join(',') : '';
|
||||
const travelDeadline = travel && Object.hasOwn(travel, 'dueTurn') ? travel.dueTurn : undefined;
|
||||
if (travel && (!proposer || targets.includes(proposer) ||
|
||||
(groupTravel ? !!payment || Object.keys(travel).sort().join(',') !== 'actor,answer,kind,minimum,territory' ||
|
||||
(groupTravel ? !!payment || travelKeys !== 'actor,answer,kind,minimum,territory' ||
|
||||
typeof travel.actor !== 'string' || !targets.includes(travel.actor) :
|
||||
Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' ||
|
||||
travelKeys !== 'answer,kind,minimum,territory' ||
|
||||
!!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0])) ||
|
||||
(Object.hasOwn(travel, 'dueTurn') && (!Number.isSafeInteger(travelDeadline) || (travelDeadline as number) < 1 ||
|
||||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || (travelDeadline as number) < currentTurn)))) ||
|
||||
!['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) ||
|
||||
!Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 ||
|
||||
typeof travel.answer !== 'boolean')) return null;
|
||||
const offeredPayment = payment ? { payment } : {};
|
||||
const offeredTravel = travel ? { travel } : {};
|
||||
const normalizedTravel = travel ? {...travel} : undefined;
|
||||
if (normalizedTravel && travelDeadline === currentTurn) delete normalizedTravel.dueTurn;
|
||||
const offeredTravel = normalizedTravel ? { travel: normalizedTravel } : {};
|
||||
return targets.length === 1
|
||||
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel }
|
||||
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment, ...offeredTravel };
|
||||
@@ -109,7 +115,8 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
const travel = (deal as TravelDeal).travel;
|
||||
const travelActor = travel ? publicDealTravelActor(deal) : undefined;
|
||||
const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id];
|
||||
const travelExpired = !!travel && deal.turn !== game.turn;
|
||||
const travelTurn = travel ? publicDealTravelDueTurn(deal) : undefined;
|
||||
const travelExpired = travelTurn !== undefined && game.turn > travelTurn;
|
||||
const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name;
|
||||
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
|
||||
const travelActorWaiting = !!travel && participants.length > 2 && game.me === travelActor &&
|
||||
@@ -123,9 +130,10 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
|
||||
<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 === 'immediateBribe' && travel && <p>The recipient cannot use this payment to fund the current travel: their share is collected at the next Mentat opening.</p>}
|
||||
{payment?.kind === 'immediateBribe' && travel && <p>The recipient cannot spend this payment before its next Mentat collection; after collection it can fund travel in a later turn.</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(travelActor!)} {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>}
|
||||
{travel && <p><strong>{travelTurn === deal.turn ? 'Current-turn travel term:' : 'Travel term:'}</strong> {name(travelActor!)} {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 {travelTurn}. {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>}
|
||||
{travel && travelTurn !== undefined && travelTurn > deal.turn && <p>Before turn {travelTurn}, this term reserves no forces or spice. Its status and legal options update during that turn.</p>}
|
||||
{travel && participants.length > 2 && deal.status === 'offered' && <p>{name(travelActor!)} must accept last, after the other recipients. Anyone invited may decline at any time.</p>}
|
||||
{deal.status === 'offered' && <p>
|
||||
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||
@@ -176,10 +184,15 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||
const [paymentAmount, setPaymentAmount] = useState('1');
|
||||
const [deadlineDraft, setDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn }));
|
||||
const [travelDeadlineDraft, setTravelDeadlineDraft] = useState(() => ({ value: String(game.turn), turn: game.turn }));
|
||||
const previousDeadline = Number(deadlineDraft.value);
|
||||
const paymentDueTurn = deadlineDraft.turn !== game.turn && deadlineDraft.value.trim() !== '' &&
|
||||
Number.isSafeInteger(previousDeadline) && previousDeadline < game.turn
|
||||
? String(game.turn) : deadlineDraft.value;
|
||||
const previousTravelDeadline = Number(travelDeadlineDraft.value);
|
||||
const travelDueTurn = travelDeadlineDraft.turn !== game.turn && travelDeadlineDraft.value.trim() !== '' &&
|
||||
Number.isSafeInteger(previousTravelDeadline) && previousTravelDeadline < game.turn
|
||||
? String(game.turn) : travelDeadlineDraft.value;
|
||||
const requests = publicDealRequests(game);
|
||||
const needsResponse = requests.length > 0;
|
||||
const paymentDue = requests.some(request => request[0] === 'payment');
|
||||
@@ -207,12 +220,16 @@ 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 travelTurn = Number(travelDueTurn);
|
||||
const travelDueValid = travelDueTurn.trim() !== '' && Number.isSafeInteger(travelTurn) && travelTurn >= game.turn;
|
||||
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
|
||||
minimum:Number(travelMinimum), answer:travelAnswer,
|
||||
...(selected.length > 1 ? {actor:travelActor} : {})} : undefined;
|
||||
...(selected.length > 1 ? {actor:travelActor} : {}),
|
||||
...(travelTurn > game.turn ? {dueTurn:travelTurn} : {})} : undefined;
|
||||
const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' &&
|
||||
payment.payer === me?.id && selected.length === 1 && payment.recipient === selected[0];
|
||||
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null;
|
||||
const offer = legalSelection && paymentValid && (!includeTravel || travelDueValid)
|
||||
? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null;
|
||||
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
|
||||
const mayOffer = game.status === 'playing' && me && !me.bot;
|
||||
return <details ref={panel} className="notice public-deals-panel">
|
||||
@@ -285,13 +302,13 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={includeTravel} disabled={disabled || !includeTravel && selected.length === 0}
|
||||
onChange={event => setIncludeTravel(event.target.checked)} />
|
||||
Include a current-turn travel term
|
||||
Include a travel term
|
||||
</label>
|
||||
{!includeTravel && selected.length === 0 && <p className="fine">Select at least one human recipient to add a travel term.</p>}
|
||||
{includeTravel && <fieldset>
|
||||
<legend>Current-turn travel term</legend>
|
||||
<p className="fine">This term concerns one physical action during turn {game.turn}. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.</p>
|
||||
<p className="fine">A two-party travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening, so it cannot fund this turn’s shipment or movement. Group travel terms cannot include a payment.</p>
|
||||
<legend>Travel term</legend>
|
||||
<p className="fine">This term concerns one physical action during the selected turn. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.</p>
|
||||
<p className="fine">A two-party travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening and can use it after collection. Group travel terms cannot include a payment.</p>
|
||||
{!combinedPaymentValid && <p role="alert">For two-party travel, choose an immediate payment from the proposer to the selected recipient. Group travel cannot include payment.</p>}
|
||||
{selected.length > 1 && <label>Committed traveler
|
||||
<select value={selected.includes(travelActor) ? travelActor : ''} disabled={disabled}
|
||||
@@ -300,7 +317,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
{selectedPlayers.map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<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>
|
||||
<p className="fine">This initial preview supports Basic games with classic factions and no expansions or optional modules. A current-turn term needs a quiet native window and a legal completion on final acceptance. A later-turn term reserves no forces or spice now. If the game finds no legal completion when the travel turn closes, it provisionally marks that term void.</p>
|
||||
<label>Travel action
|
||||
<select value={travelKind} disabled={disabled} onChange={event => setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
|
||||
<option value="ship">Reserve shipment</option>
|
||||
@@ -323,6 +340,11 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<input type="number" inputMode="numeric" min={1} max={20} step={1} value={travelMinimum} disabled={disabled}
|
||||
onChange={event => setTravelMinimum(event.target.value)} />
|
||||
</label>
|
||||
<label>Travel during turn
|
||||
<input type="number" inputMode="numeric" min={game.turn} step={1} value={travelDueTurn} disabled={disabled}
|
||||
required aria-invalid={!travelDueValid} onChange={event => setTravelDeadlineDraft({ value: event.target.value, turn: game.turn })} />
|
||||
</label>
|
||||
{!travelDueValid && <p role="alert">Choose a whole travel turn no earlier than turn {game.turn}.</p>}
|
||||
</fieldset>}
|
||||
<label>Public deal terms
|
||||
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
|
||||
|
||||
@@ -17,6 +17,21 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Later-turn travel agreements — 29 October. Prototyped / Partial.** In
|
||||
supported Basic classic games, two to six human parties can schedule a travel
|
||||
term for a later turn without reserving present resources; group consent,
|
||||
source-linked receipts, release and native physical actions remain in use.
|
||||
Actual journeys cover turn rollover, Charity funding, movement, group consent,
|
||||
whole-turn Truthtrance history, mutually released terms and all four legal bot
|
||||
profiles. Conflicting public travel terms and the same traveler's pending
|
||||
deferred payment due in that turn cannot both be newly accepted. A missing
|
||||
early planner witness stays pending; a missing witness at the actual travel
|
||||
close provisionally voids the term. The 33-file related batch passes 221 cases;
|
||||
the five changed-path files pass 44 cases. Type checking, lint and production
|
||||
build pass. Wider funding, optional modules, deployed acceptance and the
|
||||
bounded search's completeness remain open.
|
||||
[Scope](PUBLIC_DEALS.md#structured-later-turn-travel--29-october-2026).
|
||||
|
||||
**Group travel agreements — 29 October. Prototyped / Partial.** A Basic
|
||||
classic offer can name one invited traveler among three through six human
|
||||
parties. Other invitees accept first; the traveler receives the offer at once
|
||||
|
||||
+25
-1
@@ -33,7 +33,7 @@ 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,
|
||||
planners can evaluate those obligations together. Optional modules, Advanced,
|
||||
multi-party payments paired with travel, arbitrary prose and other action families remain
|
||||
unfinished. An unaccepted offer from an earlier turn cannot be accepted.
|
||||
|
||||
@@ -43,6 +43,30 @@ term and shared-event policy are provisional online representations, not
|
||||
publisher restrictions on agreements. Complete rules and deployed acceptance
|
||||
remain open.
|
||||
|
||||
## Structured later-turn travel — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** The same Basic classic agreement may name a later
|
||||
turn. All parties may accept until that turn; a group traveler still accepts
|
||||
last. Final consent records a scheduled native receipt without reserving
|
||||
today's forces or spice or predicting future income. On the due turn the term
|
||||
becomes pending and the existing physical shipment/movement controls and owner
|
||||
guide apply. An immediate two-party bribe still pays at final consent and can
|
||||
be collected and spent before later travel. All parties may release a scheduled
|
||||
term; a game ending before the due turn voids it.
|
||||
|
||||
The accepted-order guard rejects voluntarily losing a witnessed completion.
|
||||
The bounded search does not assume an unfunded journey is impossible in an
|
||||
earlier phase; income or other events may still make it possible. When the
|
||||
traveler actually closes their movement turn, an unwitnessed scheduled term is
|
||||
provisionally voided so the game can continue. This is a bounded online policy,
|
||||
not proof that every theoretical route or third-party contribution was searched.
|
||||
An objectively contradictory future term is rejected on acceptance using only
|
||||
the public action, destination and thresholds; unrelated future resources are
|
||||
not inspected. The same player cannot newly combine a scheduled travel term
|
||||
and an unpaid deferred bribe due in that same turn; their joint funding still
|
||||
needs a shared planner. Whole-turn Truthtrance, other modules and broader external-funding
|
||||
interactions still require integration evidence.
|
||||
|
||||
## Staged group alliances — 28 October 2026
|
||||
|
||||
The unexposed Basic classic group prototype treats each member as allied to
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Later-turn travel deals — 29 October 2026
|
||||
|
||||
The publisher's base rulebook, printed p. 12, permits nonallied deals for
|
||||
future actions. The later-turn structured travel prototype does not reserve
|
||||
present resources or predict future funding. A known completion is protected;
|
||||
if the bounded search has no completion when the travel turn is actually
|
||||
closed, the term is provisionally voided. This is an online policy, not a
|
||||
publisher ruling or proof that every possible future contribution was searched.
|
||||
[Scope](PUBLIC_DEALS.md#structured-later-turn-travel--29-october-2026).
|
||||
|
||||
## Group travel deals — 29 October 2026
|
||||
|
||||
The publisher's base rulebook, printed p. 12, permits nonallied public deals
|
||||
|
||||
+157
-32
@@ -8,7 +8,7 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
|
||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, publicDealTravelDueTurn, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
import { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
@@ -5968,7 +5968,7 @@ function publicDealIntegrity(g: Game): void {
|
||||
const deal = g.deals?.find(entry => entry.id === row.sourceDeal);
|
||||
const term = deal?.travel;
|
||||
requireRule(deal && term && ['active', 'released'].includes(deal.status) &&
|
||||
row.player === publicDealTravelActor(deal) && row.asker === deal.from && row.turn === deal.turn &&
|
||||
row.player === publicDealTravelActor(deal) && row.asker === deal.from && row.turn === publicDealTravelDueTurn(deal) &&
|
||||
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 &&
|
||||
@@ -18125,7 +18125,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
// The detached accepted declaration satisfies its own Bid Yes before
|
||||
// another promise checks the remaining purse; it is not a second bid.
|
||||
trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number);
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase);
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase, g);
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
reconcileRevivalPromises(trial, { actor: p.id, action });
|
||||
@@ -22333,7 +22333,7 @@ function guardThumperKnownEncounter(g: Game, actor: string) {
|
||||
const trial = structuredClone(g);
|
||||
projectAfterKnownThumper(trial);
|
||||
const action: Action = { type: 'card' };
|
||||
reconcileTravelPromises(trial, actor, action, 1);
|
||||
reconcileTravelPromises(trial, actor, action, 1, g);
|
||||
reconcileBidPromises(trial, { actor, action });
|
||||
reconcileRevivalPromises(trial, { actor, action });
|
||||
reconcileBribePromises(trial, { actor, action });
|
||||
@@ -22713,7 +22713,7 @@ function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
|
||||
if (pass && trial.turnActionPromises) trial.turnActionPromises = ownTurnActionWindowClosure(trial, actor, ['amal']);
|
||||
projectTurnActionOpeningEnd(trial);
|
||||
const action: Action = { type: 'card' };
|
||||
reconcileTravelPromises(trial, actor, action, g.phase);
|
||||
reconcileTravelPromises(trial, actor, action, g.phase, g);
|
||||
reconcileBidPromises(trial, { actor, action });
|
||||
reconcileRevivalPromises(trial, { actor, action });
|
||||
reconcileBribePromises(trial, { actor, action });
|
||||
@@ -23174,7 +23174,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
|
||||
g.turnActionPromises.push(promise);
|
||||
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
|
||||
}
|
||||
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase, before?: Game) {
|
||||
const suspendedKull = g.pendingTreacheryDiscard?.continuation.kind === 'kullSuccessDiscard' ||
|
||||
g.pendingTreacheryDiscard?.continuation.kind === 'kullCounterDiscard';
|
||||
if (g.pendingKull || suspendedKull) return;
|
||||
@@ -23217,7 +23217,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
||||
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
|
||||
}
|
||||
for (const promise of g.travelPromises ?? []) if (!promise.fulfilled && !promise.released &&
|
||||
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
|
||||
(promise.turn < g.turn || promise.turn === g.turn && g.phase > 5 || g.status !== 'playing')) promise.released = true;
|
||||
for (const promise of [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])])
|
||||
if (!promise.fulfilled && !promise.released && (promise.turn !== g.turn || !bribePromiseTurn(g))) {
|
||||
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||
@@ -23246,15 +23246,42 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
||||
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
|
||||
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
|
||||
turnActionPromises: [] as TurnActionPromise[] };
|
||||
const priorTrial = before?.turn === g.turn ? { ...before,
|
||||
shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
|
||||
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
|
||||
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
|
||||
turnActionPromises: [] as TurnActionPromise[] } : undefined;
|
||||
for (const entry of g.travelPromiseOrder!) {
|
||||
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
|
||||
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
|
||||
turnActions: 'turnActionPromises' } as const)[entry.kind];
|
||||
const promise = g[key]?.[entry.index];
|
||||
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
|
||||
if (!promise || promise.player !== p.id || promise.turn !== g.turn) continue;
|
||||
const previous = before?.[key]?.[entry.index];
|
||||
const hasPrevious = !!priorTrial && !!previous && previous.player === p.id && previous.turn === g.turn &&
|
||||
!previous.fulfilled && !previous.released;
|
||||
if (promise.fulfilled || promise.released) {
|
||||
if (hasPrevious) (priorTrial[key] as (typeof previous)[]).push(previous);
|
||||
continue;
|
||||
}
|
||||
// Each key and row came from the same discriminated accepted-order entry.
|
||||
(trial[key] as (typeof promise)[]).push(promise);
|
||||
if (hasPrevious) (priorTrial[key] as (typeof previous)[]).push(previous);
|
||||
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise) && !voluntary && g.phase < 5) {
|
||||
trial.travelPromises.pop();
|
||||
if (hasPrevious) priorTrial!.travelPromises.pop();
|
||||
continue;
|
||||
}
|
||||
if (turnActionCompletion(trial, p.id)) continue;
|
||||
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise)) {
|
||||
// A missing early witness is not proof that later income or another
|
||||
// player's action cannot make the agreed journey possible.
|
||||
const witnessed = voluntary && hasPrevious && turnActionCompletion(priorTrial!, p.id);
|
||||
requireRule(!witnessed, 'Your action would remove a known legal completion of an accepted public travel agreement.');
|
||||
trial.travelPromises.pop();
|
||||
if (hasPrevious) priorTrial!.travelPromises.pop();
|
||||
continue;
|
||||
}
|
||||
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted whole turn-action commitment.');
|
||||
promise.released = true;
|
||||
log(g, 'sourceDeal' in promise && promise.sourceDeal
|
||||
@@ -23423,6 +23450,11 @@ function travelRule<T>(operation: () => T): T {
|
||||
function travelTargetAvailable(g: Game, actor: string): boolean {
|
||||
return movementPromiseTargetAvailable(g, actor);
|
||||
}
|
||||
/** A later-turn deal has no resource witness when it is first accepted. */
|
||||
function isFutureDealTravel(g: Game, promise: TravelPromise): boolean {
|
||||
const deal = promise.sourceDeal ? g.deals?.find(entry => entry.id === promise.sourceDeal) : undefined;
|
||||
return !!deal?.travel && publicDealTravelDueTurn(deal) > deal.turn;
|
||||
}
|
||||
function appendTravelOrder(g: Game, kind: NonNullable<Game['travelPromiseOrder']>[number]['kind'], index: number) {
|
||||
g.travelPromiseOrder?.push({ kind, index });
|
||||
}
|
||||
@@ -23513,29 +23545,51 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
|
||||
* travel constraint, not a Truthtrance question or a fabricated card play. */
|
||||
function bindPublicDealTravel(g: Game, deal: PublicDeal) {
|
||||
const term = deal.travel, player = getPlayer(g, publicDealTravelActor(deal));
|
||||
requireRule(term && deal.turn === g.turn && !g.advanced && g.expansions.length === 0 &&
|
||||
nativePromiseScopeSupported(g) && travelTargetAvailable(g, player.id) && !g.truthtrance &&
|
||||
!hasBribePromises(g, player.id) &&
|
||||
!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 due = publicDealTravelDueTurn(deal);
|
||||
requireRule(term && due >= g.turn && !g.advanced && g.expansions.length === 0 && nativePromiseScopeSupported(g),
|
||||
'This travel agreement needs a supported Basic table and an unexpired 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');
|
||||
const claim: TravelClaim = { turn: due, mixed: { kind: 'action', action } };
|
||||
if (due > g.turn) {
|
||||
requireRule(!g.deals?.some(other => other.payment?.kind === 'deferredBribe' &&
|
||||
other.paymentStatus === 'pending' && other.payment.payer === player.id && other.payment.dueTurn === due),
|
||||
'This traveler already has a payment due in the same turn.');
|
||||
// These are public, physical contradictions, not a forecast of future
|
||||
// funds: there is one shipment, and a required action cannot also be
|
||||
// forbidden at an equal or lower threshold in that same territory.
|
||||
const conflicts = liveTravelPromises(g.travelPromises ?? [], player.id, due).some(row => {
|
||||
if (!row.sourceDeal || row.mixed.kind !== 'action') return false;
|
||||
const prior = row.mixed.action;
|
||||
if (prior.kind !== action.kind) return false;
|
||||
if (action.kind === 'ship' && row.answer && term.answer && prior.territory !== action.territory) return true;
|
||||
if (prior.territory !== action.territory || row.answer === term.answer) return false;
|
||||
const required = row.answer ? prior.minimum : action.minimum;
|
||||
const forbidden = row.answer ? action.minimum : prior.minimum;
|
||||
return required >= forbidden;
|
||||
});
|
||||
requireRule(!conflicts, 'This future travel term conflicts with an already accepted public travel agreement.');
|
||||
}
|
||||
if (due === g.turn) {
|
||||
requireRule(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 due turn.');
|
||||
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 },
|
||||
shipment: { pending: due > g.turn || 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; }
|
||||
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase, before?: Game) {
|
||||
if (g.turnActionPromises) { reconcileTurnActionPromises(g, actor, action, sourcePhase, before); return; }
|
||||
if (!g.travelPromises?.length) return;
|
||||
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
|
||||
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
|
||||
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
|
||||
(promise.turn < g.turn || promise.turn === g.turn && g.phase > 5 || g.status !== 'playing')) promise.released = true;
|
||||
if (g.phase < 0 || g.phase > 5) return;
|
||||
initializeTravelOrder(g);
|
||||
for (const p of g.players) {
|
||||
@@ -23558,18 +23612,54 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc
|
||||
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
|
||||
g.richeseAuction && !g.richeseAuction.outcome))) continue;
|
||||
const shipments: ShipmentPromise[] = [], movements: MovementPromise[] = [], expressions: MovementExpressionPromise[] = [], travel: TravelPromise[] = [];
|
||||
const priorShipments: ShipmentPromise[] = [], priorMovements: MovementPromise[] = [],
|
||||
priorExpressions: MovementExpressionPromise[] = [], priorTravel: TravelPromise[] = [];
|
||||
for (const entry of g.travelPromiseOrder!) {
|
||||
const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises
|
||||
: entry.kind === 'movementExpression' ? g.movementExpressionPromises : g.travelPromises;
|
||||
const promise = ledger?.[entry.index];
|
||||
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
|
||||
if (!promise || promise.player !== p.id || promise.turn !== g.turn) continue;
|
||||
const priorLedger = entry.kind === 'shipment' ? before?.shipmentPromises : entry.kind === 'movement' ? before?.movementPromises
|
||||
: entry.kind === 'movementExpression' ? before?.movementExpressionPromises : before?.travelPromises;
|
||||
const previous = priorLedger?.[entry.index];
|
||||
const hasPrevious = before?.turn === g.turn && !!previous && previous.player === p.id &&
|
||||
previous.turn === g.turn && !previous.fulfilled && !previous.released;
|
||||
if (promise.fulfilled || promise.released) {
|
||||
if (hasPrevious) {
|
||||
if (entry.kind === 'shipment') priorShipments.push(previous as ShipmentPromise);
|
||||
else if (entry.kind === 'movement') priorMovements.push(previous as MovementPromise);
|
||||
else if (entry.kind === 'movementExpression') priorExpressions.push(previous as MovementExpressionPromise);
|
||||
else priorTravel.push(previous as TravelPromise);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === 'shipment') shipments.push(promise as ShipmentPromise);
|
||||
else if (entry.kind === 'movement') movements.push(promise as MovementPromise);
|
||||
else if (entry.kind === 'movementExpression') expressions.push(promise as MovementExpressionPromise);
|
||||
else travel.push(promise as TravelPromise);
|
||||
if (hasPrevious) {
|
||||
if (entry.kind === 'shipment') priorShipments.push(previous as ShipmentPromise);
|
||||
else if (entry.kind === 'movement') priorMovements.push(previous as MovementPromise);
|
||||
else if (entry.kind === 'movementExpression') priorExpressions.push(previous as MovementExpressionPromise);
|
||||
else priorTravel.push(previous as TravelPromise);
|
||||
}
|
||||
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise) && !voluntary && g.phase < 5) {
|
||||
travel.pop();
|
||||
if (hasPrevious) priorTravel.pop();
|
||||
continue;
|
||||
}
|
||||
const trial = { ...g, shipmentPromises: shipments, movementPromises: movements,
|
||||
movementExpressionPromises: expressions, travelPromises: travel };
|
||||
if (travelCompletion(trial, p.id)) continue;
|
||||
if (entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise)) {
|
||||
const prior = before && { ...before, shipmentPromises: priorShipments, movementPromises: priorMovements,
|
||||
movementExpressionPromises: priorExpressions, travelPromises: priorTravel };
|
||||
const witnessed = voluntary && hasPrevious && prior && travelCompletion(prior, p.id);
|
||||
requireRule(!witnessed, 'Your action would remove a known legal completion of an accepted public travel agreement.');
|
||||
travel.pop();
|
||||
if (hasPrevious) priorTravel.pop();
|
||||
continue;
|
||||
}
|
||||
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted travel commitment.');
|
||||
promise.released = true;
|
||||
log(g, 'sourceDeal' in promise && promise.sourceDeal
|
||||
@@ -23579,20 +23669,20 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc
|
||||
}
|
||||
}
|
||||
function projectedTravelPromises(g: Game, actor: string) {
|
||||
return (g.travelPromises ?? []).filter(promise => promise.player === actor).map(promise => ({
|
||||
return (g.travelPromises ?? []).filter(promise => promise.player === actor && promise.turn <= g.turn).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'> = {};
|
||||
function projectedDealTravelStatus(g: Game): Record<string, 'scheduled' | 'pending' | 'fulfilled' | 'void' | 'released'> {
|
||||
const status: Record<string, 'scheduled' | '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';
|
||||
: receipt.released ? 'void' : receipt.turn > g.turn ? 'scheduled' : 'pending';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -24435,6 +24525,37 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
|
||||
log(g, `${p.name}'s ${quote.normal + quote.elite} new allied-shipment visitors left in ${quote.territory} went to Tanks. This is the provisional Basic no-departure policy, not a publisher ruling.`);
|
||||
}
|
||||
|
||||
/** A later-turn agreement can be accepted before its funding exists. If no
|
||||
* native completion is available when its owner's actual turn is being closed,
|
||||
* release only the unsupported future terms in accepted order. Other existing
|
||||
* obligations keep their normal checks. A planner witness is not a prediction
|
||||
* of future income, so this is deliberately delayed until the actual close. */
|
||||
function releaseUnwitnessedFutureTravelAtClose(g: Game, player: Player) {
|
||||
const due = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)
|
||||
.filter(row => isFutureDealTravel(g, row));
|
||||
if (!due.length || travelCompletion(g, player.id)) return;
|
||||
initializeTravelOrder(g);
|
||||
const trial = { ...g, shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
|
||||
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
|
||||
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
|
||||
turnActionPromises: [] as TurnActionPromise[], travelPromiseOrder: undefined };
|
||||
for (const entry of g.travelPromiseOrder!) {
|
||||
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
|
||||
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
|
||||
turnActions: 'turnActionPromises' } as const)[entry.kind];
|
||||
const promise = g[key]?.[entry.index];
|
||||
if (!promise || promise.player !== player.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
|
||||
const future = entry.kind === 'travel' && isFutureDealTravel(g, promise as TravelPromise);
|
||||
const prior = future && travelCompletion(trial, player.id);
|
||||
(trial[key] as (typeof promise)[]).push(promise);
|
||||
if (future && prior && !travelCompletion(trial, player.id)) {
|
||||
trial.travelPromises.pop();
|
||||
promise.released = true;
|
||||
log(g, `${player.name} has no witnessed legal completion for a due public travel agreement as their travel turn closes; that term is no longer binding.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A played movement card is already retired before queue advancement. */
|
||||
function finishMovementTurn(g: Game, id: string) {
|
||||
const p = getPlayer(g, id);
|
||||
@@ -30202,7 +30323,7 @@ function guardCharityOrderFinish(g: Game, player: string, frame: CharityOrder, a
|
||||
closed.ready.push(player);
|
||||
closed.active = frame.remaining[0] ?? null;
|
||||
const voluntary = { actor: player, action };
|
||||
reconcileTravelPromises(closed, player, action, g.phase);
|
||||
reconcileTravelPromises(closed, player, action, g.phase, g);
|
||||
reconcileRevivalPromises(closed, voluntary);
|
||||
reconcileBribePromises(closed, voluntary);
|
||||
reconcileShipmentPromises(closed, voluntary);
|
||||
@@ -36806,7 +36927,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival)
|
||||
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
|
||||
try {
|
||||
const action = { type: 'ready' };
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase);
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase, g);
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
return true;
|
||||
@@ -42431,8 +42552,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.travel || publicDealTravelDueTurn(current) >= g.turn,
|
||||
'The travel agreement’s due turn has passed; 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));
|
||||
@@ -42454,7 +42575,10 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
}
|
||||
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);
|
||||
const payer = getPlayer(g,next.payment.payer), dueTurn = next.payment.dueTurn;
|
||||
requireRule(!(g.travelPromises ?? []).some(row => row.sourceDeal && row.player === payer.id &&
|
||||
row.turn === dueTurn && !row.fulfilled && !row.released),
|
||||
'The payer already has a public travel commitment in the payment’s due turn.');
|
||||
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.');
|
||||
@@ -42669,7 +42793,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
|
||||
const pledgeVoluntary = initiator === id ? voluntary : undefined;
|
||||
reconcilePledgeCommitments(g, pledgeVoluntary);
|
||||
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
||||
reconcileTravelPromises(g, promiseActor, action, state.phase, state);
|
||||
reconcileBidPromises(g, voluntary);
|
||||
reconcilePhaseBids(g, voluntary);
|
||||
reconcileEarlyRicheseTruth(g, voluntary);
|
||||
@@ -47120,6 +47244,7 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'endMovement') {
|
||||
if (g.phase === 5 && g.active === id) releaseUnwitnessedFutureTravelAtClose(g, p);
|
||||
requireRule(!hasMovementPromises(g, id, true),
|
||||
'Complete your accepted ground-movement promises before ending movement.');
|
||||
finishShipmentPromises(g, p, null);
|
||||
|
||||
+16
-2
@@ -24,6 +24,8 @@ export type PublicDealTravel = {
|
||||
answer: boolean;
|
||||
/** Present only in a multi-party term; the selected recipient accepts last. */
|
||||
actor?: string;
|
||||
/** Absent on existing terms: their original offer turn is the due turn. */
|
||||
dueTurn?: number;
|
||||
};
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
@@ -93,6 +95,10 @@ export function publicDealAcceptVotes(deal: PublicDeal): string[] {
|
||||
export function publicDealTravelActor(deal: PublicDeal): string {
|
||||
return deal.travel?.actor ?? deal.to;
|
||||
}
|
||||
/** Call after ledger validation; older travel records are due on their offer turn. */
|
||||
export function publicDealTravelDueTurn(deal: PublicDeal): number {
|
||||
return deal.travel?.dueTurn ?? deal.turn;
|
||||
}
|
||||
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
|
||||
return new Set(votes).size === votes.length &&
|
||||
votes.every(vote => parties.includes(vote)) &&
|
||||
@@ -123,12 +129,18 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
||||
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
|
||||
const keys = object(value) ? Object.keys(value).sort().join(',') : '';
|
||||
requireDeal(object(value) && (keys === 'answer,kind,minimum,territory' ||
|
||||
keys === 'actor,answer,kind,minimum,territory' && id(value.actor)) &&
|
||||
keys === 'answer,dueTurn,kind,minimum,territory' ||
|
||||
keys === 'actor,answer,kind,minimum,territory' ||
|
||||
keys === 'actor,answer,dueTurn,kind,minimum,territory') &&
|
||||
(!Object.hasOwn(value, 'actor') || id(value.actor)) &&
|
||||
(!Object.hasOwn(value, 'dueTurn') || typeof value.dueTurn === 'number' &&
|
||||
Number.isSafeInteger(value.dueTurn) && value.dueTurn > 0) &&
|
||||
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,
|
||||
...(keys.startsWith('actor,') ? {actor: value.actor as string} : {}) };
|
||||
...(Object.hasOwn(value, 'actor') ? {actor: value.actor as string} : {}),
|
||||
...(Object.hasOwn(value, 'dueTurn') ? {dueTurn: value.dueTurn as number} : {}) };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
||||
throw error;
|
||||
@@ -175,6 +187,8 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||
if (Object.hasOwn(deal, 'travel')) {
|
||||
const travel = parsePublicDealTravel(deal.travel);
|
||||
requireDeal(travel.dueTurn === undefined || travel.dueTurn >= (deal.turn as number),
|
||||
'Choose a travel turn no earlier than the offer turn.');
|
||||
requireDeal(!Object.hasOwn(deal, 'paymentStatus') && (multi
|
||||
? !!travel.actor && travel.actor !== deal.from && parties.includes(travel.actor) && !payment
|
||||
: !Object.hasOwn(travel, 'actor') && (!payment || payment.kind === 'immediateBribe' &&
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
|
||||
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
|
||||
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
|
||||
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one current-turn reserve shipment or ground move of at least a selected number of physical forces to a named territory. In a group offer the other recipients accept first and the named traveler accepts last; the game checks that traveler’s own current ability on final consent. The actual future action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. The current-turn planner and mutual release apply, with unsupported combinations still in development.',
|
||||
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one reserve shipment or ground move of at least a selected number of physical forces to a named territory in the current or a later turn. In a group offer the other recipients accept first and the named traveler accepts last. A current-turn term is checked on acceptance; a later-turn term is scheduled without reserving present forces or spice. The actual action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. A bounded due-turn search protects witnessed completions; when the travel turn closes without a witness, the game provisionally voids that term. Unsupported combinations remain in development.',
|
||||
'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.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { actTruthTravel as act, createTruthTravelFixture as fixture,
|
||||
truthTravelInventory as inventory, truthTravelPlayer as player } from './fixture-truth-travel';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Conflicting consent must leave the source game unchanged.');
|
||||
}
|
||||
function travelOffer(game: Game, proposer: string, traveler: string, dueTurn: number) {
|
||||
const next = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Travel in a future turn.',
|
||||
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn}});
|
||||
const deal = next.deals?.at(-1); assert.ok(deal);
|
||||
return {game:next, id:deal.id};
|
||||
}
|
||||
function paymentOffer(game: Game, proposer: string, target: string, payer: string, recipient: string, dueTurn: number) {
|
||||
const next = act(game, proposer, {type:'dealOffer', target, terms:'Pay a future bribe.',
|
||||
payment:{kind:'deferredBribe', payer, recipient, amount:2, dueTurn}});
|
||||
const deal = next.deals?.at(-1); assert.ok(deal);
|
||||
return {game:next, id:deal.id};
|
||||
}
|
||||
function accept(game: Game, recipient: string, id: string) {
|
||||
return act(game, recipient, {type:'dealRespond', deal:id, accept:true});
|
||||
}
|
||||
function deal(game: Game, id: string) {
|
||||
const found = game.deals?.find(row => row.id === id); assert.ok(found); return found;
|
||||
}
|
||||
|
||||
/** Each case uses the genuine Basic Atreides/Emperor setup and actual native
|
||||
* first-turn pre-shipment window. These are independent two-party offers and
|
||||
* acceptances; no wallet, force, pending debt or travel receipt is staged. */
|
||||
void test('a same-turn deferred debt cannot be accepted after the payer schedules travel', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
|
||||
const future = travelOffer(f.game, f.asker, f.target, dueTurn);
|
||||
let game = accept(future.game, f.target, future.id);
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[future.id], 'scheduled');
|
||||
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, dueTurn);
|
||||
game = payment.game;
|
||||
const purse = player(game, f.target).spice;
|
||||
reject(game, f.target, {type:'dealRespond', deal:payment.id, accept:true});
|
||||
assert.equal(deal(game, future.id).status, 'active');
|
||||
assert.equal(deal(game, payment.id).status, 'offered');
|
||||
assert.equal(game.travelPromises?.filter(row => row.sourceDeal === future.id).length, 1);
|
||||
assert.equal(player(game, f.target).spice, purse);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
});
|
||||
|
||||
void test('a same-turn scheduled journey cannot be accepted after the traveler owes a deferred debt', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
|
||||
const payment = paymentOffer(f.game, f.asker, f.target, f.target, f.asker, dueTurn);
|
||||
let game = accept(payment.game, f.target, payment.id);
|
||||
assert.equal(deal(game, payment.id).paymentStatus, 'pending');
|
||||
const future = travelOffer(game, f.asker, f.target, dueTurn);
|
||||
game = future.game;
|
||||
const purse = player(game, f.target).spice;
|
||||
reject(game, f.target, {type:'dealRespond', deal:future.id, accept:true});
|
||||
assert.equal(deal(game, payment.id).status, 'active');
|
||||
assert.equal(deal(game, payment.id).paymentStatus, 'pending');
|
||||
assert.equal(deal(game, future.id).status, 'offered');
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === future.id) ?? false, false);
|
||||
assert.equal(player(game, f.target).spice, purse);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
});
|
||||
|
||||
for (const order of ['travel-first', 'payment-first'] as const) void test(
|
||||
`different future due turns coexist when accepted ${order}`, () => {
|
||||
const f = fixture(), travelDue = f.game.turn + 1, paymentDue = travelDue + 1;
|
||||
let game = f.game, travelId: string, paymentId: string;
|
||||
if (order === 'travel-first') {
|
||||
const future = travelOffer(game, f.asker, f.target, travelDue); travelId = future.id;
|
||||
game = accept(future.game, f.target, travelId);
|
||||
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, paymentDue); paymentId = payment.id;
|
||||
game = accept(payment.game, f.target, paymentId);
|
||||
} else {
|
||||
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, paymentDue); paymentId = payment.id;
|
||||
game = accept(payment.game, f.target, paymentId);
|
||||
const future = travelOffer(game, f.asker, f.target, travelDue); travelId = future.id;
|
||||
game = accept(future.game, f.target, travelId);
|
||||
}
|
||||
assert.equal(deal(game, travelId).status, 'active');
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[travelId], 'scheduled');
|
||||
assert.equal(game.travelPromises?.find(row => row.sourceDeal === travelId)?.turn, travelDue);
|
||||
assert.equal(deal(game, paymentId).paymentStatus, 'pending');
|
||||
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
},
|
||||
);
|
||||
|
||||
for (const order of ['travel-first', 'payment-first'] as const) void test(
|
||||
`the same future due turn can involve a different payment payer when accepted ${order}`, () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
let game = f.game, travelId: string, paymentId: string;
|
||||
if (order === 'travel-first') {
|
||||
const future = travelOffer(game, f.asker, f.target, dueTurn); travelId = future.id;
|
||||
game = accept(future.game, f.target, travelId);
|
||||
const payment = paymentOffer(game, f.asker, f.target, f.asker, f.target, dueTurn); paymentId = payment.id;
|
||||
game = accept(payment.game, f.target, paymentId);
|
||||
} else {
|
||||
const payment = paymentOffer(game, f.asker, f.target, f.asker, f.target, dueTurn); paymentId = payment.id;
|
||||
game = accept(payment.game, f.target, paymentId);
|
||||
const future = travelOffer(game, f.asker, f.target, dueTurn); travelId = future.id;
|
||||
game = accept(future.game, f.target, travelId);
|
||||
}
|
||||
assert.equal(deal(game, travelId).status, 'active');
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[travelId], 'scheduled');
|
||||
assert.equal(deal(game, paymentId).paymentStatus, 'pending');
|
||||
assert.equal(player(game, f.asker).spice, player(f.game, f.asker).spice);
|
||||
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
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; dueTurn:number};
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected consent and travel actions leave the game unchanged.');
|
||||
}
|
||||
function offer(game: Game, proposer: string, target: string, travel: Term) {
|
||||
const next = act(game, proposer, {type:'dealOffer', target, terms:'Agreed travel on a later 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 row = game.travelPromises?.find(item => item.sourceDeal === id); assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
function endCurrent(game: Game, target: string) {
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, target);
|
||||
return settle(act(game, target, {type:'endMovement'}));
|
||||
}
|
||||
function dueShipment(game: Game, target: string, dueTurn: number): Game {
|
||||
return advance(game, state => state.turn === dueTurn && state.phase === 5 &&
|
||||
state.active === target && clean(state) && !state.pendingShipment);
|
||||
}
|
||||
|
||||
/** The shared Basic two-human fixture reaches the actual first-turn shipment
|
||||
* opportunity through setup and phase actions. All agreements, transfers,
|
||||
* Charity and due-turn actions below are real player actions; no funds, clocks,
|
||||
* forces, choices or receipts are staged. */
|
||||
void test('future consent does not reserve an empty purse; actual next-turn Charity funds the promised shipment', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
|
||||
const purse = player(f.game, f.target).spice; assert.ok(purse > 0);
|
||||
let game = act(f.game, f.target, {type:'bribe', target:f.asker, amount:purse});
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
const offered = offer(game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
game = accept(offered.game, f.target, offered.id);
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(receipt(game, offered.id).turn, dueTurn);
|
||||
assert.equal(receipt(game, offered.id).player, f.target);
|
||||
assert.equal(status(game, f.asker, offered.id), 'scheduled');
|
||||
assert.equal(status(game, f.target, offered.id), 'scheduled');
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide ?? null, null);
|
||||
game = endCurrent(game, f.target);
|
||||
assert.equal(status(game, f.target, offered.id), 'scheduled');
|
||||
game = advance(game, state => state.turn === dueTurn && state.phase === 2 && clean(state) &&
|
||||
!state.ready.includes(f.target));
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
game = act(game, f.target, {type:'charity'});
|
||||
assert.equal(player(game, f.target).spice, 2);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
reject(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(game, f.asker, offered.id), 'fulfilled');
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
});
|
||||
|
||||
for (const answer of [true, false]) void test(`future movement ${answer ? 'requires' : 'forbids'} the real matching action in its due turn`, () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'move', territory:f.movementTerritory, minimum:1, answer, dueTurn});
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
assert.equal(status(game, f.target, offered.id), 'scheduled');
|
||||
game = endCurrent(game, f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
if (answer) {
|
||||
reject(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
} else {
|
||||
reject(game, f.target, f.firstMove);
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
}
|
||||
assert.equal(status(game, f.target, offered.id), 'fulfilled');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an unaccepted offer remains available through its due turn and expires only afterwards', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
let game = endCurrent(offered.game, f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
assert.equal(game.deals!.find(row => row.id === offered.id)!.status, 'offered');
|
||||
const accepted = accept(game, f.target, offered.id);
|
||||
assert.equal(status(accepted, f.target, offered.id), 'pending');
|
||||
assert.equal(receipt(accepted, offered.id).turn, dueTurn);
|
||||
game = endCurrent(game, f.target);
|
||||
game = advance(game, state => state.turn > dueTurn);
|
||||
reject(game, f.target, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
});
|
||||
|
||||
void test('mutual release before the due turn cancels the scheduled receipt without blocking current travel', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
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), 'scheduled');
|
||||
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).releasedByDeal, true);
|
||||
game = endCurrent(game, f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
assert.equal(status(game, f.asker, offered.id), 'released');
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
assert.equal(status(game, f.target, offered.id), 'released');
|
||||
});
|
||||
|
||||
void test('an unfunded future promise remains pending until its actual travel turn can close', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:20, answer:true, dueTurn});
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = endCurrent(game, f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
assert.ok(player(game, f.target).spice < 40);
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
assert.equal(status(game, f.target, offered.id), 'void');
|
||||
});
|
||||
|
||||
void test('a witnessed due shipment cannot be voluntarily made unaffordable', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
let game = endCurrent(accept(offered.game, f.target, offered.id), f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
const purse = player(game, f.target).spice; assert.ok(purse > 2);
|
||||
reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1});
|
||||
game = act(game, f.target, {type:'bribe', target:f.asker, amount:purse - 2});
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(game, f.target, offered.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('public contradictions cannot be accepted and do not disturb the earlier future agreement', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const term:Term = {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn};
|
||||
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, answer:false});
|
||||
game = second.game;
|
||||
reject(game, f.target, {type:'dealRespond', deal:second.id, accept:true});
|
||||
const third = offer(game, f.asker, f.target, {...term, territory:'carthag'});
|
||||
game = third.game;
|
||||
reject(game, f.target, {type:'dealRespond', deal:third.id, accept:true});
|
||||
assert.equal(status(game, f.target, first.id), 'scheduled');
|
||||
assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered');
|
||||
assert.equal(game.deals!.find(deal => deal.id === third.id)!.status, 'offered');
|
||||
game = endCurrent(game, f.target);
|
||||
game = dueShipment(game, f.target, dueTurn);
|
||||
assert.equal(status(game, f.target, first.id), 'pending');
|
||||
const purse = player(game, f.target).spice; assert.ok(purse > 2);
|
||||
reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1});
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(game, f.target, first.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('all minimal autopilot profiles complete an accepted future shipment on the due turn', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = endCurrent(game, f.target);
|
||||
const due = dueShipment(game, f.target, dueTurn);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let controlled = act(due, f.target, {type:'setAutopilot', difficulty});
|
||||
for (let step = 0; step < 8 && status(controlled, f.target, offered.id) === 'pending'; step++) {
|
||||
const actions = botActions(viewGame(controlled, f.target));
|
||||
assert.ok(actions.length, `${difficulty} needs a legal native continuation`);
|
||||
controlled = settle(act(controlled, f.target, actions[0]));
|
||||
}
|
||||
assert.equal(status(controlled, f.target, offered.id), 'fulfilled', difficulty);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a game that naturally ends before the scheduled turn voids the unfulfilled term', () => {
|
||||
const f = fixture(), dueTurn = 11;
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = endCurrent(game, f.target);
|
||||
game = advance(game, state => state.status === 'finished');
|
||||
assert.ok(game.turn < dueTurn);
|
||||
assert.equal(status(game, f.asker, offered.id), 'void');
|
||||
assert.equal(receipt(game, offered.id).fulfilled, false);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthTurnAction as act, advanceTruthTurnAction as advance, bindTruthTurnAction as bind,
|
||||
createTruthTurnActionFixture as fixture, finishTruthTurnAction as finish,
|
||||
settleTruthTurnAction as settle, truthTurnActionClean as clean,
|
||||
truthTurnActionInventory as inventory, truthTurnActionPlayer as player,
|
||||
truthTurnActionPromise as promise, truthTurnActionShip as ship,
|
||||
} from './fixture-truth-turn-action';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected actions preserve both original receipts.');
|
||||
}
|
||||
|
||||
/** Real Basic three-seat game, actual dealt Truthtrance and native movement
|
||||
* window. This schedules the later public obligation first; it then asks and
|
||||
* fulfills a distinct genuine whole-turn Truthtrance shipment in turn one. */
|
||||
void test('a completed earlier whole-turn Truthtrance receipt preserves a scheduled future travel deal through rollover', () => {
|
||||
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
|
||||
let game = act(f.game, f.asker, {type:'dealOffer', target:f.target, terms:'Ship to Polar Sink next turn.',
|
||||
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn}});
|
||||
const deal = game.deals?.at(-1); assert.ok(deal);
|
||||
game = act(game, f.target, {type:'dealRespond', deal:deal.id, accept:true});
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
|
||||
const scheduled = game.travelPromises?.find(row => row.sourceDeal === deal.id); assert.ok(scheduled);
|
||||
assert.equal(scheduled.turn, dueTurn); assert.equal(scheduled.player, f.target);
|
||||
assert.equal(scheduled.fulfilled, false); assert.equal(scheduled.released, false);
|
||||
|
||||
game = bind(f, 'yes', ship(), game).game;
|
||||
const truth = game.turnActionPromises?.at(-1); assert.ok(truth);
|
||||
assert.equal(truth.turn, f.game.turn); assert.equal(truth.player, f.target);
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
|
||||
reject(game, f.target, f.end);
|
||||
game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
|
||||
assert.equal(game.travelPromises?.find(row => row.sourceDeal === deal.id)?.fulfilled, false);
|
||||
const firstForces = player(game, f.target).forces['polar_sink:0'];
|
||||
game = settle(act(game, f.target, f.end));
|
||||
game = finish(game);
|
||||
assert.equal(game.turn, dueTurn);
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'pending');
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
|
||||
game = advance(game, state => state.turn === dueTurn && state.phase === 5 &&
|
||||
state.active === f.target && clean(state) && !state.pendingShipment, [], true);
|
||||
reject(game, f.target, f.end);
|
||||
game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'fulfilled');
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], firstForces + 1);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-f
|
||||
const proposer = 'atreides', traveler = 'emperor', first = 'harkonnen', second = 'fremen';
|
||||
const shipment: Action = {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0};
|
||||
const movement: Action = {type:'move', from:'polar_sink:0', territory:'imperial_basin', sector:9, amount:1};
|
||||
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean};
|
||||
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean; dueTurn?:number};
|
||||
|
||||
function reach(game: Game, until: (game: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
@@ -120,6 +120,34 @@ void test('four-party consent requires all other recipients first and release re
|
||||
assert.equal(status(game, first, offered.id), 'released');
|
||||
});
|
||||
|
||||
void test('a four-party future shipment stays scheduled until the due turn and follows the named traveler', () => {
|
||||
const initial = fixture(), dueTurn = initial.turn + 1, originalCards = inventory(initial);
|
||||
const offered = offer(initial, [first, traveler, second],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn});
|
||||
let game = offered.game;
|
||||
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
game = respond(game, second, offered.id);
|
||||
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
game = respond(game, first, offered.id);
|
||||
game = respond(game, traveler, offered.id);
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
assert.equal(receipt(game, offered.id).player, traveler);
|
||||
assert.equal(receipt(game, offered.id).turn, dueTurn);
|
||||
for (const viewer of [proposer, first, second, traveler])
|
||||
assert.equal(status(game, viewer, offered.id), 'scheduled');
|
||||
assert.equal(viewGame(game, traveler).travelPromiseGuide ?? null, null);
|
||||
game = settle(act(game, traveler, {type:'endMovement'}));
|
||||
game = reach(game, state => state.turn === dueTurn && state.phase === 5 &&
|
||||
state.active === traveler && clean(state) && !state.pendingShipment);
|
||||
assert.equal(status(game, traveler, offered.id), 'pending');
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
game = settle(act(game, traveler, shipment));
|
||||
for (const viewer of [proposer, first, second, traveler])
|
||||
assert.equal(status(game, viewer, offered.id), 'fulfilled');
|
||||
assert.equal(player(game, traveler).forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
});
|
||||
|
||||
for (const answer of [true, false]) void test(`group movement ${answer ? 'requires' : 'forbids'} one real matching action`, () => {
|
||||
const moved = settle(act(fixture(), traveler, shipment));
|
||||
const offered = offer(moved, [first, traveler],
|
||||
|
||||
@@ -39,6 +39,14 @@ void test('the offer packet retains legacy two-party terms and validates an expl
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
|
||||
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}),
|
||||
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}});
|
||||
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn:2}),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', travel}, 'Current-turn packet keeps the legacy shape.');
|
||||
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, {...travel,dueTurn:4}),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', payment, travel:{...travel,dueTurn:4}});
|
||||
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel,actor:'c',dueTurn:4}),
|
||||
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel,actor:'c',dueTurn:4}});
|
||||
for (const dueTurn of [0, 1, 2.5, Number.NaN, undefined])
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn}), null);
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'a'}), null);
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'d'}), null);
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel, actor:'b'}), null);
|
||||
@@ -67,7 +75,7 @@ void test('the combined offer explains payment timing and the recipient can acce
|
||||
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/);
|
||||
assert.match(html, /cannot spend this payment before its next Mentat collection/);
|
||||
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');
|
||||
@@ -218,6 +226,51 @@ void test('the group offer form selects an invited traveler and revalidates part
|
||||
ui.check(3); assert.equal(button()?.props.disabled,true, 'Group travel cannot be combined with payment.');
|
||||
});
|
||||
|
||||
void test('the author chooses a future travel turn and can return to the legacy current-turn packet', () => {
|
||||
const ui = combinedForm();
|
||||
ui.check(0); ui.check(2);
|
||||
ui.select(2,'carthag');
|
||||
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
|
||||
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Later journey'}});
|
||||
const deadline = () => ui.render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === 2);
|
||||
const submit = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
|
||||
assert.ok(deadline());
|
||||
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'4'}});
|
||||
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
|
||||
assert.equal(submit()?.props.disabled,false);
|
||||
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Later journey',
|
||||
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,dueTurn:4}});
|
||||
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'1'}});
|
||||
assert.equal(submit()?.props.disabled,true);
|
||||
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'2'}});
|
||||
assert.equal(submit()?.props.disabled,false);
|
||||
const current = ui.render().find(node => node.type === 'form'); assert.ok(current);
|
||||
(current.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Later journey',
|
||||
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
|
||||
});
|
||||
|
||||
void test('future offers stay actionable until the selected turn and scheduled terms do not prompt the traveler early', () => {
|
||||
const offered = {...base,turn:3, deals:[{...deal,status:'offered',travel:{...travel,dueTurn:4}}],
|
||||
dealTravelStatus:{}} as unknown as GameView;
|
||||
assert.match(markup(offered), /during turn 4/);
|
||||
assert.match(markup(offered), /Before turn 4, this term reserves no forces or spice/);
|
||||
const accept = (game:GameView) => elements(PublicDealList({game,disabled:false,act() {}}))
|
||||
.find(node => node.props.children === 'Accept deal');
|
||||
assert.equal(accept(offered)?.props.disabled,false);
|
||||
assert.equal(accept({...offered,turn:4})?.props.disabled,false);
|
||||
const expired = {...offered,turn:5};
|
||||
assert.equal(accept(expired)?.props.disabled,true);
|
||||
assert.match(markup(expired), /travel term’s turn has passed/);
|
||||
const scheduled = {...offered, deals:[{...deal,travel:{...travel,dueTurn:4}}],
|
||||
dealTravelStatus:{deal:'scheduled'}} as unknown as GameView;
|
||||
assert.match(markup(scheduled), /Travel status: scheduled/);
|
||||
assert.deepEqual(publicDealRequests(scheduled), []);
|
||||
assert.deepEqual(publicDealRequests({...scheduled,turn:4,dealTravelStatus:{deal:'pending'}} as unknown as GameView),
|
||||
[['travel','deal']]);
|
||||
});
|
||||
|
||||
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/);
|
||||
@@ -275,7 +328,7 @@ void test('the recipient guide distinguishes a void deal from unanimous release'
|
||||
|
||||
void test('the real offer form asks for recipients 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, /Include a travel term/);
|
||||
assert.match(html, /Select at least one human recipient to add a travel term/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
||||
publicDealTravelActor,
|
||||
publicDealTravelActor, publicDealTravelDueTurn,
|
||||
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
@@ -29,6 +29,53 @@ void test('travel terms accept only exact bounded native actions and an explicit
|
||||
assert.deepEqual(parsePublicDealTravel({...term, actor:'c'}), {...term, actor:'c'});
|
||||
});
|
||||
|
||||
void test('travel due turns are exact positive safe integers and cannot predate the offer', () => {
|
||||
for (const dueTurn of [1, 3, 4, Number.MAX_SAFE_INTEGER]) {
|
||||
assert.deepEqual(parsePublicDealTravel({...term, dueTurn}), {...term, dueTurn});
|
||||
assert.deepEqual(parsePublicDealTravel({...term, actor:'c', dueTurn}), {...term, actor:'c', dueTurn});
|
||||
}
|
||||
for (const dueTurn of [0, -1, 2.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '4', undefined, null]) {
|
||||
assert.throws(() => parsePublicDealTravel({...term, dueTurn}), PublicDealError);
|
||||
assert.throws(() => parsePublicDealTravel({...term, actor:'c', dueTurn}), PublicDealError);
|
||||
}
|
||||
const old = setPublicDealTravel(fresh(), term, seats);
|
||||
assert.equal(publicDealTravelDueTurn(old), 3);
|
||||
assert.equal(Object.hasOwn(old.travel!, 'dueTurn'), false);
|
||||
for (const dueTurn of [1, 2]) {
|
||||
assert.throws(() => setPublicDealTravel(fresh(), {...term, dueTurn}, seats), PublicDealError);
|
||||
assert.throws(() => validatePublicDeals([{...fresh(), travel:{...term, dueTurn}}], seats), PublicDealError);
|
||||
}
|
||||
for (const dueTurn of [3, 4]) {
|
||||
const offered = setPublicDealTravel(fresh(), {...term, dueTurn}, seats);
|
||||
assert.equal(publicDealTravelDueTurn(offered), dueTurn);
|
||||
assert.deepEqual(offered.travel, {...term, dueTurn});
|
||||
}
|
||||
});
|
||||
|
||||
void test('future two-party and group terms retain due turns across consent and release', () => {
|
||||
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
|
||||
const pair = setPublicDealTravel(createPublicDeal(seats, 'a', 'b', 'Future travel.', 3, 'pair', payment),
|
||||
{...term, dueTurn:5}, seats);
|
||||
const pairActive = respondPublicDeal(pair, seats, 'b', true);
|
||||
const pairReleased = releasePublicDeal(releasePublicDeal(pairActive, seats, 'a', true), seats, 'b', true);
|
||||
const group = setPublicDealTravel(createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Future travel.', 3, 'group'),
|
||||
{...term, actor:'c', dueTurn:5}, seats);
|
||||
assert.throws(() => respondPublicDeal(group, seats, 'c', true), PublicDealError);
|
||||
const groupActive = respondPublicDeal(respondPublicDeal(group, seats, 'b', true), seats, 'c', true);
|
||||
const groupReleased = releasePublicDeal(releasePublicDeal(releasePublicDeal(groupActive, seats, 'a', true),
|
||||
seats, 'b', true), seats, 'c', true);
|
||||
for (const record of [pair, pairActive, pairReleased, group, groupActive, groupReleased]) {
|
||||
const roundtrip = JSON.parse(JSON.stringify(record)) as PublicDeal;
|
||||
validatePublicDeals([roundtrip], seats);
|
||||
assert.equal(publicDealTravelDueTurn(roundtrip), 5);
|
||||
assert.deepEqual(roundtrip.travel, record.travel);
|
||||
assert.deepEqual(roundtrip.payment, record.payment);
|
||||
}
|
||||
const backwardsGroup = {...group, travel:{...term, actor:'c', dueTurn:2}};
|
||||
assert.throws(() => validatePublicDeals([backwardsGroup], seats), PublicDealError);
|
||||
assert.throws(() => validatePublicDeals([{...group, payment, travel:{...term, actor:'c', dueTurn:5}}], seats), 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 };
|
||||
|
||||
Reference in new issue
Block a user