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 { useEffect, useRef, useState } from 'react';
|
||||||
import { faction } from '@/game/catalog';
|
import { faction } from '@/game/catalog';
|
||||||
import { TERRITORIES } from '@/game/board';
|
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 { areCoalitionAllies } from '@/game/coalition-alliance';
|
||||||
import type { Action, GameView } from '@/game/engine';
|
import type { Action, GameView } from '@/game/engine';
|
||||||
import { Button } from '@/components/ui/button';
|
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 ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number };
|
||||||
type DeferredBribe = { kind: 'deferredBribe'; payer: string; recipient: string; amount: number; dueTurn: number };
|
type DeferredBribe = { kind: 'deferredBribe'; payer: string; recipient: string; amount: number; dueTurn: number };
|
||||||
type DealPayment = ImmediateBribe | DeferredBribe;
|
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 DealTravelStatus = 'pending' | 'fulfilled' | 'void' | 'released';
|
||||||
type TravelDeal = Deal & { travel?: DealTravel };
|
type TravelDeal = Deal & { travel?: DealTravel };
|
||||||
type TravelProjection = GameView & { dealTravelStatus?: Record<string, DealTravelStatus> };
|
type TravelProjection = GameView & { dealTravelStatus?: Record<string, DealTravelStatus> };
|
||||||
@@ -34,17 +34,22 @@ export function publicDealRequests(game: GameView): string[][] {
|
|||||||
if (game.status !== 'playing') return [];
|
if (game.status !== 'playing') return [];
|
||||||
return game.deals.flatMap(deal => {
|
return game.deals.flatMap(deal => {
|
||||||
const requests: string[][] = [];
|
const requests: string[][] = [];
|
||||||
const participant = publicDealParticipants(deal).includes(game.me);
|
const parties = publicDealParticipants(deal), accepted = publicDealAcceptVotes(deal);
|
||||||
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
|
const participant = parties.includes(game.me);
|
||||||
requests.push(['offer', deal.id]);
|
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)
|
if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0)
|
||||||
requests.push(['release', deal.id, ...deal.releaseVotes]);
|
requests.push(['release', deal.id, ...deal.releaseVotes]);
|
||||||
const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus };
|
const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus };
|
||||||
if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' &&
|
if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' &&
|
||||||
pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn)
|
pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn)
|
||||||
requests.push(['payment', deal.id]);
|
requests.push(['payment', deal.id]);
|
||||||
const travel = (deal as TravelDeal).travel;
|
if (deal.status === 'active' && travel && publicDealTravelActor(deal) === game.me &&
|
||||||
if (deal.status === 'active' && travel && deal.to === game.me &&
|
|
||||||
(game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending')
|
(game as TravelProjection).dealTravelStatus?.[deal.id] === 'pending')
|
||||||
requests.push(['travel', deal.id]);
|
requests.push(['travel', deal.id]);
|
||||||
return requests;
|
return requests;
|
||||||
@@ -60,9 +65,12 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
|
|||||||
!Number.isSafeInteger(payment.amount) || payment.amount < 1 ||
|
!Number.isSafeInteger(payment.amount) || payment.amount < 1 ||
|
||||||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 ||
|
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 ||
|
||||||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null;
|
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null;
|
||||||
if (travel && (targets.length !== 1 || !proposer || targets[0] === proposer ||
|
const groupTravel = !!travel && targets.length > 1;
|
||||||
!!payment && (payment.kind !== 'immediateBribe' || payment.payer !== proposer || payment.recipient !== targets[0]) ||
|
if (travel && (!proposer || targets.includes(proposer) ||
|
||||||
Object.keys(travel).sort().join(',') !== 'answer,kind,minimum,territory' ||
|
(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) ||
|
!['ship', 'move'].includes(travel.kind) || !TERRITORIES.some(territory => territory.id === travel.territory) ||
|
||||||
!Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 ||
|
!Number.isSafeInteger(travel.minimum) || travel.minimum < 1 || travel.minimum > 20 ||
|
||||||
typeof travel.answer !== 'boolean')) return null;
|
typeof travel.answer !== 'boolean')) return null;
|
||||||
@@ -70,7 +78,7 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
|
|||||||
const offeredTravel = travel ? { travel } : {};
|
const offeredTravel = travel ? { travel } : {};
|
||||||
return targets.length === 1
|
return targets.length === 1
|
||||||
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment, ...offeredTravel }
|
? { 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. */
|
/** 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 payment = (deal as unknown as { payment?: DealPayment }).payment;
|
||||||
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
|
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
|
||||||
const travel = (deal as TravelDeal).travel;
|
const travel = (deal as TravelDeal).travel;
|
||||||
|
const travelActor = travel ? publicDealTravelActor(deal) : undefined;
|
||||||
const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id];
|
const travelStatus = (game as TravelProjection).dealTravelStatus?.[deal.id];
|
||||||
const travelExpired = !!travel && deal.turn !== game.turn;
|
const travelExpired = !!travel && deal.turn !== game.turn;
|
||||||
const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name;
|
const travelDestination = travel && TERRITORIES.find(territory => territory.id === travel.territory)?.name;
|
||||||
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
|
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 paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null;
|
||||||
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn;
|
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn;
|
||||||
const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id];
|
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' && <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 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>}
|
{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>
|
{deal.status === 'offered' && <p>
|
||||||
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||||
</p>}
|
</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.
|
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
|
||||||
</p>}
|
</p>}
|
||||||
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
|
{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>
|
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
|
{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 [travelTerritory, setTravelTerritory] = useState('');
|
||||||
const [travelMinimum, setTravelMinimum] = useState('1');
|
const [travelMinimum, setTravelMinimum] = useState('1');
|
||||||
const [travelAnswer, setTravelAnswer] = useState(true);
|
const [travelAnswer, setTravelAnswer] = useState(true);
|
||||||
|
const [travelActor, setTravelActor] = useState('');
|
||||||
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
|
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
|
||||||
const [payer, setPayer] = useState('');
|
const [payer, setPayer] = useState('');
|
||||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||||
@@ -195,7 +208,8 @@ export function PublicDeals({ game, act, busy }: {
|
|||||||
Number.isSafeInteger(amount) && amount > 0;
|
Number.isSafeInteger(amount) && amount > 0;
|
||||||
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
|
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
|
||||||
const travel: DealTravel | undefined = includeTravel ? {kind:travelKind, territory:travelTerritory,
|
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' &&
|
const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' &&
|
||||||
payment.payer === me?.id && selected.length === 1 && payment.recipient === selected[0];
|
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 ? 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 checked = selected.includes(player.id);
|
||||||
const conflicts = !!me && (allied(game, me, player) || selectedPlayers.some(other => other.id !== player.id && allied(game, other, player)));
|
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">
|
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 => {
|
onChange={event => {
|
||||||
const nextChecked = event.target.checked;
|
const nextChecked = event.target.checked;
|
||||||
|
if (!nextChecked && travelActor === player.id) setTravelActor('');
|
||||||
setSelected(current => nextChecked
|
setSelected(current => nextChecked
|
||||||
? current.includes(player.id) ? current : [...current, player.id] : current.filter(id => id !== player.id));
|
? 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>}
|
{paymentKind === 'deferredBribe' && !dueValid && <p role="alert">Choose a whole due turn no earlier than turn {game.turn}.</p>}
|
||||||
</fieldset>}
|
</fieldset>}
|
||||||
<label className="checkbox-row">
|
<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)} />
|
onChange={event => setIncludeTravel(event.target.checked)} />
|
||||||
Include a current-turn travel term
|
Include a current-turn travel term
|
||||||
</label>
|
</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>
|
{includeTravel && <fieldset>
|
||||||
<legend>Current-turn travel term</legend>
|
<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">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 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>
|
<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 a travel term, choose an immediate payment from the proposer to the selected recipient, or remove the 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>
|
<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
|
<label>Travel action
|
||||||
<select value={travelKind} disabled={disabled} onChange={event => setTravelKind(event.target.value === 'move' ? 'move' : 'ship')}>
|
<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>
|
<Button type="submit" disabled={disabled || !offer}>Offer public deal</Button>
|
||||||
{targets.length === 0 && <p>No human recipient is available.</p>}
|
{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 && !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>}
|
</form>}
|
||||||
<h3>Deal history</h3>
|
<h3>Deal history</h3>
|
||||||
<PublicDealList game={game} act={act} disabled={disabled} />
|
<PublicDealList game={game} act={act} disabled={disabled} />
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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.**
|
**Basic Richese reveals during responses — 29 October. Prototyped / Partial.**
|
||||||
The owner can reveal an existing No-Field during clean CHOAM Charity,
|
The owner can reveal an existing No-Field during clean CHOAM Charity,
|
||||||
Atreides normal-auction inspection, Richese cache-auction declaration and
|
Atreides normal-auction inspection, Richese cache-auction declaration and
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
## Structured current-turn travel — 29 October 2026
|
## Structured current-turn travel — 29 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** In a quiet Basic classic table, a two-human offer
|
**Prototyped / Partial.** In a quiet Basic classic table, an offer between two
|
||||||
can ask its recipient to make or avoid one native reserve shipment or ground
|
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
|
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
|
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
|
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
|
compatible travel deals. Earlier actions and sums of smaller moves do not
|
||||||
count. The existing native travel planner enforces the accepted term and
|
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
|
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
|
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
|
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,
|
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
|
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, 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.
|
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)
|
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
|
# 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
|
## Richese reveal during selected Basic responses — 29 October 2026
|
||||||
|
|
||||||
The publisher CHOAM & Richese rules, printed p. 6, allow Richese to reveal
|
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,
|
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
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 { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
|
||||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
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 deal = g.deals?.find(entry => entry.id === row.sourceDeal);
|
||||||
const term = deal?.travel;
|
const term = deal?.travel;
|
||||||
requireRule(deal && term && ['active', 'released'].includes(deal.status) &&
|
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.answer === term.answer && row.mixed.kind === 'action' &&
|
||||||
row.mixed.action.kind === term.kind && row.mixed.action.territory === term.territory &&
|
row.mixed.action.kind === term.kind && row.mixed.action.territory === term.territory &&
|
||||||
row.mixed.action.minimum === term.minimum && row.mixed.action.sector === undefined &&
|
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
|
/** A deal is a separately consented public source for the same physical
|
||||||
* travel constraint, not a Truthtrance question or a fabricated card play. */
|
* travel constraint, not a Truthtrance question or a fabricated card play. */
|
||||||
function bindPublicDealTravel(g: Game, deal: PublicDeal) {
|
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 &&
|
requireRule(term && deal.turn === g.turn && !g.advanced && g.expansions.length === 0 &&
|
||||||
nativePromiseScopeSupported(g) && travelTargetAvailable(g, player.id) && !g.truthtrance &&
|
nativePromiseScopeSupported(g) && travelTargetAvailable(g, player.id) && !g.truthtrance &&
|
||||||
!hasBribePromises(g, player.id) &&
|
!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 keys = Object.keys(action).sort().join(',');
|
||||||
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
|
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';
|
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');
|
action.targets.every(target => typeof target === 'string');
|
||||||
const hasPayment = Object.hasOwn(action, 'payment');
|
const hasPayment = Object.hasOwn(action, 'payment');
|
||||||
requireRule((single || multiple) && typeof action.terms === 'string' &&
|
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, payment!)
|
||||||
: createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event));
|
: createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event));
|
||||||
if (Object.hasOwn(action, 'travel')) {
|
if (Object.hasOwn(action, 'travel')) {
|
||||||
requireRule(single && !g.advanced && g.expansions.length === 0 &&
|
requireRule(!g.advanced && g.expansions.length === 0 && nativePromiseScopeSupported(g),
|
||||||
nativePromiseScopeSupported(g), 'Choose a two-player travel agreement in a supported Basic table.');
|
'Choose a travel agreement in a supported Basic table.');
|
||||||
offer = publicDealRule(() => setPublicDealTravel(offer, action.travel, seats));
|
offer = publicDealRule(() => setPublicDealTravel(offer, action.travel, seats));
|
||||||
}
|
}
|
||||||
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
|
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.');
|
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
|
||||||
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
|
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
|
||||||
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
|
} 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);
|
bindPublicDealTravel(g, next);
|
||||||
|
}
|
||||||
if (current.status === 'active' && next.status === 'released' && next.travel) {
|
if (current.status === 'active' && next.status === 'released' && next.travel) {
|
||||||
const receipt = g.travelPromises?.find(row => row.sourceDeal === next.id);
|
const receipt = g.travelPromises?.find(row => row.sourceDeal === next.id);
|
||||||
requireRule(receipt, 'The public travel deal lost its accepted commitment.');
|
requireRule(receipt, 'The public travel deal lost its accepted commitment.');
|
||||||
|
|||||||
+36
-15
@@ -15,12 +15,15 @@ export type PublicDealPayment = {
|
|||||||
dueTurn: number;
|
dueTurn: number;
|
||||||
};
|
};
|
||||||
export type PublicDealPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
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 = {
|
export type PublicDealTravel = {
|
||||||
kind: 'ship' | 'move';
|
kind: 'ship' | 'move';
|
||||||
territory: string;
|
territory: string;
|
||||||
minimum: number;
|
minimum: number;
|
||||||
answer: boolean;
|
answer: boolean;
|
||||||
|
/** Present only in a multi-party term; the selected recipient accepts last. */
|
||||||
|
actor?: string;
|
||||||
};
|
};
|
||||||
export type PublicDeal = {
|
export type PublicDeal = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -86,6 +89,10 @@ export function publicDealAcceptVotes(deal: PublicDeal): string[] {
|
|||||||
return deal.status === 'active' || deal.status === 'released'
|
return deal.status === 'active' || deal.status === 'released'
|
||||||
? [deal.from, deal.to] : [deal.from];
|
? [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 {
|
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
|
||||||
return new Set(votes).size === votes.length &&
|
return new Set(votes).size === votes.length &&
|
||||||
votes.every(vote => parties.includes(vote)) &&
|
votes.every(vote => parties.includes(vote)) &&
|
||||||
@@ -112,13 +119,16 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
|||||||
amount: value.amount, dueTurn: value.dueTurn };
|
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 {
|
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.');
|
typeof value.answer === 'boolean', 'Choose an exact shipment or movement term and require or avoid it.');
|
||||||
try {
|
try {
|
||||||
const claim = parseTravelActionClaim({ kind: value.kind, territory: value.territory, minimum: value.minimum });
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -164,10 +174,17 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
|||||||
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
||||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||||
if (Object.hasOwn(deal, 'travel')) {
|
if (Object.hasOwn(deal, 'travel')) {
|
||||||
requireDeal(!multi && !Object.hasOwn(deal, 'paymentStatus') &&
|
const travel = parsePublicDealTravel(deal.travel);
|
||||||
(!payment || payment.kind === 'immediateBribe' && payment.payer === deal.from && payment.recipient === deal.to),
|
requireDeal(!Object.hasOwn(deal, 'paymentStatus') && (multi
|
||||||
'A travel term requires a two-party deal with no payment or an immediate bribe from the proposer to the recipient.');
|
? !!travel.actor && travel.actor !== deal.from && parties.includes(travel.actor) && !payment
|
||||||
parsePublicDealTravel(deal.travel);
|
: !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') {
|
if (payment?.kind === 'deferredBribe') {
|
||||||
const status = deal.paymentStatus;
|
const status = deal.paymentStatus;
|
||||||
@@ -218,15 +235,16 @@ export function createMultipartyPublicDeal(
|
|||||||
...paymentTerm };
|
...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 {
|
export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: readonly PublicDealSeat[]): PublicDeal {
|
||||||
validatePublicDeals([deal], players);
|
validatePublicDeals([deal], players);
|
||||||
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'participants') &&
|
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'travel') &&
|
||||||
!Object.hasOwn(deal, 'acceptVotes') && !Object.hasOwn(deal, 'travel') &&
|
!Object.hasOwn(deal, 'paymentStatus') && (deal.participants
|
||||||
!Object.hasOwn(deal, 'paymentStatus') && (!deal.payment ||
|
? !!deal.acceptVotes && deal.acceptVotes.length === 1 && deal.acceptVotes[0] === deal.from && !deal.payment
|
||||||
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to),
|
: !Object.hasOwn(deal, 'acceptVotes') && (!deal.payment ||
|
||||||
'Only a fresh two-party offer with no payment or an immediate proposer-to-recipient bribe can add a travel term.');
|
deal.payment.kind === 'immediateBribe' && deal.payment.payer === deal.from && deal.payment.recipient === deal.to)),
|
||||||
notAllied(participants(players, [deal.from, 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) };
|
const result = { ...deal, travel: parsePublicDealTravel(value) };
|
||||||
validatePublicDeals([result], players);
|
validatePublicDeals([result], players);
|
||||||
return result;
|
return result;
|
||||||
@@ -243,6 +261,9 @@ export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDeal
|
|||||||
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
||||||
const alreadyAccepted = publicDealAcceptVotes(deal);
|
const alreadyAccepted = publicDealAcceptVotes(deal);
|
||||||
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
|
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 accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
|
||||||
const active = accepts.length === parties.length;
|
const active = accepts.length === parties.length;
|
||||||
return { ...deal, acceptVotes: accepts, status: active ? 'active' : 'offered', releaseVotes: [],
|
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.',
|
'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 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.',
|
'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.',
|
'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.',
|
'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: '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: '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: '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) =>
|
const markup = (game:GameView, disabled = false) =>
|
||||||
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
|
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),
|
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
|
||||||
{type:'dealOffer', target:'b', terms:'Terms', travel});
|
{type:'dealOffer', target:'b', terms:'Terms', travel});
|
||||||
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
|
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
|
||||||
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
|
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
|
||||||
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
|
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
|
||||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
|
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),
|
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
|
||||||
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
|
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
|
||||||
assert.equal(publicDealOfferAction(['b'], 'Terms', {...payment,payer:'b',recipient:'a'}, 'a', 2, travel), null);
|
assert.equal(publicDealOfferAction(['b'], 'Terms', {...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. */
|
/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
|
||||||
function combinedForm() {
|
function combinedForm(gameOverride?: GameView) {
|
||||||
const states:unknown[] = [], sent:Action[] = [];
|
const states:unknown[] = [], sent:Action[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
const exported:{PublicDeals?:typeof PublicDeals} = {};
|
const exported:{PublicDeals?:typeof PublicDeals} = {};
|
||||||
@@ -96,7 +102,7 @@ function combinedForm() {
|
|||||||
throw new Error(`Unexpected import ${name}`);
|
throw new Error(`Unexpected import ${name}`);
|
||||||
}});
|
}});
|
||||||
assert.ok(exported.PublicDeals);
|
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 render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
|
||||||
const check = (index:number) => {
|
const check = (index:number) => {
|
||||||
const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
|
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}`));
|
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', () => {
|
void test('only the obligated recipient gets a persistent pending reminder', () => {
|
||||||
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
|
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
|
||||||
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
|
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}));
|
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
|
||||||
assert.match(html, /Include a current-turn travel term/);
|
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/);
|
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 test from 'node:test';
|
||||||
import {
|
import {
|
||||||
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
||||||
|
publicDealTravelActor,
|
||||||
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
||||||
type PublicDeal, type PublicDealSeat,
|
type PublicDeal, type PublicDealSeat,
|
||||||
} from '../game/public-deals';
|
} 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' },
|
{ ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' },
|
||||||
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
|
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
|
||||||
{ ...term, answer: 'yes' }, { ...term, answer: undefined },
|
{ ...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.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', () => {
|
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.equal(source.travel, undefined);
|
||||||
assert.deepEqual(offered.travel, term);
|
assert.deepEqual(offered.travel, term);
|
||||||
assert.equal(offered.turn, 3);
|
assert.equal(offered.turn, 3);
|
||||||
|
assert.equal(publicDealTravelActor(offered), 'b');
|
||||||
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
|
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
|
||||||
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
|
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
|
||||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
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);
|
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 multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
|
||||||
const offered = setPublicDealTravel(fresh(), term, seats);
|
const offered = setPublicDealTravel(fresh(), term, seats);
|
||||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
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);
|
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
|
||||||
for (const invalid of [
|
for (const invalid of [
|
||||||
{ ...multi, travel: term },
|
{ ...multi, travel: term },
|
||||||
|
{ ...fresh(), travel: {...term, actor:'b'} },
|
||||||
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
|
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
|
||||||
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
|
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
|
||||||
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
|
]) 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', () => {
|
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 payment = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 3 } as const;
|
||||||
const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
|
const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
|
||||||
|
|||||||
Reference in new issue
Block a user