Support group travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m13s
Verify and publish checkpoint container / container (push) Successful in 11m13s
This commit is contained in:
1 parent
82d857b667
commit
e1e02fc1a1
10 files changed
+432
-56
No files matched your search
+43
-21
@@ -3,7 +3,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 { publicDealAcceptVotes, publicDealParticipants, publicDealTravelActor } from '@/game/public-deals';
|
||||
import { areCoalitionAllies } from '@/game/coalition-alliance';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,7 +12,7 @@ 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 DealTravel = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean; actor?: string };
|
||||
type DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released';
|
||||
type TravelDeal = Deal & { travel?: DealTravel };
|
||||
type TravelProjection = GameView & { dealTravelStatus?: Record<string, DealTravelStatus> };
|
||||
@@ -34,17 +34,22 @@ export function publicDealRequests(game: GameView): string[][] {
|
||||
if (game.status !== 'playing') return [];
|
||||
return game.deals.flatMap(deal => {
|
||||
const requests: string[][] = [];
|
||||
const participant = publicDealParticipants(deal).includes(game.me);
|
||||
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
|
||||
requests.push(['offer', deal.id]);
|
||||
const parties = publicDealParticipants(deal), accepted = publicDealAcceptVotes(deal);
|
||||
const participant = parties.includes(game.me);
|
||||
const travel = (deal as TravelDeal).travel;
|
||||
const waitingForOtherVotes = !!travel && parties.length > 2 && publicDealTravelActor(deal) === game.me &&
|
||||
parties.some(id => id !== game.me && !accepted.includes(id));
|
||||
if (deal.status === 'offered' && participant && deal.from !== game.me && !accepted.includes(game.me)) {
|
||||
const groupTraveler = !!travel && parties.length > 2 && publicDealTravelActor(deal) === game.me;
|
||||
requests.push(groupTraveler ? ['offer', deal.id, waitingForOtherVotes ? 'waiting' : 'ready'] : ['offer', deal.id]);
|
||||
}
|
||||
if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0)
|
||||
requests.push(['release', deal.id, ...deal.releaseVotes]);
|
||||
const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus };
|
||||
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 &&
|
||||
if (deal.status === 'active' && travel && publicDealTravelActor(deal) === game.me &&
|
||||
(game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending')
|
||||
requests.push(['travel', deal.id]);
|
||||
return requests;
|
||||
@@ -60,9 +65,12 @@ 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 && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0]) ||
|
||||
Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' ||
|
||||
const groupTravel = !!travel && targets.length > 1;
|
||||
if (travel && (!proposer || targets.includes(proposer) ||
|
||||
(groupTravel ? !!payment || Object.keys(travel).sort().join(',') !== 'actor,answer,kind,minimum,territory' ||
|
||||
typeof travel.actor !== 'string' || !targets.includes(travel.actor) :
|
||||
Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' ||
|
||||
!!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0])) ||
|
||||
!['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;
|
||||
@@ -70,7 +78,7 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
|
||||
const offeredTravel = travel ? { travel } : {};
|
||||
return targets.length === 1
|
||||
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel }
|
||||
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment };
|
||||
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment, ...offeredTravel };
|
||||
}
|
||||
|
||||
/** The whole table can read an offer and its history; only its parties act. */
|
||||
@@ -99,10 +107,13 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
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 travelActor = travel ? publicDealTravelActor(deal) : undefined;
|
||||
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 travelActorWaiting = !!travel && participants.length > 2 && game.me === travelActor &&
|
||||
participants.some(participant => participant !== travelActor && !accepted.includes(participant));
|
||||
const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null;
|
||||
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn;
|
||||
const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id];
|
||||
@@ -114,7 +125,8 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
{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 === '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>}
|
||||
{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 && 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(', ')}.
|
||||
</p>}
|
||||
@@ -126,7 +138,7 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
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 || travelExpired} 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 || travelActorWaiting} 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}
|
||||
@@ -158,6 +170,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const [travelTerritory, setTravelTerritory] = useState('');
|
||||
const [travelMinimum, setTravelMinimum] = useState('1');
|
||||
const [travelAnswer, setTravelAnswer] = useState(true);
|
||||
const [travelActor, setTravelActor] = useState('');
|
||||
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
|
||||
const [payer, setPayer] = useState('');
|
||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||
@@ -195,7 +208,8 @@ export function PublicDeals({ game, act, busy }: {
|
||||
Number.isSafeInteger(amount) && amount > 0;
|
||||
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
|
||||
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
|
||||
minimum:Number(travelMinimum), answer:travelAnswer} : undefined;
|
||||
minimum:Number(travelMinimum), answer:travelAnswer,
|
||||
...(selected.length > 1 ? {actor:travelActor} : {})} : 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;
|
||||
@@ -215,9 +229,10 @@ 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 || includeTravel && selected.length >= 1))}
|
||||
<input type="checkbox" checked={checked} disabled={disabled || (!checked && conflicts)}
|
||||
onChange={event => {
|
||||
const nextChecked = event.target.checked;
|
||||
if (!nextChecked && travelActor === player.id) setTravelActor('');
|
||||
setSelected(current => nextChecked
|
||||
? current.includes(player.id) ? current : [...current, player.id] : current.filter(id => id !== player.id));
|
||||
}} />
|
||||
@@ -268,16 +283,23 @@ export function PublicDeals({ game, act, busy }: {
|
||||
{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 || !includeTravel && selected.length !== 1}
|
||||
<input type="checkbox" checked={includeTravel} disabled={disabled || !includeTravel && selected.length === 0}
|
||||
onChange={event => setIncludeTravel(event.target.checked)} />
|
||||
Include a current-turn travel term
|
||||
</label>
|
||||
{!includeTravel && selected.length !== 1 && <p className="fine">Select exactly one human recipient to add a travel term.</p>}
|
||||
{!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">The selected recipient is the committed player. This term concerns one physical action during turn {game.turn} and cannot include additional recipients. Written terms remain player-managed.</p>
|
||||
<p className="fine">A travel term may include an immediate payment from the proposer to the recipient. The recipient collects their share at the next Mentat opening, so it cannot fund this turn’s shipment or movement.</p>
|
||||
{!combinedPaymentValid && <p role="alert">For a travel term, choose an immediate payment from the proposer to the selected recipient, or remove the payment.</p>}
|
||||
<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>
|
||||
{!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}
|
||||
onChange={event => setTravelActor(event.target.value)}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{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>
|
||||
<label>Travel action
|
||||
<select value={travelKind} disabled={disabled} onChange={event => setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
|
||||
@@ -310,7 +332,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<Button type="submit" disabled={disabled || !offer}>Offer public deal</Button>
|
||||
{targets.length === 0 && <p>No human recipient is available.</p>}
|
||||
{selected.length > 0 && !legalSelection && <p>Clear or change any recipient whose seat or alliance is no longer eligible.</p>}
|
||||
{selected.length > 0 && <Button type="button" variant="outline" disabled={disabled} onClick={() => setSelected([])}>Clear recipients</Button>}
|
||||
{selected.length > 0 && <Button type="button" variant="outline" disabled={disabled} onClick={() => { setSelected([]); setTravelActor(''); }}>Clear recipients</Button>}
|
||||
</form>}
|
||||
<h3>Deal history</h3>
|
||||
<PublicDealList game={game} act={act} disabled={disabled} />
|
||||
|
||||
@@ -17,6 +17,19 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**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
|
||||
and can decline early, then gives final consent after a private feasibility
|
||||
check. The existing physical travel receipt, status, action guide and unanimous
|
||||
release remain authoritative; older two-party records keep their original
|
||||
shape. Real three- and four-seat games cover shipment, movement, failed final
|
||||
consent, release and four legal autopilot profiles; schema and controls cover
|
||||
larger party lists. The 29-file related batch passes 181 cases, and the three
|
||||
changed files pass 28 cases. Type checking, lint and production build pass.
|
||||
Multi-party payment combinations, future turns, optional modules and deployed
|
||||
acceptance remain open. [Scope](PUBLIC_DEALS.md#structured-current-turn-travel--29-october-2026).
|
||||
|
||||
**Basic Richese reveals during responses — 29 October. Prototyped / Partial.**
|
||||
The owner can reveal an existing No-Field during clean CHOAM Charity,
|
||||
Atreides normal-auction inspection, Richese cache-auction declaration and
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
## 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
|
||||
**Prototyped / Partial.** In a quiet Basic classic table, an offer between two
|
||||
to six human seats can ask one invited 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.
|
||||
autopilot handover uses that same guide. For a group offer, the proposer names
|
||||
the traveler and the other invitees accept first; the traveler gives final
|
||||
consent and the server privately checks their own ability to comply. Any invitee
|
||||
can decline, and release still requires every party. This actor-last online
|
||||
ordering is provisional and does not restrict the printed scope of agreements.
|
||||
|
||||
A two-party offer can also pair that term with an immediate ordinary bribe
|
||||
from the proposer to the traveler. Final acceptance binds the travel and
|
||||
@@ -30,7 +34,7 @@ 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
|
||||
multi-party payments paired with travel, 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)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Group travel deals — 29 October 2026
|
||||
|
||||
The publisher's base rulebook, printed p. 12, permits nonallied public deals
|
||||
for future actions. A structured current-turn group travel term names an
|
||||
invited traveler, who accepts last after the other parties; that online consent
|
||||
ordering is provisional. The final acceptance privately checks their own
|
||||
feasibility, and all signatories must release a binding agreement.
|
||||
[Scope](PUBLIC_DEALS.md#structured-current-turn-travel--29-october-2026).
|
||||
|
||||
## Richese reveal during selected Basic responses — 29 October 2026
|
||||
|
||||
The publisher CHOAM & Richese rules, printed p. 6, allow Richese to reveal
|
||||
|
||||
+10
-7
@@ -8,7 +8,7 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
|
||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, setPublicDealTravel, publicDealTravelActor, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
import { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
@@ -5968,7 +5968,7 @@ function publicDealIntegrity(g: Game): void {
|
||||
const deal = g.deals?.find(entry => entry.id === row.sourceDeal);
|
||||
const term = deal?.travel;
|
||||
requireRule(deal && term && ['active', 'released'].includes(deal.status) &&
|
||||
row.player === deal.to && row.asker === deal.from && row.turn === deal.turn &&
|
||||
row.player === publicDealTravelActor(deal) && row.asker === deal.from && row.turn === deal.turn &&
|
||||
row.answer === term.answer && row.mixed.kind === 'action' &&
|
||||
row.mixed.action.kind === term.kind && row.mixed.action.territory === term.territory &&
|
||||
row.mixed.action.minimum === term.minimum && row.mixed.action.sector === undefined &&
|
||||
@@ -23512,7 +23512,7 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
|
||||
/** A deal is a separately consented public source for the same physical
|
||||
* travel constraint, not a Truthtrance question or a fabricated card play. */
|
||||
function bindPublicDealTravel(g: Game, deal: PublicDeal) {
|
||||
const term = deal.travel, player = getPlayer(g, deal.to);
|
||||
const term = deal.travel, player = getPlayer(g, publicDealTravelActor(deal));
|
||||
requireRule(term && deal.turn === g.turn && !g.advanced && g.expansions.length === 0 &&
|
||||
nativePromiseScopeSupported(g) && travelTargetAvailable(g, player.id) && !g.truthtrance &&
|
||||
!hasBribePromises(g, player.id) &&
|
||||
@@ -42396,7 +42396,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
const keys = Object.keys(action).sort().join(',');
|
||||
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
|
||||
keys === 'target,terms,travel,type' || keys === 'payment,target,terms,travel,type') && typeof action.target === 'string';
|
||||
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type') && Array.isArray(action.targets) &&
|
||||
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type' ||
|
||||
keys === 'targets,terms,travel,type') && Array.isArray(action.targets) &&
|
||||
action.targets.every(target => typeof target === 'string');
|
||||
const hasPayment = Object.hasOwn(action, 'payment');
|
||||
requireRule((single || multiple) && typeof action.terms === 'string' &&
|
||||
@@ -42413,8 +42414,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
? createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event, payment!)
|
||||
: createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event));
|
||||
if (Object.hasOwn(action, 'travel')) {
|
||||
requireRule(single && !g.advanced && g.expansions.length === 0 &&
|
||||
nativePromiseScopeSupported(g), 'Choose a two-player travel agreement in a supported Basic table.');
|
||||
requireRule(!g.advanced && g.expansions.length === 0 && nativePromiseScopeSupported(g),
|
||||
'Choose a travel agreement in a supported Basic table.');
|
||||
offer = publicDealRule(() => setPublicDealTravel(offer, action.travel, seats));
|
||||
}
|
||||
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
|
||||
@@ -42439,8 +42440,10 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
|
||||
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
|
||||
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
|
||||
if (current.status === 'offered' && next.status === 'active' && next.travel)
|
||||
if (current.status === 'offered' && next.status === 'active' && next.travel) {
|
||||
requireRule(id === publicDealTravelActor(next), 'The traveler must give the final consent.');
|
||||
bindPublicDealTravel(g, next);
|
||||
}
|
||||
if (current.status === 'active' && next.status === 'released' && next.travel) {
|
||||
const receipt = g.travelPromises?.find(row => row.sourceDeal === next.id);
|
||||
requireRule(receipt, 'The public travel deal lost its accepted commitment.');
|
||||
|
||||
+36
-15
@@ -15,12 +15,15 @@ export type PublicDealPayment = {
|
||||
dueTurn: number;
|
||||
};
|
||||
export type PublicDealPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
||||
/** The recipient commits to one native action (or its absence) during the offer turn. */
|
||||
/** One invited recipient commits to one native action (or its absence).
|
||||
* Old two-party records identify that recipient through `to`. */
|
||||
export type PublicDealTravel = {
|
||||
kind: 'ship' | 'move';
|
||||
territory: string;
|
||||
minimum: number;
|
||||
answer: boolean;
|
||||
/** Present only in a multi-party term; the selected recipient accepts last. */
|
||||
actor?: string;
|
||||
};
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
@@ -86,6 +89,10 @@ export function publicDealAcceptVotes(deal: PublicDeal): string[] {
|
||||
return deal.status === 'active' || deal.status === 'released'
|
||||
? [deal.from, deal.to] : [deal.from];
|
||||
}
|
||||
/** Call after ledger validation; legacy two-party terms need no new field. */
|
||||
export function publicDealTravelActor(deal: PublicDeal): string {
|
||||
return deal.travel?.actor ?? deal.to;
|
||||
}
|
||||
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
|
||||
return new Set(votes).size === votes.length &&
|
||||
votes.every(vote => parties.includes(vote)) &&
|
||||
@@ -112,13 +119,16 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
||||
amount: value.amount, dueTurn: value.dueTurn };
|
||||
}
|
||||
|
||||
/** Parse only the exact two-seat deal form, without a sector or an extra deadline. */
|
||||
/** Parse an exact travel claim; the multi-party actor is validated against the deal. */
|
||||
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
|
||||
requireDeal(object(value) && Object.keys(value).sort().join(',') === 'answer,kind,minimum,territory' &&
|
||||
const keys = object(value) ? Object.keys(value).sort().join(',') : '';
|
||||
requireDeal(object(value) && (keys === 'answer,kind,minimum,territory' ||
|
||||
keys === 'actor,answer,kind,minimum,territory' && id(value.actor)) &&
|
||||
typeof value.answer === 'boolean', 'Choose an exact shipment or movement term and require or avoid it.');
|
||||
try {
|
||||
const claim = parseTravelActionClaim({ kind: value.kind, territory: value.territory, minimum: value.minimum });
|
||||
return { kind: claim.kind, territory: claim.territory, minimum: claim.minimum, answer: value.answer };
|
||||
return { kind: claim.kind, territory: claim.territory, minimum: claim.minimum, answer: value.answer,
|
||||
...(keys.startsWith('actor,') ? {actor: value.actor as string} : {}) };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
||||
throw error;
|
||||
@@ -164,10 +174,17 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||
if (Object.hasOwn(deal, 'travel')) {
|
||||
requireDeal(!multi && !Object.hasOwn(deal, 'paymentStatus') &&
|
||||
(!payment || payment.kind === 'immediateBribe' && payment.payer === deal.from && payment.recipient === deal.to),
|
||||
'A travel term requires a two-party deal with no payment or an immediate bribe from the proposer to the recipient.');
|
||||
parsePublicDealTravel(deal.travel);
|
||||
const travel = parsePublicDealTravel(deal.travel);
|
||||
requireDeal(!Object.hasOwn(deal, 'paymentStatus') && (multi
|
||||
? !!travel.actor && travel.actor !== deal.from && parties.includes(travel.actor) && !payment
|
||||
: !Object.hasOwn(travel, 'actor') && (!payment || payment.kind === 'immediateBribe' &&
|
||||
payment.payer === deal.from && payment.recipient === deal.to)),
|
||||
'Choose an invited traveler; payment is available only for a two-party proposer-to-recipient immediate bribe.');
|
||||
// The actor's final acceptance is checked privately. An unfinished offer
|
||||
// cannot already contain that vote, even after a decline or withdrawal.
|
||||
if (multi && deal.status !== 'active' && deal.status !== 'released')
|
||||
requireDeal(!(deal.acceptVotes as string[]).includes(travel.actor!),
|
||||
'The committed traveler accepts after all other invited participants.');
|
||||
}
|
||||
if (payment?.kind === 'deferredBribe') {
|
||||
const status = deal.paymentStatus;
|
||||
@@ -218,15 +235,16 @@ export function createMultipartyPublicDeal(
|
||||
...paymentTerm };
|
||||
}
|
||||
|
||||
/** Attach a travel term while constructing a fresh two-party offer. */
|
||||
/** Attach a travel term while constructing a fresh offer. */
|
||||
export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: readonly PublicDealSeat[]): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'participants') &&
|
||||
!Object.hasOwn(deal, 'acceptVotes') && !Object.hasOwn(deal, 'travel') &&
|
||||
!Object.hasOwn(deal, 'paymentStatus') && (!deal.payment ||
|
||||
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to),
|
||||
'Only a fresh two-party offer with no payment or an immediate proposer-to-recipient bribe can add a travel term.');
|
||||
notAllied(participants(players, [deal.from, deal.to]));
|
||||
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'travel') &&
|
||||
!Object.hasOwn(deal, 'paymentStatus') && (deal.participants
|
||||
? !!deal.acceptVotes && deal.acceptVotes.length === 1 && deal.acceptVotes[0] === deal.from && !deal.payment
|
||||
: !Object.hasOwn(deal, 'acceptVotes') && (!deal.payment ||
|
||||
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to)),
|
||||
'Only a fresh offer may add a travel term with a supported payment.');
|
||||
notAllied(participants(players, publicDealParticipants(deal)));
|
||||
const result = { ...deal, travel: parsePublicDealTravel(value) };
|
||||
validatePublicDeals([result], players);
|
||||
return result;
|
||||
@@ -243,6 +261,9 @@ export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDeal
|
||||
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
||||
const alreadyAccepted = publicDealAcceptVotes(deal);
|
||||
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
|
||||
if (deal.travel && actor === publicDealTravelActor(deal))
|
||||
requireDeal(parties.every(seat => seat === actor || alreadyAccepted.includes(seat)),
|
||||
'The committed traveler accepts after all other invited participants.');
|
||||
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
|
||||
const active = accepts.length === parties.length;
|
||||
return { ...deal, acceptVotes: accepts, status: active ? 'active' : 'offered', releaseVotes: [],
|
||||
|
||||
+2
-2
@@ -303,7 +303,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
|
||||
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
|
||||
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
|
||||
'In a supported Basic classic table, a two-person deal can instead specify that the accepting recipient will make or avoid one current-turn reserve shipment or ground move of at least a selected number of physical forces to a named territory. The actual future action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. The current-turn planner and mutual release apply, with unsupported combinations still in development.',
|
||||
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one current-turn reserve shipment or ground move of at least a selected number of physical forces to a named territory. In a group offer the other recipients accept first and the named traveler accepts last; the game checks that traveler’s own current ability on final consent. The actual future action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. The current-turn planner and mutual release apply, with unsupported combinations still in development.',
|
||||
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility. Free text alone pays no spice. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
|
||||
],
|
||||
@@ -313,7 +313,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and a structured current-turn shipment or movement term with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection, Bureaucrat responses and legal autopilot continuation of a previously accepted travel term work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Group travel has actual multi-seat consent, physical travel and release coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-group-travel-runtime.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthMovement as act, truthMovementClean as clean, truthMovementInventory as inventory,
|
||||
truthMovementPlayer as player } from './fixture-truth-movement';
|
||||
import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-factions';
|
||||
|
||||
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};
|
||||
|
||||
function reach(game: Game, until: (game: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native game ended before the travel opportunity.');
|
||||
const step = next(game); assert.ok(step, `Supply native continuation at ${game.turn}/${game.phase}`);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('The native travel opportunity was not reached.');
|
||||
}
|
||||
function settle(game: Game): Game {
|
||||
return reach(game, state => clean(state) && !state.pendingShipment);
|
||||
}
|
||||
|
||||
/** Four actual human Basic seats. Reorder only original unopened starting
|
||||
* cards and Spice cards, and use native setup and phase actions to reach the
|
||||
* Emperor's unused first-turn shipment. No resource, force, promise, vote or
|
||||
* pending decision is installed by the test. */
|
||||
function nativeSource(): Game {
|
||||
let game = createGame('GROUPTRAVEL', newPlayer(proposer, 'Atreides', 'atreides'), false, []);
|
||||
for (const [id, faction] of [[traveler,'emperor'], [first,'harkonnen'], [second,'fremen']] as const)
|
||||
joinGame(game, newPlayer(id, id, faction));
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, proposer, {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
const cards = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 4);
|
||||
assert.equal(cards.length, 4);
|
||||
for (const [position, card] of cards.entries()) {
|
||||
const from = game.deck.findIndex(row => row.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
}
|
||||
game = reach(game, state => state.status === 'playing');
|
||||
assert.equal(game.status, 'playing');
|
||||
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]);
|
||||
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === traveler && clean(state));
|
||||
assert.equal(player(game, traveler).shipped, false);
|
||||
assert.ok(player(game, traveler).spice >= 2);
|
||||
assert.ok(!game.travelPromises?.length);
|
||||
return game;
|
||||
}
|
||||
let source: Game | undefined;
|
||||
const fixture = () => structuredClone(source ??= nativeSource());
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected deal and travel actions preserve the table.');
|
||||
}
|
||||
function offer(game: Game, targets: string[], travel: Term) {
|
||||
const nextGame = act(game, proposer, {type:'dealOffer', targets, terms:'A shared current-turn journey.', travel});
|
||||
const deal = nextGame.deals?.at(-1); assert.ok(deal);
|
||||
return {game:nextGame, id:deal.id};
|
||||
}
|
||||
function respond(game: Game, actor: string, id: string, accept = true) {
|
||||
return act(game, actor, {type:'dealRespond', deal:id, accept});
|
||||
}
|
||||
function status(game: Game, actor: string, id: string) {
|
||||
return viewGame(game, actor).dealTravelStatus[id];
|
||||
}
|
||||
function receipt(game: Game, id: string) {
|
||||
const row = game.travelPromises?.find(promise => promise.sourceDeal === id); assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
void test('a named traveler accepts last and performs the real shipment for a three-party deal', () => {
|
||||
const initial = fixture(), originalCards = inventory(initial);
|
||||
const offered = offer(initial, [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
let game = offered.game;
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
game = respond(game, first, offered.id);
|
||||
assert.equal(game.deals!.at(-1)!.status, 'offered');
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
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).asker, proposer);
|
||||
for (const viewer of [proposer, first, traveler, second]) assert.equal(status(game, viewer, offered.id), 'pending');
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
const completed = settle(act(game, traveler, shipment));
|
||||
assert.equal(player(completed, traveler).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(completed, traveler).reserves, player(initial, traveler).reserves - 1);
|
||||
for (const viewer of [proposer, first, traveler]) assert.equal(status(completed, viewer, offered.id), 'fulfilled');
|
||||
assert.equal(completed.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
|
||||
assert.deepEqual(inventory(completed), originalCards);
|
||||
});
|
||||
|
||||
void test('four-party consent requires all other recipients first and release requires every participant', () => {
|
||||
const offered = offer(fixture(), [traveler, first, second],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
let game = offered.game;
|
||||
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');
|
||||
for (const actor of [proposer, second, first]) {
|
||||
game = act(game, actor, {type:'dealRelease', deal:offered.id, agree:true});
|
||||
assert.equal(status(game, traveler, offered.id), 'pending');
|
||||
}
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
game = act(game, traveler, {type:'dealRelease', deal:offered.id, agree:true});
|
||||
assert.equal(status(game, traveler, offered.id), 'released');
|
||||
assert.equal(receipt(game, offered.id).releasedByDeal, true);
|
||||
game = settle(act(game, traveler, {type:'endMovement'}));
|
||||
assert.equal(status(game, first, offered.id), 'released');
|
||||
});
|
||||
|
||||
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],
|
||||
{actor:traveler, kind:'move', territory:'imperial_basin', minimum:1, answer});
|
||||
let game = respond(offered.game, first, offered.id);
|
||||
game = respond(game, traveler, offered.id);
|
||||
if (answer) {
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
game = settle(act(game, traveler, movement));
|
||||
assert.equal(player(game, traveler).forces['imperial_basin:9'], 1);
|
||||
} else {
|
||||
reject(game, traveler, movement);
|
||||
game = settle(act(game, traveler, {type:'endMovement'}));
|
||||
assert.equal(player(game, traveler).forces['imperial_basin:9'] ?? 0, 0);
|
||||
}
|
||||
assert.equal(status(game, first, offered.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('the traveler may decline early; malformed or uninvited actors and payment combinations reject', () => {
|
||||
const initial = fixture(), term:Term = {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true};
|
||||
for (const bad of [proposer, second, 'unknown'])
|
||||
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:{...term, actor:bad}});
|
||||
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:term,
|
||||
payment:{kind:'immediateBribe', payer:proposer, recipient:traveler, amount:1}});
|
||||
const offered = offer(initial, [first, traveler], term);
|
||||
const declined = respond(offered.game, traveler, offered.id, false);
|
||||
assert.equal(declined.deals!.at(-1)!.status, 'declined');
|
||||
assert.equal(declined.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
});
|
||||
|
||||
void test('an unavailable journey rejects only the final traveler consent and leaves the group offer open', () => {
|
||||
const alreadyShipped = settle(act(fixture(), traveler, shipment));
|
||||
const offered = offer(alreadyShipped, [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
const waiting = respond(offered.game, first, offered.id);
|
||||
assert.equal(waiting.deals!.at(-1)!.status, 'offered');
|
||||
assert.equal(waiting.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
reject(waiting, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
assert.equal(viewGame(waiting, first).deals.at(-1)!.status, 'offered');
|
||||
});
|
||||
|
||||
void test('the legacy two-person packet still binds the recipient without a named actor', () => {
|
||||
const game = fixture();
|
||||
let nextGame = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Two-player travel.',
|
||||
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true}});
|
||||
const deal = nextGame.deals!.at(-1)!;
|
||||
nextGame = respond(nextGame, traveler, deal.id);
|
||||
assert.equal(receipt(nextGame, deal.id).player, traveler);
|
||||
assert.equal(status(nextGame, first, deal.id), 'pending');
|
||||
});
|
||||
|
||||
void test('all minimal autopilot profiles can complete the named group travel obligation', () => {
|
||||
const offered = offer(fixture(), [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
const accepted = respond(respond(offered.game, first, offered.id), traveler, offered.id);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = act(accepted, traveler, {type:'setAutopilot', difficulty});
|
||||
for (let step = 0; step < 8 && status(game, traveler, offered.id) === 'pending'; step++) {
|
||||
const actions = botActions(viewGame(game, traveler));
|
||||
assert.ok(actions.length, `${difficulty} needs a legal native continuation`);
|
||||
game = settle(act(game, traveler, actions[0]));
|
||||
}
|
||||
assert.equal(status(game, traveler, offered.id), 'fulfilled', difficulty);
|
||||
}
|
||||
});
|
||||
@@ -30,13 +30,19 @@ const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState
|
||||
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', () => {
|
||||
void test('the offer packet retains legacy two-party terms and validates an explicitly named group traveler', () => {
|
||||
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.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}),
|
||||
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}});
|
||||
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);
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}), null);
|
||||
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', {...payment,payer:'b',recipient:'a'}, 'a', 2, travel), null);
|
||||
@@ -75,7 +81,7 @@ void test('the combined offer explains payment timing and the recipient can acce
|
||||
});
|
||||
|
||||
/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
|
||||
function combinedForm() {
|
||||
function combinedForm(gameOverride?: GameView) {
|
||||
const states:unknown[] = [], sent:Action[] = [];
|
||||
let cursor = 0;
|
||||
const exported:{PublicDeals?:typeof PublicDeals} = {};
|
||||
@@ -96,7 +102,7 @@ function combinedForm() {
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
}});
|
||||
assert.ok(exported.PublicDeals);
|
||||
const game = {...base, me:'a', deals:[]} as GameView;
|
||||
const game = gameOverride ?? {...base, me:'a', deals:[]} as GameView;
|
||||
const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
|
||||
const check = (index:number) => {
|
||||
const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
|
||||
@@ -156,6 +162,62 @@ void test('the public deal shows the exact recipient, travel intent, turn and au
|
||||
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
|
||||
});
|
||||
|
||||
void test('group offers name the traveler, require their last vote and still permit declining early', () => {
|
||||
const players = [...base.players, {id:'c',name:'Cora',faction:'emperor',ally:null},
|
||||
{id:'d',name:'Dara',faction:'guild',ally:null}];
|
||||
const group = {...deal, participants:['a','b','c','d'], acceptVotes:['a','b'], status:'offered',
|
||||
travel:{...travel,actor:'c'}};
|
||||
const game = {...base, players, deals:[group], me:'c', dealTravelStatus:{}} as unknown as GameView;
|
||||
assert.match(markup(game), /Cora \(Emperor\) would make a reserve shipment/);
|
||||
assert.match(markup(game), /Cora \(Emperor\) must accept last/);
|
||||
const actions:Action[] = [];
|
||||
const early = elements(PublicDealList({game,disabled:false,act(action) {actions.push(action);}}));
|
||||
assert.equal(early.find(node => node.props.children === 'Accept deal')?.props.disabled,true);
|
||||
const decline = early.find(node => node.props.children === 'Decline deal');
|
||||
assert.ok(decline); assert.equal(decline.props.disabled,false);
|
||||
(decline.props.onClick as () => void)();
|
||||
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:false}]);
|
||||
assert.deepEqual(publicDealRequests(game), [['offer','deal','waiting']], 'The traveler can see and decline the offer before other votes are in.');
|
||||
const ordinary = {...game,me:'d'};
|
||||
assert.deepEqual(publicDealRequests(ordinary), [['offer','deal']]);
|
||||
assert.equal(elements(PublicDealList({game:ordinary,disabled:false,act() {}}))
|
||||
.find(node => node.props.children === 'Accept deal')?.props.disabled,false);
|
||||
const final = {...game,deals:[{...group,acceptVotes:['a','b','d']}]} as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(final), [['offer','deal','ready']], 'A changed request reopens the panel for the final vote.');
|
||||
const finalActions:Action[] = [];
|
||||
const accept = elements(PublicDealList({game:final,disabled:false,act(action) {finalActions.push(action);}}))
|
||||
.find(node => node.props.children === 'Accept deal');
|
||||
assert.ok(accept); assert.equal(accept.props.disabled,false);
|
||||
(accept.props.onClick as () => void)();
|
||||
assert.deepEqual(finalActions.at(-1),{type:'dealRespond',deal:'deal',accept:true});
|
||||
const active = {...game, deals:[{...group,status:'active',acceptVotes:['a','b','c','d']}],
|
||||
dealTravelStatus:{deal:'pending'}} as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(active), [['travel','deal']]);
|
||||
assert.deepEqual(publicDealRequests({...active,me:'b'}), []);
|
||||
});
|
||||
|
||||
void test('the group offer form selects an invited traveler and revalidates participant changes', () => {
|
||||
const game = {...base, me:'a', deals:[], players:[...base.players,
|
||||
{id:'c',name:'Cora',faction:'emperor',ally:null}, {id:'d',name:'Dara',faction:'guild',ally:null}]} as unknown as GameView;
|
||||
const ui = combinedForm(game);
|
||||
ui.check(0); ui.check(1); ui.check(4); // Basil, Cora, then travel.
|
||||
const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
|
||||
ui.select(0,'c'); ui.select(3,'carthag');
|
||||
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
|
||||
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Group travel'}});
|
||||
assert.equal(button()?.props.disabled,false);
|
||||
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
|
||||
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',targets:['b','c'],terms:'Group travel',
|
||||
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'c'}});
|
||||
const cora = ui.render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[1];
|
||||
(cora.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:false}});
|
||||
ui.check(2); // Add Dara after removing the chosen traveler.
|
||||
assert.equal(button()?.props.disabled,true);
|
||||
ui.select(0,'d'); assert.equal(button()?.props.disabled,false);
|
||||
ui.check(3); assert.equal(button()?.props.disabled,true, 'Group travel cannot be combined with payment.');
|
||||
});
|
||||
|
||||
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/);
|
||||
@@ -211,9 +273,9 @@ void test('the recipient guide distinguishes a void deal from unanimous release'
|
||||
}
|
||||
});
|
||||
|
||||
void test('the real offer form explains single-recipient eligibility before a travel term is selected', () => {
|
||||
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, /Select exactly one human recipient to add 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,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
||||
publicDealTravelActor,
|
||||
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
@@ -22,8 +23,10 @@ void test('travel terms accept only exact bounded native actions and an explicit
|
||||
{ ...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' },
|
||||
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: '' }, { ...term, actor: undefined },
|
||||
{ ...term, actor: ' c ' },
|
||||
]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError);
|
||||
assert.deepEqual(parsePublicDealTravel({...term, actor:'c'}), {...term, actor:'c'});
|
||||
});
|
||||
|
||||
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
|
||||
@@ -34,6 +37,7 @@ void test('a recipient accepts and both release an unchanged current-turn travel
|
||||
assert.equal(source.travel, undefined);
|
||||
assert.deepEqual(offered.travel, term);
|
||||
assert.equal(offered.turn, 3);
|
||||
assert.equal(publicDealTravelActor(offered), 'b');
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
@@ -57,7 +61,7 @@ void test('a recipient accepts and both release an unchanged current-turn travel
|
||||
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
|
||||
});
|
||||
|
||||
void test('travel is incompatible with multi-party, replacement and malformed persisted terms', () => {
|
||||
void test('travel requires an actor for multiparty offers and cannot replace an existing term', () => {
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
|
||||
const offered = setPublicDealTravel(fresh(), term, seats);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
@@ -65,11 +69,62 @@ void test('travel is incompatible with multi-party, replacement and malformed pe
|
||||
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
|
||||
for (const invalid of [
|
||||
{ ...multi, travel: term },
|
||||
{ ...fresh(), travel: {...term, actor:'b'} },
|
||||
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
|
||||
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
|
||||
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
|
||||
});
|
||||
|
||||
void test('multiparty travel requires the committed recipient to accept last and all parties to release', () => {
|
||||
const people: PublicDealSeat[] = ['a','b','c','d','e','f'].map(id => ({id}));
|
||||
for (const actor of ['b','c','d','e','f']) {
|
||||
const multi = createMultipartyPublicDeal(people, 'a', ['b','c','d','e','f'], 'Travel together.', 3, `multi-${actor}`);
|
||||
const offered = setPublicDealTravel(multi, {...term,actor}, people);
|
||||
assert.deepEqual(offered.travel, {...term,actor});
|
||||
assert.equal(publicDealTravelActor(offered), actor);
|
||||
assert.equal(multi.travel, undefined);
|
||||
assert.throws(() => respondPublicDeal(offered, people, actor, true), PublicDealError);
|
||||
assert.equal(respondPublicDeal(offered, people, actor, false).status, 'declined');
|
||||
assert.equal(withdrawPublicDeal(offered, people, 'a').status, 'withdrawn');
|
||||
let current = offered;
|
||||
// Deliberately vote in reverse roster order; serialization stays canonical.
|
||||
for (const voter of ['f','e','d','c','b'].filter(id => id !== actor)) {
|
||||
current = respondPublicDeal(current, people, voter, true);
|
||||
assert.equal(current.status, 'offered');
|
||||
if (current.acceptVotes!.length < people.length - 1)
|
||||
assert.throws(() => respondPublicDeal(current, people, actor, true), PublicDealError);
|
||||
}
|
||||
const last = respondPublicDeal(current, people, actor, true);
|
||||
assert.equal(last.status, 'active');
|
||||
assert.deepEqual(last.acceptVotes, ['a','b','c','d','e','f']);
|
||||
let released = last;
|
||||
for (const voter of ['f','e','d','c','b','a']) released = releasePublicDeal(released, people, voter, true);
|
||||
assert.equal(released.status, 'released');
|
||||
for (const snapshot of [offered,current,last,released]) {
|
||||
const copy = JSON.parse(JSON.stringify(snapshot)) as PublicDeal;
|
||||
validatePublicDeals([copy], people);
|
||||
assert.deepEqual(copy.travel, {...term,actor});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('multiparty travel rejects forged actors, early votes, payments and nonfresh offers', () => {
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Terms', 3, 'multi');
|
||||
for (const actor of ['a','d','',undefined]) {
|
||||
const invalid = {...term, actor};
|
||||
assert.throws(() => setPublicDealTravel(multi, invalid, seats), PublicDealError);
|
||||
assert.throws(() => validatePublicDeals([{...multi, travel:invalid}], seats), PublicDealError);
|
||||
}
|
||||
const offered = setPublicDealTravel(multi, {...term,actor:'b'}, seats);
|
||||
for (const status of ['offered','withdrawn','declined'] as const) {
|
||||
assert.throws(() => validatePublicDeals([{...offered,status,acceptVotes:['a','b']}], seats), PublicDealError);
|
||||
}
|
||||
const acceptedOther = respondPublicDeal(offered, seats, 'c', true);
|
||||
assert.throws(() => setPublicDealTravel(respondPublicDeal(multi, seats, 'c', true), {...term,actor:'b'}, seats), PublicDealError);
|
||||
assert.throws(() => validatePublicDeals([{...offered,payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1}}], seats), PublicDealError);
|
||||
assert.equal(respondPublicDeal(acceptedOther, seats, 'b', true).status, 'active');
|
||||
});
|
||||
|
||||
void test('a proposer-to-recipient immediate payment can accompany travel through the complete deal lifecycle', () => {
|
||||
const payment = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 3 } as const;
|
||||
const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
|
||||
|
||||
Reference in new issue
Block a user