Support current-turn payments in public deals
This commit is contained in:
1 parent
974b7fd99f
commit
bb193d8f8c
19 files changed
+1202
-43
No files matched your search
+67
-22
@@ -8,14 +8,40 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
type Deal = GameView['deals'][number];
|
||||
type ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number };
|
||||
type DeferredBribe = { kind: 'deferredBribe'; payer: string; recipient: string; amount: number; dueTurn: number };
|
||||
type DealPayment = ImmediateBribe | DeferredBribe;
|
||||
type DeferredPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
||||
type DeferredPaymentProjection = GameView & {
|
||||
deferredDealPaymentOptions?: Record<string, { blocked: string | null }>;
|
||||
};
|
||||
|
||||
/** Requests are local to the active seat; an owed payment is as important to
|
||||
* surface as a pending invitation or a request to release an agreement. */
|
||||
export function publicDealRequests(game: GameView): string[][] {
|
||||
if (game.status !== 'playing') return [];
|
||||
return game.deals.flatMap(deal => {
|
||||
const requests: string[][] = [];
|
||||
const participant = publicDealParticipants(deal).includes(game.me);
|
||||
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
|
||||
requests.push(['offer', deal.id]);
|
||||
if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0)
|
||||
requests.push(['release', deal.id, ...deal.releaseVotes]);
|
||||
const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus };
|
||||
if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' &&
|
||||
pending.payment.payer === game.me && pending.paymentStatus === 'pending')
|
||||
requests.push(['payment', deal.id]);
|
||||
return requests;
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep the legacy single-recipient wire packet when only one seat is chosen. */
|
||||
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: ImmediateBribe, proposer?: string): Action | null {
|
||||
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string): Action | null {
|
||||
const text = terms.trim();
|
||||
if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null;
|
||||
if (payment && (!proposer || payment.kind !== 'immediateBribe' || payment.payer === payment.recipient ||
|
||||
if (payment && (!proposer || (payment.kind !== 'immediateBribe' && payment.kind !== 'deferredBribe') || payment.payer === payment.recipient ||
|
||||
![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) ||
|
||||
!Number.isSafeInteger(payment.amount) || payment.amount < 1)) return null;
|
||||
!Number.isSafeInteger(payment.amount) || payment.amount < 1 ||
|
||||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1)))) return null;
|
||||
const offeredPayment = payment ? { payment } : {};
|
||||
return targets.length === 1
|
||||
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment }
|
||||
@@ -45,23 +71,30 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
const seats = participants.map(id => game.players.find(player => player.id === id));
|
||||
const eligible = seats.every((player, index) => player && seats.slice(index + 1).every(other =>
|
||||
other && player.ally !== other.id && other.ally !== player.id));
|
||||
const payment = (deal as Deal & { payment?: ImmediateBribe }).payment;
|
||||
const payment = (deal as unknown as { payment?: DealPayment }).payment;
|
||||
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
|
||||
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
|
||||
const paymentBlocked = payment && 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 deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id];
|
||||
const mayPay = payment?.kind === 'deferredBribe' && deal.status === 'active' && paymentStatus === 'pending' &&
|
||||
payment.payer === game.me && canAct;
|
||||
return <li key={deal.id}>
|
||||
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
|
||||
<p>{deal.terms}</p>
|
||||
{payment && <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 === 'deferredBribe' && <p><strong>Deferred payment term:</strong> {name(payment.payer)} pays exactly {payment.amount} spice to {name(payment.recipient)} by 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>}
|
||||
{deal.status === 'offered' && <p>
|
||||
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||
</p>}
|
||||
{deal.status === 'offered' && !eligible && <p>Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.</p>}
|
||||
{deal.status === 'offered' && paymentBlocked && <p>Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.</p>}
|
||||
{deal.status === 'offered' && paymentExpired && <p>The payment’s due turn has passed. The offer may still be declined or withdrawn.</p>}
|
||||
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
|
||||
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
|
||||
</p>}
|
||||
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
|
||||
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
|
||||
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked || !!paymentExpired} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
|
||||
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
|
||||
</div>}
|
||||
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
|
||||
@@ -70,6 +103,11 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
onClick={() => act({ type: 'dealRelease', deal: deal.id, agree: !voted })}>
|
||||
{voted ? 'Withdraw release request' : deal.releaseVotes.length > 0 ? 'Agree to release deal' : 'Request release of deal'}
|
||||
</Button>}
|
||||
{mayPay && <div>
|
||||
{deferredOption?.blocked && <p>Payment is unavailable: {deferredOption.blocked}</p>}
|
||||
<Button type="button" disabled={disabled || !deferredOption || !!deferredOption.blocked}
|
||||
onClick={() => act({ type: 'dealPay', deal: deal.id })}>Pay agreed amount</Button>
|
||||
</div>}
|
||||
</li>;
|
||||
})}
|
||||
</ol>;
|
||||
@@ -83,18 +121,13 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [terms, setTerms] = useState('');
|
||||
const [includePayment, setIncludePayment] = useState(false);
|
||||
const [paymentKind, setPaymentKind] = useState<'immediateBribe' | 'deferredBribe'>('immediateBribe');
|
||||
const [payer, setPayer] = useState('');
|
||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||
const [paymentAmount, setPaymentAmount] = useState('1');
|
||||
const requests = game.status === 'playing' ? game.deals.flatMap(deal => {
|
||||
const participant = publicDealParticipants(deal).includes(game.me);
|
||||
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
|
||||
return [['offer', deal.id]];
|
||||
if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0)
|
||||
return [['release', deal.id, ...deal.releaseVotes]];
|
||||
return [];
|
||||
}) : [];
|
||||
const requests = publicDealRequests(game);
|
||||
const needsResponse = requests.length > 0;
|
||||
const paymentDue = requests.some(request => request[0] === 'payment');
|
||||
const requestKey = JSON.stringify(requests);
|
||||
const panel = useRef<HTMLDetailsElement>(null);
|
||||
useEffect(() => { if (requestKey !== '[]' && panel.current) panel.current.open = true; }, [requestKey]);
|
||||
@@ -109,8 +142,11 @@ export function PublicDeals({ game, act, busy }: {
|
||||
selectedPlayers.slice(index + 1).every(other => !allied(player, other)));
|
||||
const parties = me ? [me, ...selectedPlayers] : [];
|
||||
const amount = Number(paymentAmount);
|
||||
const payment: ImmediateBribe | undefined = includePayment
|
||||
? { kind: 'immediateBribe', payer, recipient: paymentRecipient, amount } : undefined;
|
||||
const payment: DealPayment | undefined = includePayment
|
||||
? paymentKind === 'immediateBribe'
|
||||
? { kind: paymentKind, payer, recipient: paymentRecipient, amount }
|
||||
: { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn: game.turn }
|
||||
: undefined;
|
||||
const paymentValid = !includePayment || !!me && payer !== paymentRecipient &&
|
||||
parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) &&
|
||||
Number.isSafeInteger(amount) && amount > 0;
|
||||
@@ -118,9 +154,9 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
|
||||
const mayOffer = game.status === 'playing' && me && !me.bot;
|
||||
return <details ref={panel} className="notice public-deals-panel">
|
||||
<summary>Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
|
||||
<summary>Public deals{paymentDue ? ' · payment due' : needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
|
||||
<p>Deals are public. Every recipient must accept an offer. Players must honor valid terms; an impossible part is void, and all parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.</p>
|
||||
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional immediate payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
|
||||
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
|
||||
{mayOffer && <form onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (!disabled && offer) act(offer);
|
||||
@@ -144,10 +180,17 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={includePayment} disabled={disabled}
|
||||
onChange={event => setIncludePayment(event.target.checked)} />
|
||||
Include an immediate spice payment
|
||||
Include an immediate spice payment or a payment due this turn
|
||||
</label>
|
||||
{includePayment && <fieldset>
|
||||
<legend>Payment on final acceptance</legend>
|
||||
<legend>Payment term</legend>
|
||||
<label>Payment timing
|
||||
<select value={paymentKind} disabled={disabled} onChange={event =>
|
||||
setPaymentKind(event.target.value === 'deferredBribe' ? 'deferredBribe' : 'immediateBribe')}>
|
||||
<option value="immediateBribe">Immediately on final acceptance</option>
|
||||
<option value="deferredBribe">By the end of turn {game.turn}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Paying participant
|
||||
<select value={parties.some(player => player.id === payer) ? payer : ''} disabled={disabled}
|
||||
onChange={event => setPayer(event.target.value)}>
|
||||
@@ -166,7 +209,9 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<input type="number" inputMode="numeric" min={1} step={1} value={paymentAmount} disabled={disabled}
|
||||
onChange={event => setPaymentAmount(event.target.value)} />
|
||||
</label>
|
||||
<p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p>
|
||||
{paymentKind === 'immediateBribe'
|
||||
? <p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p>
|
||||
: <p className="fine">The agreed amount becomes due when all participants accept. The payer must pay it by the end of turn {game.turn} while payment remains possible. A separate pay control appears on the active deal; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank.</p>}
|
||||
{legalSelection && !paymentValid && <p>Choose two different participants and a positive whole spice amount.</p>}
|
||||
</fieldset>}
|
||||
<label>Public deal terms
|
||||
|
||||
@@ -16,6 +16,18 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Structured public-deal payment due this turn — 28 October. Prototyped /
|
||||
Partial.** A public agreement can bind one exact current-turn bribe after
|
||||
unanimous consent. The payer sees a persistent request and pays each deal
|
||||
separately through the native bribe; release cancels an unpaid term without
|
||||
refunding a paid one. A bounded forecast protects supported obligations and
|
||||
allows real Charity and already-earned Mentat income. Twenty-one related
|
||||
offline files pass 129 cases, including genuine multi-party games, multiple
|
||||
debts, Truthtrance, Charity, autopilot and selected conserved staging. One
|
||||
isolated authenticated HTTP case covers consent, payer authority, escrow and
|
||||
replay. Complete deal interpretation, impossibility, other combinations,
|
||||
browser and deployed acceptance remain open. [Scope](PUBLIC_DEALS.md#structured-payment-due-this-turn--28-october-2026).
|
||||
|
||||
**Basic phase-wide bids through Ixian/Richese preparation — 28 October.
|
||||
Prototyped / Partial.** Existing ordinary-bid questions can survive real Ixian
|
||||
inspection or cancellation and Richese cache-first/last preparation, including
|
||||
|
||||
+44
-1
@@ -1,5 +1,48 @@
|
||||
# Public deal records
|
||||
|
||||
## Structured payment due this turn — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A public offer may name a payer, recipient and exact
|
||||
gross bribe due by the end of its current turn. Everyone sees the immutable
|
||||
term and every signatory must accept; final acceptance creates a pending
|
||||
obligation without transferring spice. The payer's persistent deal panel
|
||||
offers a dedicated payment action. That action settles exactly one agreement
|
||||
through the normal bribe path, including the deferred Mentat receipt and any
|
||||
Bureaucrat diversion. An unrelated bribe does not silently settle the deal.
|
||||
Two independent agreements require two transfers, even to the same recipient.
|
||||
|
||||
The acceptance and subsequent voluntary-action quote considers the payer's
|
||||
actual uncommitted funds, supported existing commitments, possible native
|
||||
Charity/CHOAM income and already-earned incoming bribes. Exact payments may
|
||||
occur on either side of an eligible Charity claim. The bounded planner tries
|
||||
whole-payment orders and up to 2,048 candidate states; unavailable or more
|
||||
complex combinations can be rejected rather than treated as proven impossible.
|
||||
The quote never creates funds. A binding deal and a compatible Truthtrance
|
||||
minimum can both be satisfied by the same actual deal payment, but independent
|
||||
deals cannot share it. A payer may not voluntarily spend or close the payment
|
||||
window while an achievable obligation remains.
|
||||
|
||||
Unanimous release cancels the unpaid term and does not refund a completed
|
||||
payment. A temporary decision, funding shortage or alliance does not by itself
|
||||
void the deal. In a quiet, supported Basic Mentat, a term individually larger
|
||||
than the payer's remaining funds is void; after an external shortfall leaving
|
||||
several individually payable terms, the payer can choose which to settle.
|
||||
Unpaid terms also become void when their turn actually ends or the game ends.
|
||||
The selection/feasibility policy is provisional. It does not yet decide all
|
||||
impossibility, optional-module or cross-turn cases, interpret free text, or
|
||||
enable AI negotiation. An already-offered immediate payment remains unreserved;
|
||||
its final acceptance can fail if it would undercut an active obligation.
|
||||
|
||||
The publisher's rulebook permits legal public deals and voids impossible parts;
|
||||
this explicit receipt and deadline are an online representation, not a new
|
||||
printed restriction. Twenty-one related offline files pass 129 cases, including
|
||||
native consent, each separate payment, release, Charity, earned income,
|
||||
Truthtrance, autopilot and selected external-loss cases. One isolated
|
||||
authenticated HTTP case verifies consent, payer authority, deferred receipt and
|
||||
replay rejection on a separate test database. Broader combinations, free-form
|
||||
enforcement, browser and deployed acceptance remain open. Type checking, lint
|
||||
and the production build pass for this checkpoint.
|
||||
|
||||
## Source and prototype scope — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** The [publisher's 2019 rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
@@ -50,7 +93,7 @@ Free-text terms alone move no spice and create no automatically enforced game
|
||||
action. Players remain responsible for honoring valid terms, and an impossible
|
||||
part is void under the rules. Existing [bribe controls](BRIBE_CONTROLS.md)
|
||||
also make actual eligible payments into the recipient's deferred Mentat supply.
|
||||
Automatic interpretation, enforcement and impossibility handling,
|
||||
Automatic interpretation and full enforcement and impossibility handling,
|
||||
AI negotiation and complete multiplayer/deployed acceptance remain
|
||||
open. The text interface is an online convention, not a publisher ruling.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Structured public-deal payment due this turn — 28 October 2026
|
||||
|
||||
The publisher permits binding legal public agreements but does not define this
|
||||
online receipt or forecast. The prototype records an exact separately paid
|
||||
current-turn bribe after unanimous consent; supported native funding witnesses
|
||||
protect a viable payment, and only defined expiration or Basic Mentat
|
||||
shortfalls void it. Temporary alliances and uncertain income are not treated as
|
||||
proof of permanent impossibility. These are provisional software policies.
|
||||
[Scope](PUBLIC_DEALS.md#structured-payment-due-this-turn--28-october-2026).
|
||||
|
||||
## Basic phase-wide bids through special-auction preparation — 28 October 2026
|
||||
|
||||
The current-turn Truthtrance FAQ does not define forecasts through an Ixian
|
||||
|
||||
@@ -4296,6 +4296,12 @@ export function botActions(g: GameView): Action[] {
|
||||
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? policyActions(g) : [];
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
const dealPayer = g.players.find(player => player.id === g.me);
|
||||
if ((dealPayer?.bot || dealPayer?.autopilot) && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) {
|
||||
const payable = Object.entries(g.deferredDealPaymentOptions ?? {}).find(([,option]) => !option.blocked);
|
||||
if (payable) return [{type:'dealPay',deal:payable[0]}];
|
||||
}
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
if (g.turnActionGuide && g.status === 'playing' &&
|
||||
|
||||
+162
-3
@@ -5,7 +5,8 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
|
||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
import { pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError } from './public-deal-obligations';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -5815,6 +5816,136 @@ function publicDealRule<T>(operation: () => T): T {
|
||||
function publicDealIntegrity(g: Game): void {
|
||||
if (g.deals !== undefined) publicDealRule(() => validatePublicDeals(g.deals, g.players));
|
||||
}
|
||||
function publicDealObligationRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof PublicDealObligationError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
/** A detached quote spends each deal separately, so two agreements cannot be
|
||||
* satisfied by one transfer. It also lets a real deal payment satisfy an
|
||||
* overlapping Truthtrance bribe while retaining other live obligations. */
|
||||
function publicDealCompletion(g: Game, payer: Player): boolean {
|
||||
const allDebts = publicDealObligationRule(() => pendingPublicDealPayments(g.deals ?? [],payer.id,g.turn));
|
||||
// An allied recipient cannot currently receive a bribe. Keep that term
|
||||
// pending, but do not let it mask other independently payable debts.
|
||||
const debts = allDebts.filter(debt => debt.recipient !== payer.ally);
|
||||
if (!debts.length) return true;
|
||||
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],payer.id,g.turn));
|
||||
if (g.status !== 'playing') return false;
|
||||
const source = structuredClone(g);
|
||||
source.truthtrance = null; source.response = null; source.decision = null; source.phaseOpening = null;
|
||||
source.pendingKarama = null; source.pendingTreacheryDiscard = null;
|
||||
let visited = 0;
|
||||
const visit = (candidate: Game, remaining: typeof debts): boolean => {
|
||||
// Limit adversarially large ledgers. Exhaustion is not proof that an
|
||||
// externally changed deal is impossible; no automatic void uses it.
|
||||
if (++visited > 2048) return false;
|
||||
try {
|
||||
if (candidate.inflation?.side === 'double') return false;
|
||||
if (remaining.length) {
|
||||
// Each agreement is one exact transfer. Try real whole payments in
|
||||
// different orders; a payer may become eligible for Charity between
|
||||
// them, but a partial transfer never settles a separate deal.
|
||||
for (const [index,debt] of remaining.entries()) {
|
||||
const trial = structuredClone(candidate);
|
||||
try {
|
||||
quoteOrdinaryPayments(trial,payer.id,[{recipient:debt.recipient,amount:debt.amount}]);
|
||||
if (visit(trial,remaining.filter((_,position) => position !== index))) return true;
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
const charity = forecastOrdinaryCharity(candidate,payer.id);
|
||||
if (charity && visit(charity,remaining)) return true;
|
||||
const income = forecastChoamOpeningIncome(candidate,payer.id);
|
||||
if (income && visit(income,remaining)) return true;
|
||||
// Existing bribes are already earned and arrive at this turn's
|
||||
// Mentat opening; no other player's future payment is invented.
|
||||
if (candidate.phase < 8 && getPlayer(candidate,payer.id).bribes > 0 &&
|
||||
!bribesCloseBeforeNextMentat(candidate)) {
|
||||
const opening = structuredClone(candidate), recipient = getPlayer(opening,payer.id);
|
||||
opening.phase = 8; opening.ready = [];
|
||||
recipient.spice += recipient.bribes; recipient.bribes = 0;
|
||||
if (visit(opening,remaining)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const trial = candidate, seat = getPlayer(trial,payer.id);
|
||||
if (hasBribePromises(trial,payer.id,true) && !bribePromiseCompletion(trial,seat)) return false;
|
||||
if (liveTurnActionPromises(trial.turnActionPromises ?? [],payer.id,trial.turn).length &&
|
||||
!turnActionCompletion(trial,payer.id)) return false;
|
||||
const voluntary = {actor:payer.id,action:{type:'bribe'}};
|
||||
reconcileBattlePromises(trial,voluntary);
|
||||
reconcileShipmentPromises(trial,voluntary);
|
||||
reconcileGroundMovementPromises(trial,payer.id,voluntary.action,g.phase);
|
||||
reconcileBidPromises(trial,voluntary);
|
||||
reconcilePhaseBids(trial,voluntary);
|
||||
reconcileEarlyRicheseTruth(trial,voluntary);
|
||||
reconcileRicheseBids(trial,voluntary);
|
||||
reconcileRevivalPromises(trial,voluntary);
|
||||
return true;
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
};
|
||||
return visit(source,debts);
|
||||
}
|
||||
/** Hold accepted obligations while their current-turn continuation remains
|
||||
* possible. Only settled, proven expiry or a base-game Mentat shortfall voids
|
||||
* a term; temporary pending decisions do not establish impossibility. */
|
||||
function reconcilePublicDealTruth(g: Game, voluntary?: {actor:string;action:Action}, before?: Game): void {
|
||||
if (!g.deals?.some(deal => deal.payment?.kind === 'deferredBribe' && deal.paymentStatus === 'pending')) return;
|
||||
for (const p of g.players) {
|
||||
const pending: PublicDeal[] = (g.deals ?? []).filter(deal => deal.status === 'active' && deal.payment?.kind === 'deferredBribe' &&
|
||||
deal.payment.payer === p.id && deal.paymentStatus === 'pending');
|
||||
if (!pending.length) continue;
|
||||
const beforePayer = before && getPlayer(before,p.id);
|
||||
const wasPossible = !!before && !!beforePayer && voluntary?.actor === p.id &&
|
||||
pendingPublicDealPayments(before.deals ?? [],p.id,before.turn).some(debt => debt.recipient !== beforePayer.ally) &&
|
||||
publicDealCompletion(before,beforePayer);
|
||||
const expiredByAction = pending.some(deal => deal.payment?.kind === 'deferredBribe' &&
|
||||
(g.turn > deal.payment.dueTurn || g.status === 'finished'));
|
||||
const closesPaymentWindow = wasPossible && voluntary?.action.type === 'ready' &&
|
||||
(before!.phase === 8 || before!.phase === 7 && bribesCloseBeforeNextMentat(before!));
|
||||
if (wasPossible && (closesPaymentWindow || expiredByAction || !publicDealCompletion(g,p)))
|
||||
throw new RuleError('Keep a legal completion for your accepted public deal payments.');
|
||||
const quiet = !g.truthtrance && !g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.bureaucratPayments?.pending;
|
||||
const available = Math.max(0,uncommittedSpice(g,p));
|
||||
for (const deal of pending) {
|
||||
const payment = deal.payment!;
|
||||
if (payment.kind !== 'deferredBribe') continue;
|
||||
const expired = g.turn > payment.dueTurn || g.status === 'finished';
|
||||
if (!expired && payment.recipient === p.ally) continue;
|
||||
const baseMentatShortfall = quiet && g.turn === payment.dueTurn && g.phase === 8 &&
|
||||
g.expansions.length === 0 && bribePromiseModeSupported(g) && available < payment.amount;
|
||||
if (expired || baseMentatShortfall) {
|
||||
g.deals = g.deals!.map(row => row.id === deal.id ? publicDealRule((): PublicDeal => voidPublicDealPayment(row,g.players)) : row);
|
||||
log(g, `${p.name}'s agreed ${payment.amount}-spice payment can no longer be completed and is void.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Once Basic Mentat has opened, outstanding promises can exceed funds
|
||||
* after an external loss. Let the payer choose which still-payable deal to
|
||||
* settle; the other individually impossible terms are then voided. */
|
||||
function publicDealInsolventAtBaseMentat(g: Game, player: string): boolean {
|
||||
return g.phase === 8 && g.expansions.length === 0 && bribePromiseModeSupported(g) &&
|
||||
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],player,g.turn)) >
|
||||
Math.max(0,uncommittedSpice(g,getPlayer(g,player)));
|
||||
}
|
||||
/** Only the payer receives its exact native payment quote. */
|
||||
function projectedDeferredDealPaymentOptions(g: Game, player: string): Record<string,{blocked:string|null}> {
|
||||
const result: Record<string,{blocked:string|null}> = {};
|
||||
for (const debt of pendingPublicDealPayments(g.deals ?? [],player,g.turn)) {
|
||||
try {
|
||||
const trial = structuredClone(g), index = trial.deals!.findIndex(deal => deal.id === debt.deal);
|
||||
trial.deals![index] = publicDealRule(() => markPublicDealPaid(trial.deals![index],trial.players,player));
|
||||
applyActionWithInitiator(trial,player,{type:'bribe',target:debt.recipient,amount:debt.amount},player,
|
||||
{insolventDealPayment:publicDealInsolventAtBaseMentat(g,player)});
|
||||
result[debt.deal] = {blocked:null};
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
result[debt.deal] = {blocked:error.message};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function assignLobbyFaction(player: Player, chosen: FactionId): void {
|
||||
player.faction = chosen;
|
||||
if (player.bot) player.name = `${faction(chosen).name} AI`;
|
||||
@@ -16091,6 +16222,7 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
|
||||
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
|
||||
return !!bribePromiseCompletion(source, p, promises) &&
|
||||
publicDealCompletion({ ...source, bribePromises: promises },p) &&
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p) &&
|
||||
richeseBidCompatibleLaterPromises({ ...source, bribePromises: promises },p);
|
||||
});
|
||||
@@ -16103,6 +16235,7 @@ function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionC
|
||||
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }];
|
||||
return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) &&
|
||||
publicDealCompletion({ ...source, bribeExpressionPromises: expressions },p) &&
|
||||
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p) &&
|
||||
richeseBidCompatibleLaterPromises({ ...source, bribeExpressionPromises: expressions },p);
|
||||
});
|
||||
@@ -40129,7 +40262,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
/** The initiator can differ from the payer only for a previously consented
|
||||
* automatic deal payment; it is never supplied by a client action. */
|
||||
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string): Game {
|
||||
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
|
||||
options?: {insolventDealPayment?: boolean}): Game {
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
factionSwapIntegrity(state);
|
||||
@@ -40203,6 +40337,18 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
ecazCollectionIntegrity(state);
|
||||
ecazAllianceIntegrity(state);
|
||||
if (action?.type === 'dealPay') {
|
||||
requireRule(state.status === 'playing' && Object.keys(action).sort().join(',') === 'deal,type' &&
|
||||
typeof action.deal === 'string', 'Choose your pending deal payment.');
|
||||
const g = structuredClone(state), index = (g.deals ?? []).findIndex(deal => deal.id === action.deal);
|
||||
requireRule(index >= 0, 'This deal is no longer available.');
|
||||
const current = g.deals![index], payment = current.payment;
|
||||
requireRule(payment?.kind === 'deferredBribe' && payment.dueTurn === g.turn,
|
||||
'This deferred payment is not due in the current turn.');
|
||||
g.deals![index] = publicDealRule(() => markPublicDealPaid(current,g.players,id));
|
||||
return applyActionWithInitiator(g,id,{type:'bribe',target:payment.recipient,amount:payment.amount},id,
|
||||
{insolventDealPayment:publicDealInsolventAtBaseMentat(state,id)});
|
||||
}
|
||||
if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
|
||||
requireRule(state.status === 'playing', 'Public deals require a game in progress.');
|
||||
const player = getPlayer(state, id);
|
||||
@@ -40246,7 +40392,15 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
|
||||
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
|
||||
g.deals = deals.map((deal, position) => position === index ? next : deal);
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment) {
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'deferredBribe') {
|
||||
const payer = getPlayer(g,next.payment.payer);
|
||||
requireRule(next.payment.dueTurn === g.turn && publicDealCompletion(g,payer),
|
||||
'The agreed payment needs a legal funded completion in this turn.');
|
||||
if ((g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) &&
|
||||
g.ready.includes(payer.id)) g.ready = g.ready.filter(playerId => playerId !== payer.id);
|
||||
log(g, `${payer.name} agreed to pay ${next.payment.amount} spice to ${getPlayer(g,next.payment.recipient).name} by the end of turn ${g.turn}.`);
|
||||
}
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'immediateBribe') {
|
||||
// The payer consented to these immutable terms. Run the native action
|
||||
// on the same cloned state so a failed payment also rolls back consent.
|
||||
try {
|
||||
@@ -40444,6 +40598,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
reconcileCharityTruth(g, voluntary);
|
||||
reconcileRevivalPromises(g, voluntary);
|
||||
reconcileBribePromises(g, voluntary);
|
||||
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
|
||||
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
|
||||
reconcileTraitorTruth(g, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
@@ -40572,6 +40727,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
reconcileCharityTruth(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
|
||||
reconcileAlliancePromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
@@ -40807,6 +40963,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcilePublicDealTruth(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
@@ -40924,6 +41081,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcilePublicDealTruth(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
@@ -45466,6 +45624,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
host: g.host,
|
||||
status: g.status,
|
||||
deals: g.deals ?? [],
|
||||
deferredDealPaymentOptions: projectedDeferredDealPaymentOptions(g,id),
|
||||
factionDraft: g.factionDraft ?? null,
|
||||
factionSwap: g.factionSwap ?? null,
|
||||
twoPlayerControllers: g.twoPlayerControllers ?? null,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { validatePublicDeals, voidPublicDealPayment, type PublicDeal, type PublicDealSeat } from './public-deals';
|
||||
|
||||
/** A deal-linked obligation is independent of every other deal and of
|
||||
* Truthtrance. It is due once for its exact gross payment, not a threshold
|
||||
* shared with a different agreement. */
|
||||
export type PendingPublicDealPayment = {
|
||||
deal: string;
|
||||
payer: string;
|
||||
recipient: string;
|
||||
dueTurn: number;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export class PublicDealObligationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PublicDealObligationError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const nonnegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** Input ledger order is acceptance order; callers validate persisted deals
|
||||
* against seated players before using a quote. */
|
||||
export function pendingPublicDealPayments(
|
||||
deals: readonly PublicDeal[], payer: string, turn: number,
|
||||
): PendingPublicDealPayment[] {
|
||||
if (!Array.isArray(deals) || !identifier(payer) || !Number.isSafeInteger(turn) || turn < 1)
|
||||
throw new PublicDealObligationError('Retain the payer and original turn of the deferred payments.');
|
||||
return deals.flatMap(deal => {
|
||||
const payment = deal.payment;
|
||||
if (deal.status !== 'active' || deal.paymentStatus !== 'pending' ||
|
||||
payment?.kind !== 'deferredBribe' || payment.payer !== payer || payment.dueTurn !== turn) return [];
|
||||
return [{ deal: deal.id, payer, recipient: payment.recipient, dueTurn: payment.dueTurn, amount: payment.amount }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Separate deals add, including two deals for the same recipient. */
|
||||
export function publicDealPaymentTotal(deals: readonly PublicDeal[], payer: string, turn: number): number {
|
||||
let total = 0;
|
||||
for (const payment of pendingPublicDealPayments(deals, payer, turn)) {
|
||||
if (!Number.isSafeInteger(payment.amount) || payment.amount <= 0 ||
|
||||
!Number.isSafeInteger(total + payment.amount))
|
||||
throw new PublicDealObligationError('The outstanding deal payments exceed the safe spice range.');
|
||||
total += payment.amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export type PublicDealPaymentWitness = {
|
||||
/** The engine's currently available uncommitted funding, or null if a
|
||||
* pending continuation means no reliable current quote is available. */
|
||||
available: number | null;
|
||||
change: 'voluntary' | 'external';
|
||||
/** Each ID must be proven individually impossible by the engine. A mere
|
||||
* cash shortage does not prove loss of future income or the other debts. */
|
||||
impossibleDealIds?: readonly string[];
|
||||
};
|
||||
|
||||
/** An actor may not voluntarily make their accepted, funded payments
|
||||
* impossible. External uncertainty leaves every agreement intact; only
|
||||
* independently proven impossible terms are voided. No funds move here. */
|
||||
export function reconcilePublicDealPayments(
|
||||
deals: readonly PublicDeal[], players: readonly PublicDealSeat[], payer: string, turn: number,
|
||||
witness: PublicDealPaymentWitness,
|
||||
): PublicDeal[] {
|
||||
validatePublicDeals(deals, players);
|
||||
if (!witness || (witness.change !== 'voluntary' && witness.change !== 'external') ||
|
||||
(witness.available !== null && !nonnegative(witness.available)) ||
|
||||
(witness.impossibleDealIds !== undefined && !Array.isArray(witness.impossibleDealIds)))
|
||||
throw new PublicDealObligationError('Retain the actual funded witness and cause of the change.');
|
||||
const pending = pendingPublicDealPayments(deals, payer, turn);
|
||||
const ids = witness.impossibleDealIds ?? [];
|
||||
if (new Set(ids).size !== ids.length || ids.some(id => !pending.some(row => row.deal === id)))
|
||||
throw new PublicDealObligationError('Void only individually proven pending terms of this payer and turn.');
|
||||
if (witness.change === 'voluntary' && (ids.length > 0 ||
|
||||
witness.available !== null && witness.available < publicDealPaymentTotal(deals, payer, turn)))
|
||||
throw new PublicDealObligationError('Keep the funding needed for your accepted public deal payments.');
|
||||
if (witness.change !== 'external' || !ids.length) return [...deals];
|
||||
return deals.map(deal => ids.includes(deal.id) ? voidPublicDealPayment(deal, players) : deal);
|
||||
}
|
||||
+65
-14
@@ -1,11 +1,18 @@
|
||||
/** Public agreements record written terms without parsing or enforcing them.
|
||||
* Older two-seat records omit participants and acceptVotes entirely. */
|
||||
/** Written terms remain player-managed. Typed payment terms use the native
|
||||
* bribe rules; older two-seat records omit participants and acceptVotes. */
|
||||
export type PublicDealPayment = {
|
||||
kind: 'immediateBribe';
|
||||
payer: string;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
} | {
|
||||
kind: 'deferredBribe';
|
||||
payer: string;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
dueTurn: number;
|
||||
};
|
||||
export type PublicDealPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
|
||||
export type PublicDeal = {
|
||||
id: string;
|
||||
from: string;
|
||||
@@ -17,8 +24,10 @@ export type PublicDeal = {
|
||||
/** Present together only on the multi-party record format. */
|
||||
participants?: string[];
|
||||
acceptVotes?: string[];
|
||||
/** Exact gross bribe due when the last participant accepts. */
|
||||
/** Exact gross bribe; immediate payment settles on final acceptance. */
|
||||
payment?: PublicDealPayment;
|
||||
/** Present only for accepted or released deferred payments. */
|
||||
paymentStatus?: PublicDealPaymentStatus;
|
||||
};
|
||||
|
||||
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
|
||||
@@ -73,14 +82,23 @@ function orderedSubset(votes: readonly string[], parties: readonly string[]): bo
|
||||
}
|
||||
|
||||
/** Parse one public gross payment term; no funds move until native settlement. */
|
||||
export function parsePublicDealPayment(value: unknown, parties: readonly string[]): PublicDealPayment {
|
||||
requireDeal(object(value) && Object.keys(value).sort().join(',') === 'amount,kind,payer,recipient' &&
|
||||
value.kind === 'immediateBribe' && id(value.payer) && id(value.recipient) && value.payer !== value.recipient &&
|
||||
export function parsePublicDealPayment(value: unknown, parties: readonly string[], turn?: number): PublicDealPayment {
|
||||
requireDeal(object(value) && id(value.payer) && id(value.recipient) && value.payer !== value.recipient &&
|
||||
parties.includes(value.payer) && parties.includes(value.recipient) &&
|
||||
typeof value.amount === 'number' && Number.isSafeInteger(value.amount) && value.amount > 0,
|
||||
'Choose a positive whole immediate bribe between two deal participants.');
|
||||
'Choose a positive whole bribe between two deal participants.');
|
||||
if (value.kind === 'immediateBribe') {
|
||||
requireDeal(Object.keys(value).sort().join(',') === 'amount,kind,payer,recipient',
|
||||
'Choose an exact immediate bribe term.');
|
||||
return { kind: 'immediateBribe', payer: value.payer, recipient: value.recipient, amount: value.amount };
|
||||
}
|
||||
requireDeal(value.kind === 'deferredBribe' &&
|
||||
Object.keys(value).sort().join(',') === 'amount,dueTurn,kind,payer,recipient' &&
|
||||
Number.isSafeInteger(turn) && (turn as number) > 0 && value.dueTurn === turn,
|
||||
'Choose a deferred bribe due in the deal’s current turn.');
|
||||
return { kind: 'deferredBribe', payer: value.payer, recipient: value.recipient,
|
||||
amount: value.amount, dueTurn: turn as number };
|
||||
}
|
||||
|
||||
/** Validate persisted ledger shape and lifecycle without applying current
|
||||
* alliance restrictions retroactively to existing agreements. */
|
||||
@@ -89,7 +107,7 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
|
||||
const ids = new Set<string>();
|
||||
for (const deal of value) {
|
||||
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment').sort().join(',') : '';
|
||||
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment' && key !== 'paymentStatus').sort().join(',') : '';
|
||||
const multi = keys === 'acceptVotes,from,id,participants,releaseVotes,status,terms,to,turn';
|
||||
requireDeal(object(deal) && (multi || keys === 'from,id,releaseVotes,status,terms,to,turn') &&
|
||||
id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
|
||||
@@ -118,7 +136,15 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
||||
parties = [deal.from, deal.to];
|
||||
participants(players, parties);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(deal, 'payment')) parsePublicDealPayment(deal.payment, parties);
|
||||
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
|
||||
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
|
||||
if (payment?.kind === 'deferredBribe') {
|
||||
const status = deal.paymentStatus;
|
||||
requireDeal(deal.status === 'active' ? status === 'pending' || status === 'paid' || status === 'void' :
|
||||
deal.status === 'released' ? status === 'released' || status === 'paid' :
|
||||
!Object.hasOwn(deal, 'paymentStatus'), 'The deferred payment status does not match the deal lifecycle.');
|
||||
} else requireDeal(!Object.hasOwn(deal, 'paymentStatus'),
|
||||
'Only an accepted deferred payment has a payment status.');
|
||||
const votes = deal.releaseVotes as string[];
|
||||
requireDeal(new Set(votes).size === votes.length && votes.every(vote => parties.includes(vote)) &&
|
||||
(!multi || orderedSubset(votes, parties)) &&
|
||||
@@ -143,7 +169,7 @@ export function createPublicDeal(
|
||||
payment?: PublicDealPayment,
|
||||
): PublicDeal {
|
||||
const text = validOffer(players, actor, [target], terms, turn, event);
|
||||
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, target]) } : {};
|
||||
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, target], turn) } : {};
|
||||
return { id: event, from: actor, to: target, terms: text, turn, status: 'offered', releaseVotes: [],
|
||||
...paymentTerm };
|
||||
}
|
||||
@@ -155,7 +181,7 @@ export function createMultipartyPublicDeal(
|
||||
requireDeal(Array.isArray(targets) && targets.length >= 2 && targets.length <= 5,
|
||||
'Choose two through five other participants.');
|
||||
const text = validOffer(players, actor, targets, terms, turn, event);
|
||||
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, ...targets]) } : {};
|
||||
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, ...targets], turn) } : {};
|
||||
return { id: event, from: actor, to: targets[0], terms: text, turn, status: 'offered',
|
||||
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor],
|
||||
...paymentTerm };
|
||||
@@ -168,11 +194,14 @@ export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDeal
|
||||
'Only an invited recipient may accept or decline an outstanding deal.');
|
||||
if (!accept) return { ...deal, status: 'declined', releaseVotes: [] };
|
||||
notAllied(participants(players, parties));
|
||||
if (!deal.participants) return { ...deal, status: 'active', releaseVotes: [] };
|
||||
if (!deal.participants) return { ...deal, status: 'active', releaseVotes: [],
|
||||
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
||||
const alreadyAccepted = publicDealAcceptVotes(deal);
|
||||
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
|
||||
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
|
||||
return { ...deal, acceptVotes: accepts, status: accepts.length === parties.length ? 'active' : 'offered', releaseVotes: [] };
|
||||
const active = accepts.length === parties.length;
|
||||
return { ...deal, acceptVotes: accepts, status: active ? 'active' : 'offered', releaseVotes: [],
|
||||
...(active && deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
|
||||
}
|
||||
|
||||
export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
|
||||
@@ -191,5 +220,27 @@ export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDeal
|
||||
let votes = deal.releaseVotes.filter(vote => vote !== actor);
|
||||
if (agree) votes.push(actor);
|
||||
if (deal.participants) votes = parties.filter(seat => votes.includes(seat));
|
||||
return { ...deal, status: votes.length === parties.length ? 'released' : 'active', releaseVotes: votes };
|
||||
const released = votes.length === parties.length;
|
||||
return { ...deal, status: released ? 'released' : 'active', releaseVotes: votes,
|
||||
...(released && deal.payment?.kind === 'deferredBribe' && deal.paymentStatus !== 'paid'
|
||||
? { paymentStatus: 'released' as const } : {}) };
|
||||
}
|
||||
|
||||
/** The engine must first authorize the native payer and exact bribe action.
|
||||
* Apply this transition on its detached action state, so native failure rolls
|
||||
* both the payment and the status change back together. */
|
||||
export function markPublicDealPaid(deal: PublicDeal, players: readonly PublicDealSeat[], payer: string): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'active' && deal.payment?.kind === 'deferredBribe' &&
|
||||
deal.paymentStatus === 'pending' && deal.payment.payer === payer,
|
||||
'Only the payer may settle a pending deferred deal payment.');
|
||||
return { ...deal, paymentStatus: 'paid' };
|
||||
}
|
||||
|
||||
/** The caller must establish actual external impossibility before voiding. */
|
||||
export function voidPublicDealPayment(deal: PublicDeal, players: readonly PublicDealSeat[]): PublicDeal {
|
||||
validatePublicDeals([deal], players);
|
||||
requireDeal(deal.status === 'active' && deal.payment?.kind === 'deferredBribe' && deal.paymentStatus === 'pending',
|
||||
'Only a pending deferred deal payment can become impossible.');
|
||||
return { ...deal, paymentStatus: 'void' };
|
||||
}
|
||||
+4
-3
@@ -301,16 +301,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
|
||||
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
|
||||
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
|
||||
'A deal may instead specify one exact payment due this turn. After everyone accepts, the payer sees a persistent payment request and uses that deal’s Pay button. 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.',
|
||||
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility. Free text alone pays no spice. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. An optional immediate payment executes through the native bribe action upon final acceptance; prose compliance remains player managed. Bribe escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and current-turn deferred deal payments use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, immediate native payments, pending decisions and authenticated visibility have focused coverage, including multiparty 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/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-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: 'Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including multiparty 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/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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2118,7 +2119,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Ixians begin with three cyborgs and three suboids inside. After the first storm moves, select its pointing sector in any non-stronghold territory, including the Polar Sink. Placement collects no spice.',
|
||||
'Before later storm dials or card reveals, occupied Ixians may keep the current position or declare a route of up to three territories. Use the route selector to name each sector. The destination must be a non-stronghold territory. The route cannot begin, end or pass through the current storm sector. Occupants stay inside throughout relocation.',
|
||||
'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per Ixian force inside, separately in each sector. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.',
|
||||
'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per eligible force inside, separately in each sector. Foreign fighters count, a concealed No-Field counts as one, and advisors do not count. Older moves without selected amounts retain their Ixian-only automatic limit. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.',
|
||||
'Forces inside are separate from forces outside. Enter and leave through the pointing sector, counting the stronghold as one more territory of movement. A storm in that pointing sector blocks ordinary entry or exit. Direct shipping inside is restricted to Ixians; advanced Bene Gesserit may accompany their shipment.',
|
||||
'Worms and storms do not kill the occupants. Battles wait while the pointing sector is in storm; otherwise normal battles and the two-faction capacity apply inside. The sole controller counts it toward victory, even when that controller is not Ixian. Capturing it does not grant another faction permission to move it.',
|
||||
'In advanced play, Ixians may spend special Karama once during their own Shipment and Movement turn to relocate up to two territories while their forces occupy it. This preserves their ordinary shipment and troop movement. Setup and bidding technology now have decision controls; expansion starts and remaining interaction audits are unfinished.',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMultipartyPublicDeal, createPublicDeal, markPublicDealPaid, parsePublicDealPayment,
|
||||
PublicDealError, releasePublicDeal, respondPublicDeal, validatePublicDeals,
|
||||
voidPublicDealPayment, type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
import {
|
||||
pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError,
|
||||
reconcilePublicDealPayments,
|
||||
} from '../game/public-deal-obligations';
|
||||
|
||||
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const deferred = (amount: number) => ({
|
||||
kind: 'deferredBribe' as const, payer: 'a', recipient: 'b', amount, dueTurn: 3,
|
||||
});
|
||||
const accept = (id: string, amount: number) =>
|
||||
respondPublicDeal(createPublicDeal(seats, 'a', 'b', 'Pay during this turn', 3, id, deferred(amount)), seats, 'b', true);
|
||||
|
||||
void test('deferred deal terms bind an exact positive payment to this turn without changing immediate terms', () => {
|
||||
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 2 } as const;
|
||||
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
|
||||
assert.deepEqual(parsePublicDealPayment(deferred(4), ['a', 'b'], 3), deferred(4));
|
||||
for (const input of [
|
||||
{ ...deferred(4), dueTurn: 4 }, { ...deferred(0) },
|
||||
{ ...deferred(4), recipient: 'c' }, { ...deferred(4), extra: true },
|
||||
]) assert.throws(() => parsePublicDealPayment(input, ['a', 'b'], 3), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(deferred(4), ['a', 'b']), PublicDealError);
|
||||
assert.throws(() => createPublicDeal(seats, 'a', 'b', 'Pay later', 4, 'wrong-turn', deferred(4)), PublicDealError);
|
||||
});
|
||||
|
||||
void test('all signatories accept before a future payment becomes pending; only its payer can settle it once', () => {
|
||||
const offered = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Pay later', 3, 'group', deferred(4));
|
||||
const partially = respondPublicDeal(offered, seats, 'b', true);
|
||||
assert.equal(partially.status, 'offered');
|
||||
assert.equal(partially.paymentStatus, undefined);
|
||||
const active = respondPublicDeal(partially, seats, 'c', true);
|
||||
assert.equal(active.paymentStatus, 'pending');
|
||||
assert.throws(() => markPublicDealPaid(active, seats, 'b'), PublicDealError);
|
||||
const paid = markPublicDealPaid(active, seats, 'a');
|
||||
assert.equal(paid.paymentStatus, 'paid');
|
||||
assert.throws(() => markPublicDealPaid(paid, seats, 'a'), PublicDealError);
|
||||
let released = releasePublicDeal(paid, seats, 'a', true);
|
||||
released = releasePublicDeal(released, seats, 'b', true);
|
||||
released = releasePublicDeal(released, seats, 'c', true);
|
||||
assert.equal(released.status, 'released');
|
||||
assert.equal(released.paymentStatus, 'paid');
|
||||
validatePublicDeals([{ ...offered, id: 'offered' }, active, { ...released, id: 'released' }], seats);
|
||||
});
|
||||
|
||||
void test('mutual release and proven impossibility end only an unpaid accepted payment', () => {
|
||||
const first = accept('first', 4);
|
||||
const vote = releasePublicDeal(first, seats, 'a', true);
|
||||
assert.equal(vote.paymentStatus, 'pending');
|
||||
const released = releasePublicDeal(vote, seats, 'b', true);
|
||||
assert.equal(released.paymentStatus, 'released');
|
||||
const impossible = voidPublicDealPayment(first, seats);
|
||||
assert.equal(impossible.paymentStatus, 'void');
|
||||
assert.throws(() => markPublicDealPaid(impossible, seats, 'a'), PublicDealError);
|
||||
assert.throws(() => voidPublicDealPayment(released, seats), PublicDealError);
|
||||
const bad = [
|
||||
{ ...first, paymentStatus: undefined }, { ...first, paymentStatus: 'released' },
|
||||
{ ...first, status: 'offered', paymentStatus: 'pending' },
|
||||
{ ...first, payment: { ...deferred(4), dueTurn: 4 } },
|
||||
{ ...first, payment: { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 4 }, paymentStatus: 'paid' },
|
||||
];
|
||||
for (const deal of bad) assert.throws(() => validatePublicDeals([deal], seats), PublicDealError);
|
||||
});
|
||||
|
||||
void test('distinct pending agreements add, voluntary spending is guarded, and uncertain external shortage does not erase them', () => {
|
||||
const deals: PublicDeal[] = [accept('first', 4), accept('second', 5)];
|
||||
const original = structuredClone(deals);
|
||||
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 3).map(row => [row.deal, row.amount]), [['first', 4], ['second', 5]]);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 3), 9);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'b', 3), 0);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 4), 0);
|
||||
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: 8, change: 'voluntary' }), PublicDealObligationError);
|
||||
assert.deepEqual(reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: 8, change: 'external' }), deals, 'Current shortage does not prove impossibility.');
|
||||
assert.deepEqual(reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: null, change: 'external' }), deals);
|
||||
const reconciled = reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: null, change: 'external', impossibleDealIds: ['second'] });
|
||||
assert.equal(reconciled[0].paymentStatus, 'pending');
|
||||
assert.equal(reconciled[1].paymentStatus, 'void');
|
||||
assert.equal(publicDealPaymentTotal(reconciled, 'a', 3), 4);
|
||||
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: 9, change: 'voluntary', impossibleDealIds: ['second'] }), PublicDealObligationError);
|
||||
assert.throws(() => reconcilePublicDealPayments(deals, seats, 'a', 3,
|
||||
{ available: 9, change: 'external', impossibleDealIds: ['other'] }), PublicDealObligationError);
|
||||
assert.deepEqual(deals, original, 'Rejected quotes and pure transitions preserve the caller’s state.');
|
||||
});
|
||||
|
||||
void test('individually valid payments cannot overflow their combined obligation', () => {
|
||||
const deals = [accept('large', Number.MAX_SAFE_INTEGER), accept('one', 1)];
|
||||
assert.throws(() => publicDealPaymentTotal(deals, 'a', 3), PublicDealObligationError);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, createMentatBribeFixture as fixture,
|
||||
mentatBribeInventory as inventory, mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
/** Native turn-one Basic Mentat: starting hands and purses come from actual
|
||||
* setup and auctions; only unopened source ordering was selected. */
|
||||
function accepted() {
|
||||
const f = fixture();
|
||||
let game = act(f.game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Pay the agreed spice this turn.',
|
||||
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 3, dueTurn: f.game.turn } });
|
||||
const deal = game.deals!.at(-1)!;
|
||||
game = act(game, f.target, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
|
||||
return { f, game, deal: deal.id };
|
||||
}
|
||||
|
||||
void test('human-controlled seats are not given an automatic deferred payment', () => {
|
||||
const { f, game, deal } = accepted();
|
||||
const view = viewGame(game, f.target);
|
||||
assert.equal(view.deferredDealPaymentOptions[deal]?.blocked, null);
|
||||
assert.ok(!botActions(view).some(action => action.type === 'dealPay'));
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
|
||||
});
|
||||
|
||||
void test('a delegated human seat pays its own exact pending deal legally', () => {
|
||||
const { f, game: source, deal } = accepted();
|
||||
const delegated = act(source, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
assert.equal(player(delegated, f.target).autopilot, 'Easy');
|
||||
const action = botActions(viewGame(delegated, f.target))[0];
|
||||
assert.deepEqual(action, { type: 'dealPay', deal });
|
||||
const settled = act(delegated, f.target, action);
|
||||
assert.equal(settled.deals!.at(-1)!.paymentStatus, 'paid');
|
||||
assert.equal(player(settled, f.target).spice, 7);
|
||||
assert.equal(player(settled, f.asker).bribes, 3);
|
||||
assert.deepEqual(inventory(settled), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a native Truthtrance response blocks autopilot payment until it resolves', () => {
|
||||
const { f, game: source, deal } = accepted();
|
||||
let game: Game = act(source, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
game = act(game, f.asker, { type: 'card', card: f.truths[0] });
|
||||
assert.ok(game.truthtrance, 'An actual original Truthtrance opens a native response.');
|
||||
const view = viewGame(game, f.target);
|
||||
assert.ok(view.deferredDealPaymentOptions[deal]?.blocked);
|
||||
assert.ok(!botActions(view).some(action => action.type === 'dealPay'));
|
||||
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthCharityPayments as act,
|
||||
createTruthCharityPaymentsFixture as fixture,
|
||||
truthCharityPaymentsInventory as inventory,
|
||||
truthCharityPaymentsPlayer as player,
|
||||
} from './fixture-truth-charity-payments';
|
||||
|
||||
function agree(game: Game, proposer: string, payer: string, amount: number) {
|
||||
const offered = act(game, proposer, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
|
||||
payment: { kind: 'deferredBribe', payer, recipient: proposer, amount, dueTurn: game.turn } });
|
||||
const id = offered.deals!.at(-1)!.id;
|
||||
return { game: act(offered, payer, { type: 'dealRespond', deal: id, accept: true }), id };
|
||||
}
|
||||
|
||||
void test('two whole deal payments can be funded by paying one before a real Charity claim', () => {
|
||||
// The native source plays the actual Amal at Spice Blow, halving the
|
||||
// Emperor's original ten spice to five, then enters actual Basic Charity.
|
||||
// Only unopened card/Spice ordering was selected; no wallet or hand staged.
|
||||
const f = fixture({ source: 'phase2', advanced: false });
|
||||
assert.equal(f.game.phase, 2);
|
||||
assert.equal(player(f.game, f.target).spice, 5);
|
||||
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
|
||||
const first = agree(f.game, f.asker, f.target, 4);
|
||||
const second = agree(first.game, f.asker, f.target, 2);
|
||||
const accepted = second.game;
|
||||
assert.equal(player(accepted, f.target).spice, 5, 'Both exact deals can be accepted before either payment.');
|
||||
assert.equal(accepted.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
|
||||
assert.equal(accepted.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
|
||||
|
||||
const beforeMalformed = structuredClone(accepted);
|
||||
assert.throws(() => act(accepted, f.target, { type: 'dealPay', deal: first.id, amount: 1 }));
|
||||
assert.deepEqual(accepted, beforeMalformed, 'A partial dealPay cannot settle either exact payment.');
|
||||
const separate = act(accepted, f.target, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
assert.equal(separate.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
|
||||
assert.equal(separate.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending',
|
||||
'An untagged transfer does not silently settle either distinct deal.');
|
||||
|
||||
let game = act(accepted, f.target, { type: 'dealPay', deal: first.id });
|
||||
assert.equal(player(game, f.target).spice, 1);
|
||||
assert.equal(game.deals!.find(row => row.id === first.id)!.paymentStatus, 'paid');
|
||||
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
|
||||
game = act(game, f.target, { type: 'charity' });
|
||||
assert.equal(player(game, f.target).spice, 2);
|
||||
assert.equal(player(game, f.target).charityTurn, game.turn);
|
||||
game = act(game, f.target, { type: 'dealPay', deal: second.id });
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(player(game, f.asker).bribes, 6);
|
||||
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'paid');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
const payment = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 3, dueTurn: 2 } as const;
|
||||
const deal = { id: 'deal', from: 'a', to: 'b', terms: 'A promise.', turn: 2,
|
||||
status: 'active', releaseVotes: [], payment, paymentStatus: 'pending' };
|
||||
const base = { status: 'playing', turn: 2, me: 'a', roomControl: null,
|
||||
players: [{ id: 'a', name: 'Alice', faction: 'atreides' }, { id: 'b', name: 'Basil', faction: 'harkonnen' }],
|
||||
deals: [deal], deferredDealPaymentOptions: { deal: { blocked: null } },
|
||||
} as unknown as GameView;
|
||||
const html = (game: GameView) => renderToStaticMarkup(createElement(PublicDealList, { game, act() {}, disabled: false }));
|
||||
function buttons(game: GameView, calls: Action[], disabled = false) {
|
||||
return elements(PublicDealList({ game, act: action => calls.push(action), disabled }))
|
||||
.filter(element => typeof element.props.onClick === 'function');
|
||||
}
|
||||
const find = (nodes: Element[], text: string) => nodes.find(node => node.props.children === text);
|
||||
|
||||
void test('an offer binds the exact deferred gross payment, due turn and distinct participants', () => {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', payment, 'a'),
|
||||
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment });
|
||||
for (const bad of [
|
||||
{ ...payment, payer: 'b' }, { ...payment, recipient: 'outside' },
|
||||
{ ...payment, amount: 0 }, { ...payment, amount: 1.5 },
|
||||
{ ...payment, dueTurn: 0 }, { ...payment, dueTurn: 1.5 },
|
||||
]) assert.equal(publicDealOfferAction(['b'], 'Terms', bad, 'a'), null);
|
||||
});
|
||||
|
||||
void test('only the payer can pay a pending active term through its exact deal', () => {
|
||||
const calls: Action[] = [];
|
||||
const markup = html(base);
|
||||
assert.match(markup, /Deferred payment term/);
|
||||
assert.match(markup, /exactly 3 spice/);
|
||||
assert.match(markup, /end of turn 2/);
|
||||
assert.match(markup, /Payment status: pending/);
|
||||
assert.match(markup, /next Mentat opening/);
|
||||
assert.match(markup, /Bureaucrat/);
|
||||
const button = find(buttons(base, calls), 'Pay agreed amount');
|
||||
assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(calls, [{ type: 'dealPay', deal: 'deal' }]);
|
||||
assert.equal(find(buttons({ ...base, me: 'b' }, calls), 'Pay agreed amount'), undefined);
|
||||
assert.equal(find(buttons({ ...base, deals: [{ ...deal, paymentStatus: 'paid' }] } as unknown as GameView, calls), 'Pay agreed amount'), undefined);
|
||||
assert.equal(find(buttons({ ...base, deals: [{ ...deal, status: 'released', paymentStatus: 'released' }] } as unknown as GameView, calls), 'Pay agreed amount'), undefined);
|
||||
});
|
||||
|
||||
void test('payer sees the server reason and cannot initiate an unavailable payment', () => {
|
||||
const calls: Action[] = [];
|
||||
const blocked = { ...base, deferredDealPaymentOptions: { deal: { blocked: 'Finish the current decision.' } } } as unknown as GameView;
|
||||
assert.match(html(blocked), /Finish the current decision/);
|
||||
const button = find(buttons(blocked, calls), 'Pay agreed amount');
|
||||
assert.ok(button); assert.equal(button.props.disabled, true);
|
||||
assert.equal(find(buttons(base, calls, true), 'Pay agreed amount')?.props.disabled, true);
|
||||
const unknown = { ...base, deferredDealPaymentOptions: {} } as unknown as GameView;
|
||||
assert.equal(find(buttons(unknown, calls), 'Pay agreed amount')?.props.disabled, true);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
void test('a pending payment opens the payer’s persistent request panel and remains until resolved', () => {
|
||||
assert.deepEqual(publicDealRequests(base), [['payment', 'deal']]);
|
||||
assert.match(renderToStaticMarkup(createElement(PublicDeals, { game: base, act() {}, busy: false })), /Public deals · payment due/);
|
||||
assert.deepEqual(publicDealRequests({ ...base, me: 'b' }), []);
|
||||
const requested = { ...base, deals: [{ ...deal, releaseVotes: ['b'] }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(requested), [['release', 'deal', 'b'], ['payment', 'deal']]);
|
||||
const paid = { ...base, deals: [{ ...deal, paymentStatus: 'paid' }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(paid), []);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(PublicDeals, { game: paid, act() {}, busy: false })), /Public deals · payment due/);
|
||||
const voided = { ...base, deals: [{ ...deal, paymentStatus: 'void' }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(voided), []);
|
||||
});
|
||||
|
||||
void test('deferred final acceptance does not require an immediate bribe window, but an expired offer cannot be accepted', () => {
|
||||
const offered = { ...deal, status: 'offered', paymentStatus: undefined };
|
||||
const game = { ...base, me: 'b', bribeOptions: { blocked: 'Finish the current decision.' }, deals: [offered] } as unknown as GameView;
|
||||
const calls: Action[] = [];
|
||||
const accept = find(buttons(game, calls), 'Accept deal');
|
||||
assert.ok(accept); assert.equal(accept.props.disabled, false);
|
||||
(accept.props.onClick as () => void)();
|
||||
assert.deepEqual(calls, [{ type: 'dealRespond', deal: 'deal', accept: true }]);
|
||||
const expired = { ...game, turn: 3 };
|
||||
assert.match(html(expired), /due turn has passed/);
|
||||
assert.equal(find(buttons(expired, calls), 'Accept deal')?.props.disabled, true);
|
||||
assert.equal(find(buttons(expired, calls), 'Decline deal')?.props.disabled, false);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(selected ? { 'X-Dune-Seat': selected } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
data: await response.json() as GameView & { error?: string } };
|
||||
}
|
||||
|
||||
/** This HTTP test creates only its own room on the isolated test server. It
|
||||
* neither reuses nor deletes any existing room or local development game. */
|
||||
void test('authenticated participants activate and the exact payer settles a deferred public deal once', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Deferred deal recipient', faction: 'atreides', advanced: false, expansions: [],
|
||||
});
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const payer = await request(path, undefined, { type: 'join', name: 'Deferred deal payer', faction: 'harkonnen' });
|
||||
assert.equal(payer.status, 200, JSON.stringify(payer.data)); assert.ok(payer.cookie);
|
||||
const third = await request(path, undefined, { type: 'join', name: 'Deferred deal witness', faction: 'emperor' });
|
||||
assert.equal(third.status, 200, JSON.stringify(third.data)); assert.ok(third.cookie);
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: host.cookie },
|
||||
{ id: payer.data.me, cookie: payer.cookie },
|
||||
{ id: third.data.me, cookie: third.cookie },
|
||||
];
|
||||
let view = third.data;
|
||||
const act = async (cookie: string, action: object) => {
|
||||
const next = await request(path, cookie, { version: view.version, action });
|
||||
assert.equal(next.status, 200, JSON.stringify(next.data)); view = next.data; return next.data;
|
||||
};
|
||||
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
|
||||
await act(host.cookie, { type: 'start' });
|
||||
for (const seat of seats) {
|
||||
const own = await request(path, seat.cookie);
|
||||
assert.equal(own.status, 200, JSON.stringify(own.data)); view = own.data;
|
||||
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
|
||||
if (choices?.length) await act(seat.cookie, { type: 'traitor', leader: choices[0] });
|
||||
}
|
||||
assert.equal(view.status, 'playing'); assert.equal(view.phase, 0);
|
||||
const payerBefore = await request(path, payer.cookie);
|
||||
const recipientBefore = await request(path, host.cookie);
|
||||
assert.equal(payerBefore.status, 200); assert.equal(recipientBefore.status, 200);
|
||||
const payerSpice = payerBefore.data.players.find(player => player.id === payer.data.me)!.spice;
|
||||
const recipientSpice = recipientBefore.data.players.find(player => player.id === host.data.me)!.spice;
|
||||
const recipientIncoming = recipientBefore.data.bribeOptions.incoming;
|
||||
assert.ok(typeof payerSpice === 'number' && payerSpice >= 3);
|
||||
|
||||
const payment = { kind: 'deferredBribe', payer: payer.data.me, recipient: host.data.me,
|
||||
amount: 3, dueTurn: view.turn };
|
||||
await act(host.cookie, { type: 'dealOffer', targets: [payer.data.me, third.data.me],
|
||||
terms: 'Pay the agreed spice this turn.', payment });
|
||||
const deal = view.deals.at(-1); assert.ok(deal); assert.equal(deal.status, 'offered');
|
||||
assert.deepEqual(deal.payment, payment);
|
||||
await act(payer.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'offered');
|
||||
await act(third.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'active');
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'pending');
|
||||
for (const seat of seats) {
|
||||
const read = await request(path, seat.cookie); assert.equal(read.status, 200, JSON.stringify(read.data));
|
||||
assert.equal(read.data.deals.find(row => row.id === deal.id)?.paymentStatus, 'pending');
|
||||
}
|
||||
const pendingPayer = await request(path, payer.cookie);
|
||||
const pendingRecipient = await request(path, host.cookie);
|
||||
assert.equal(pendingPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
|
||||
assert.equal(pendingRecipient.data.bribeOptions.incoming, recipientIncoming);
|
||||
assert.equal(pendingPayer.data.deferredDealPaymentOptions[deal.id]?.blocked, null);
|
||||
assert.equal(pendingRecipient.data.deferredDealPaymentOptions[deal.id], undefined);
|
||||
|
||||
const pay = { type: 'dealPay', deal: deal.id };
|
||||
const otherSeat = await request(path, host.cookie, { version: view.version, action: pay });
|
||||
assert.equal(otherSeat.status, 409, JSON.stringify(otherSeat.data));
|
||||
const forged = await request(path, host.cookie, { version: view.version, action: pay }, payer.data.me);
|
||||
assert.equal(forged.status, 409, JSON.stringify(forged.data));
|
||||
const previousVersion = view.version;
|
||||
await act(payer.cookie, pay);
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'paid');
|
||||
const paidPayer = await request(path, payer.cookie);
|
||||
const paidRecipient = await request(path, host.cookie);
|
||||
assert.equal(paidPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice - 3);
|
||||
assert.equal(paidRecipient.data.players.find(player => player.id === host.data.me)!.spice, recipientSpice);
|
||||
assert.equal(paidRecipient.data.bribeOptions.incoming, recipientIncoming + 3);
|
||||
const stale = await request(path, payer.cookie, { version: previousVersion, action: pay });
|
||||
assert.equal(stale.status, 409, JSON.stringify(stale.data));
|
||||
const replay = await request(path, payer.cookie, { version: view.version, action: pay });
|
||||
assert.equal(replay.status, 409, JSON.stringify(replay.data));
|
||||
assert.equal((await request(path, host.cookie)).data.bribeOptions.incoming, recipientIncoming + 3);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthBribe as act, createTruthBribeFixture as fixture,
|
||||
truthBribeInventory as inventory, truthBribePlayer as player,
|
||||
} from './fixture-truth-bribe';
|
||||
import { enterMentatBribeWithUnpaidPromise as enterMentat, finishMentatBribe as finish } from './fixture-mentat-bribes';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected action leaves the source state unchanged.');
|
||||
}
|
||||
|
||||
/** This is a genuine public H/A/E Basic source at Collection. Its starting
|
||||
* deal/auction and Spice cards were selected while unopened; all payments and
|
||||
* Mentat transitions below are actual native actions, with no purse staging. */
|
||||
void test('already paid bribe escrow can fund a deferred deal at this turn’s real Mentat opening', () => {
|
||||
const f = fixture({ phase: 7 }), initial = f.game;
|
||||
assert.equal(initial.phase, 7);
|
||||
assert.equal(player(initial, f.asker).spice, 10);
|
||||
// H spends nine now, then receives an independently completed E→H bribe.
|
||||
let game = act(initial, f.asker, { type: 'bribe', target: f.opponent, amount: 9 });
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
assert.equal(player(game, f.asker).spice, 1);
|
||||
assert.equal(player(game, f.asker).bribes, 3);
|
||||
assert.equal(player(game, f.opponent).bribes, 9);
|
||||
|
||||
// A has not yet received its own nine either; E's three are already in
|
||||
// H's native escrow and cannot be spent until this turn's real Mentat.
|
||||
game = act(game, f.asker, { type: 'dealOffer', target: f.opponent,
|
||||
terms: 'Pay four after the upcoming Mentat opening.',
|
||||
payment: { kind: 'deferredBribe', payer: f.asker, recipient: f.opponent, amount: 4, dueTurn: game.turn } });
|
||||
const id = game.deals!.at(-1)!.id;
|
||||
game = act(game, f.opponent, { type: 'dealRespond', deal: id, accept: true });
|
||||
assert.equal(game.deals!.at(-1)!.paymentStatus, 'pending');
|
||||
assert.equal(player(game, f.asker).spice, 1);
|
||||
assert.equal(player(game, f.asker).bribes, 3);
|
||||
rejected(game, f.asker, { type: 'dealPay', deal: id });
|
||||
rejected(game, f.asker, { type: 'bribe', target: f.target, amount: 1 });
|
||||
|
||||
const mentat = enterMentat(game);
|
||||
assert.equal(mentat.phase, 8); assert.equal(mentat.turn, initial.turn);
|
||||
assert.equal(player(mentat, f.asker).spice, 4);
|
||||
assert.equal(player(mentat, f.asker).bribes, 0);
|
||||
assert.equal(player(mentat, f.opponent).bribes, 0);
|
||||
const paid = act(mentat, f.asker, { type: 'dealPay', deal: id });
|
||||
assert.equal(paid.deals!.find(deal => deal.id === id)!.paymentStatus, 'paid');
|
||||
assert.equal(player(paid, f.asker).spice, 0);
|
||||
assert.equal(player(paid, f.opponent).bribes, 4,
|
||||
'A payment after this Mentat opening waits for the next opening.');
|
||||
const next = finish(paid);
|
||||
assert.equal(next.turn, initial.turn + 1); assert.equal(next.phase, 0);
|
||||
assert.equal(player(next, f.opponent).bribes, 4);
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, createMentatBribeFixture as fixture,
|
||||
mentatBribeInventory as inventory, mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function agree(game: Game, proposer: string, payer: string) {
|
||||
const offered = act(game, proposer, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
|
||||
payment: { kind: 'deferredBribe', payer, recipient: proposer, amount: 4, dueTurn: game.turn } });
|
||||
const id = offered.deals!.at(-1)!.id;
|
||||
return { game: act(offered, payer, { type: 'dealRespond', deal: id, accept: true }), id };
|
||||
}
|
||||
const spice = (game: Game) => game.players.reduce((sum, seat) => sum + seat.spice + seat.bribes, 0);
|
||||
|
||||
for (const pick of ['first', 'second'] as const) {
|
||||
void test(`external shortage preserves both individually payable deals until payer chooses ${pick}`, () => {
|
||||
// The fixture reaches actual Basic Mentat with Emperor's original ten
|
||||
// spice. Both independent agreements are really offered and accepted.
|
||||
const f = fixture(), first = agree(f.game, f.asker, f.target);
|
||||
const second = agree(first.game, f.asker, f.target);
|
||||
const staged = structuredClone(second.game), target = player(staged, f.target), other = player(staged, f.opponent);
|
||||
const originalSpice = spice(staged);
|
||||
// Model an external loss: transfer five existing spice, not create or
|
||||
// destroy it. Each four-spice payment is now possible on its own.
|
||||
target.spice -= 5; other.spice += 5;
|
||||
const waiting = act(staged, f.opponent, { type: 'ready' });
|
||||
assert.equal(waiting.deals!.find(row => row.id === first.id)!.paymentStatus, 'pending');
|
||||
assert.equal(waiting.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
|
||||
const options = viewGame(waiting, f.target).deferredDealPaymentOptions;
|
||||
assert.equal(options[first.id]?.blocked, null);
|
||||
assert.equal(options[second.id]?.blocked, null);
|
||||
|
||||
const chosen = pick === 'first' ? first.id : second.id;
|
||||
const unchosen = pick === 'first' ? second.id : first.id;
|
||||
const paid = act(waiting, f.target, { type: 'dealPay', deal: chosen });
|
||||
assert.equal(paid.deals!.find(row => row.id === chosen)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(row => row.id === unchosen)!.paymentStatus, 'void');
|
||||
assert.equal(player(paid, f.target).spice, 1);
|
||||
assert.equal(player(paid, f.asker).bribes, 4);
|
||||
assert.equal(spice(paid), originalSpice);
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Game } from '../game/engine';
|
||||
import {
|
||||
actTruthBribe as act, createTruthBribeFixture as fixture,
|
||||
truthBribeInventory as inventory, truthBribePlayer as player,
|
||||
} from './fixture-truth-bribe';
|
||||
|
||||
function agree(game: Game, recipient: string, payer: string, amount: number) {
|
||||
const offered = act(game, recipient, { type: 'dealOffer', target: payer, terms: 'Pay this turn.',
|
||||
payment: { kind: 'deferredBribe', payer, recipient, amount, dueTurn: game.turn } });
|
||||
const deal = offered.deals!.at(-1)!;
|
||||
const accepted = act(offered, payer, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
return { game: accepted, id: deal.id };
|
||||
}
|
||||
|
||||
void test('a temporarily allied recipient does not let the payer evade a different, still-payable deal', () => {
|
||||
// Real Basic setup, ordinary auction, hands and ten-spice Emperor purse.
|
||||
// Both agreements are genuinely proposed and unanimously accepted. Only
|
||||
// their later mutual alliance is staged; no card or spice is fabricated.
|
||||
const f = fixture(), first = agree(f.game, f.asker, f.target, 4);
|
||||
const second = agree(first.game, f.opponent, f.target, 3);
|
||||
const game = structuredClone(second.game);
|
||||
player(game, f.target).ally = f.asker;
|
||||
player(game, f.asker).ally = f.target;
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.equal(game.deals!.find(deal => deal.id === first.id)!.paymentStatus, 'pending');
|
||||
assert.equal(game.deals!.find(deal => deal.id === second.id)!.paymentStatus, 'pending');
|
||||
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.opponent, amount: 8 }),
|
||||
/deal|payment|fund/i, 'Spending below the other payable deal cannot use an unrelated temporarily blocked term as an escape.');
|
||||
assert.deepEqual(game, before, 'Rejected spending preserves both agreements and the complete source state.');
|
||||
|
||||
const paid = act(game, f.target, { type: 'dealPay', deal: second.id });
|
||||
assert.equal(paid.deals!.find(deal => deal.id === second.id)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(deal => deal.id === first.id)!.paymentStatus, 'pending');
|
||||
assert.equal(player(paid, f.target).spice, 7);
|
||||
assert.equal(player(paid, f.opponent).bribes, 3);
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { PublicDeal } from '../game/public-deals';
|
||||
import {
|
||||
actMentatBribe as act, createMentatBribeFixture as fixture,
|
||||
finishMentatBribe as finish, mentatBribeInventory as inventory,
|
||||
mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
type DeferredDeal = PublicDeal & { paymentStatus?: 'pending' | 'paid' | 'void' | 'released' };
|
||||
const getDeal = (game: Game, id: string): DeferredDeal => {
|
||||
const deal = game.deals?.find(row => row.id === id); assert.ok(deal); return deal as DeferredDeal;
|
||||
};
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected packets preserve the original game.');
|
||||
}
|
||||
function offer(game: Game, proposer: string, payer: string, recipient: string, amount: number,
|
||||
targets: string[] = [payer]) {
|
||||
const payment = { kind: 'deferredBribe', payer, recipient, amount, dueTurn: game.turn };
|
||||
const fields = targets.length === 1 ? { target: targets[0] } : { targets };
|
||||
const next = act(game, proposer, { type: 'dealOffer', ...fields, terms: 'Pay the agreed spice this turn.', payment });
|
||||
const deal = next.deals?.at(-1); assert.ok(deal);
|
||||
return { game: next, id: deal.id, payment };
|
||||
}
|
||||
function accept(game: Game, id: string, actors: string[]) {
|
||||
for (const actor of actors) game = act(game, actor, { type: 'dealRespond', deal: id, accept: true });
|
||||
return game;
|
||||
}
|
||||
function pay(game: Game, payer: string, id: string) {
|
||||
return act(game, payer, { type: 'dealPay', deal: id });
|
||||
}
|
||||
|
||||
/** Each test reaches real Basic turn-one Mentat through original setup,
|
||||
* bidding and Collection. The source orders only unopened physical cards;
|
||||
* no dealt hand, purse, clock or agreement is installed. */
|
||||
void test('unanimous deferred consent creates a pending exact payment without moving spice', () => {
|
||||
const f = fixture(), before = f.game;
|
||||
const { game: proposed, id, payment } = offer(before, f.opponent, f.target, f.asker, 3, [f.asker, f.target]);
|
||||
const first = accept(proposed, id, [f.asker]);
|
||||
assert.equal(getDeal(first, id).status, 'offered');
|
||||
assert.equal(player(first, f.target).spice, player(before, f.target).spice);
|
||||
const active = accept(first, id, [f.target]);
|
||||
assert.equal(getDeal(active, id).status, 'active');
|
||||
assert.equal(getDeal(active, id).paymentStatus, 'pending');
|
||||
assert.deepEqual(getDeal(active, id).payment, payment);
|
||||
assert.equal(player(active, f.target).spice, player(before, f.target).spice);
|
||||
assert.equal(player(active, f.asker).bribes, player(before, f.asker).bribes);
|
||||
assert.deepEqual(viewGame(active, f.opponent).deals.find(row => row.id === id), getDeal(active, id));
|
||||
rejected(active, f.opponent, { type: 'dealPay', deal: id });
|
||||
const paid = pay(active, f.target, id);
|
||||
assert.equal(getDeal(paid, id).paymentStatus, 'paid');
|
||||
assert.equal(player(paid, f.target).spice, player(before, f.target).spice - 3);
|
||||
assert.equal(player(paid, f.asker).bribes, player(before, f.asker).bribes + 3);
|
||||
assert.equal(player(paid, f.asker).spice, player(before, f.asker).spice);
|
||||
rejected(paid, f.target, { type: 'dealPay', deal: id });
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two independently agreed debts need two exact native transfers', () => {
|
||||
const f = fixture();
|
||||
const first = offer(f.game, f.asker, f.target, f.asker, 3);
|
||||
let game = accept(first.game, first.id, [f.target]);
|
||||
const second = offer(game, f.asker, f.target, f.asker, 4);
|
||||
game = accept(second.game, second.id, [f.target]);
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
game = pay(game, f.target, second.id);
|
||||
assert.equal(getDeal(game, first.id).paymentStatus, 'pending');
|
||||
assert.equal(getDeal(game, second.id).paymentStatus, 'paid');
|
||||
assert.equal(player(game, f.target).spice, 6);
|
||||
game = pay(game, f.target, first.id);
|
||||
assert.equal(player(game, f.target).spice, 3);
|
||||
assert.equal(player(game, f.asker).bribes, 7);
|
||||
assert.equal(getDeal(game, first.id).paymentStatus, 'paid');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('unanimous release removes only the unpaid agreement and never refunds a paid one', () => {
|
||||
const f = fixture();
|
||||
const first = offer(f.game, f.asker, f.target, f.asker, 3);
|
||||
let game = accept(first.game, first.id, [f.target]);
|
||||
const second = offer(game, f.asker, f.target, f.asker, 4);
|
||||
game = accept(second.game, second.id, [f.target]);
|
||||
game = pay(game, f.target, first.id);
|
||||
game = act(game, f.asker, { type: 'dealRelease', deal: second.id, agree: true });
|
||||
assert.equal(getDeal(game, second.id).paymentStatus, 'pending');
|
||||
game = act(game, f.target, { type: 'dealRelease', deal: second.id, agree: true });
|
||||
assert.equal(getDeal(game, second.id).paymentStatus, 'released');
|
||||
rejected(game, f.target, { type: 'dealPay', deal: second.id });
|
||||
game = act(game, f.asker, { type: 'dealRelease', deal: first.id, agree: true });
|
||||
game = act(game, f.target, { type: 'dealRelease', deal: first.id, agree: true });
|
||||
assert.equal(getDeal(game, first.id).paymentStatus, 'paid');
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
assert.equal(player(game, f.asker).bribes, 3);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('voluntary spending preserves enough cash for each accepted exact payment', () => {
|
||||
const f = fixture();
|
||||
const first = offer(f.game, f.asker, f.target, f.asker, 3);
|
||||
let game = accept(first.game, first.id, [f.target]);
|
||||
const second = offer(game, f.asker, f.target, f.asker, 4);
|
||||
game = accept(second.game, second.id, [f.target]);
|
||||
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
|
||||
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
game = pay(game, f.target, first.id);
|
||||
game = pay(game, f.target, second.id);
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(player(game, f.opponent).bribes, 3);
|
||||
assert.equal(player(game, f.asker).bribes, 7);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a payable current-turn debt blocks its payer ending Mentat, then real turn advance retains the receipt', () => {
|
||||
const f = fixture();
|
||||
const offerResult = offer(f.game, f.asker, f.target, f.asker, 3);
|
||||
let game = accept(offerResult.game, offerResult.id, [f.target]);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = pay(game, f.target, offerResult.id);
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
const next = finish(game);
|
||||
assert.equal(next.turn, f.game.turn + 1); assert.equal(next.phase, 0);
|
||||
assert.equal(getDeal(next, offerResult.id).paymentStatus, 'paid');
|
||||
assert.equal(player(next, f.asker).bribes, 3, 'Late Mentat transfers wait for the next Mentat opening.');
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a staged externally lost purse is voided on another player’s actual next action', () => {
|
||||
const f = fixture();
|
||||
const offered = offer(f.game, f.asker, f.target, f.asker, 3);
|
||||
let game = accept(offered.game, offered.id, [f.target]);
|
||||
// Controlled external loss: move existing spice, never create/destroy it.
|
||||
const staged = structuredClone(game), target = player(staged, f.target), other = player(staged, f.opponent);
|
||||
target.spice -= 8; other.spice += 8;
|
||||
game = act(staged, f.opponent, { type: 'ready' });
|
||||
assert.equal(getDeal(game, offered.id).paymentStatus, 'void');
|
||||
rejected(game, f.target, { type: 'dealPay', deal: offered.id });
|
||||
assert.equal(player(game, f.target).spice, 2);
|
||||
assert.equal(player(game, f.asker).bribes, 0);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, askMentatBribe as ask,
|
||||
createMentatBribeFixture as fixture, declareMentatBribe as declare,
|
||||
mentatBribeInventory as inventory, mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected answer or action must not mutate the source.');
|
||||
}
|
||||
function offer(game: Game, asker: string, target: string, amount: number) {
|
||||
game = act(game, asker, { type: 'dealOffer', target, terms: 'A payment before this turn ends.',
|
||||
payment: { kind: 'deferredBribe', payer: target, recipient: asker, amount, dueTurn: game.turn } });
|
||||
const id = game.deals!.at(-1)!.id;
|
||||
return { game: act(game, target, { type: 'dealRespond', deal: id, accept: true }), id };
|
||||
}
|
||||
function openQuestion(f: ReturnType<typeof fixture>, game: Game, minimum: number) {
|
||||
const opened = declare(f, game);
|
||||
return ask(opened.game, f.asker, f.target, f.asker, minimum);
|
||||
}
|
||||
|
||||
/** Genuine Basic H/A/E turn-one Mentat; the fixture preorders unopened cards,
|
||||
* not an already-dealt hand, purse, promise, battle, or phase. */
|
||||
void test('a matching Truthtrance Yes remains legal and a conflicting No is not offered', () => {
|
||||
const f = fixture(), agreed = offer(f.game, f.asker, f.target, 3);
|
||||
const asked = openQuestion(f, agreed.game, 3);
|
||||
const options = viewGame(asked, f.target).truthBribeAnswers;
|
||||
assert.ok(options);
|
||||
assert.ok(options.includes('yes'));
|
||||
assert.ok(!options.includes('no'), 'A no answer cannot contradict the earlier accepted exact payment.');
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.answer && !row.fulfilled));
|
||||
game = act(game, f.target, { type: 'dealPay', deal: agreed.id });
|
||||
assert.equal(game.deals!.find(row => row.id === agreed.id)!.paymentStatus, 'paid');
|
||||
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.fulfilled));
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
assert.equal(player(game, f.asker).bribes, 3);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('one payment can satisfy a Truthtrance minimum but cannot satisfy two distinct deals', () => {
|
||||
const f = fixture(), first = offer(f.game, f.asker, f.target, 3);
|
||||
const second = offer(first.game, f.asker, f.target, 3);
|
||||
const asked = openQuestion(f, second.game, 3);
|
||||
assert.ok(viewGame(asked, f.target).truthBribeAnswers?.includes('yes'));
|
||||
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'dealPay', deal: first.id });
|
||||
assert.ok(game.bribePromises?.some(row => row.player === f.target && row.recipient === f.asker && row.fulfilled));
|
||||
assert.equal(game.deals!.find(row => row.id === first.id)!.paymentStatus, 'paid');
|
||||
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'pending');
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, { type: 'dealPay', deal: second.id });
|
||||
assert.equal(game.deals!.find(row => row.id === second.id)!.paymentStatus, 'paid');
|
||||
assert.equal(player(game, f.target).spice, 4);
|
||||
assert.equal(player(game, f.asker).bribes, 6);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user