Support future-turn public deal payments
This commit is contained in:
1 parent
bb193d8f8c
commit
7d71647a90
16 files changed
+641
-35
No files matched your search
+22
-12
@@ -28,20 +28,21 @@ export function publicDealRequests(game: GameView): string[][] {
|
||||
requests.push(['release', deal.id, ...deal.releaseVotes]);
|
||||
const pending = deal as unknown as { payment?: DealPayment; paymentStatus?: DeferredPaymentStatus };
|
||||
if (deal.status === 'active' && pending.payment?.kind === 'deferredBribe' &&
|
||||
pending.payment.payer === game.me && pending.paymentStatus === 'pending')
|
||||
pending.payment.payer === game.me && pending.paymentStatus === 'pending' && pending.payment.dueTurn <= game.turn)
|
||||
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?: DealPayment, proposer?: string): Action | null {
|
||||
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: DealPayment, proposer?: string, currentTurn?: number): Action | null {
|
||||
const text = terms.trim();
|
||||
if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null;
|
||||
if (payment && (!proposer || (payment.kind !== 'immediateBribe' && payment.kind !== 'deferredBribe') || payment.payer === payment.recipient ||
|
||||
![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) ||
|
||||
!Number.isSafeInteger(payment.amount) || payment.amount < 1 ||
|
||||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1)))) return null;
|
||||
(payment.kind === 'deferredBribe' && (!Number.isSafeInteger(payment.dueTurn) || payment.dueTurn < 1 ||
|
||||
(currentTurn !== undefined && (!Number.isSafeInteger(currentTurn) || currentTurn < 1 || payment.dueTurn < currentTurn)))))) return null;
|
||||
const offeredPayment = payment ? { payment } : {};
|
||||
return targets.length === 1
|
||||
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment }
|
||||
@@ -75,7 +76,7 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
const paymentStatus = (deal as unknown as { paymentStatus?: DeferredPaymentStatus }).paymentStatus;
|
||||
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
|
||||
const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null;
|
||||
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn !== payment.dueTurn;
|
||||
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;
|
||||
@@ -83,7 +84,7 @@ export function PublicDealList({ game, act, disabled }: {
|
||||
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
|
||||
<p>{deal.terms}</p>
|
||||
{payment?.kind === 'immediateBribe' && <p><strong>Immediate payment term:</strong> {name(payment.payer)} pays {payment.amount} spice to {name(payment.recipient)} on final acceptance. The payer loses the gross amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before that share is allocated. Releasing the deal later does not refund the payment.</p>}
|
||||
{payment?.kind === '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>}
|
||||
{payment?.kind === 'deferredBribe' && <p><strong>Deferred payment term:</strong> {name(payment.payer)} pays exactly {payment.amount} spice to {name(payment.recipient)} on or before the end of turn {payment.dueTurn}. The recipient collects their share at the next Mentat opening; a Bureaucrat may divert two spice to the bank. {paymentStatus ? `Payment status: ${paymentStatus}.` : 'Payment becomes binding on final acceptance.'}</p>}
|
||||
{deal.status === 'offered' && <p>
|
||||
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||
</p>}
|
||||
@@ -125,6 +126,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
const [payer, setPayer] = useState('');
|
||||
const [paymentRecipient, setPaymentRecipient] = useState('');
|
||||
const [paymentAmount, setPaymentAmount] = useState('1');
|
||||
const [paymentDueTurn, setPaymentDueTurn] = useState(() => String(game.turn));
|
||||
const requests = publicDealRequests(game);
|
||||
const needsResponse = requests.length > 0;
|
||||
const paymentDue = requests.some(request => request[0] === 'payment');
|
||||
@@ -142,15 +144,18 @@ 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 dueTurn = Number(paymentDueTurn);
|
||||
const dueValid = Number.isSafeInteger(dueTurn) && dueTurn >= game.turn;
|
||||
const payment: DealPayment | undefined = includePayment
|
||||
? paymentKind === 'immediateBribe'
|
||||
? { kind: paymentKind, payer, recipient: paymentRecipient, amount }
|
||||
: { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn: game.turn }
|
||||
: { kind: paymentKind, payer, recipient: paymentRecipient, amount, dueTurn }
|
||||
: undefined;
|
||||
const paymentValid = !includePayment || !!me && payer !== paymentRecipient &&
|
||||
const paymentPeopleAndAmountValid = !!me && payer !== paymentRecipient &&
|
||||
parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) &&
|
||||
Number.isSafeInteger(amount) && amount > 0;
|
||||
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id) : null;
|
||||
const paymentValid = !includePayment || paymentPeopleAndAmountValid && (paymentKind !== 'deferredBribe' || dueValid);
|
||||
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id, game.turn) : null;
|
||||
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
|
||||
const mayOffer = game.status === 'playing' && me && !me.bot;
|
||||
return <details ref={panel} className="notice public-deals-panel">
|
||||
@@ -180,7 +185,7 @@ 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 or a payment due this turn
|
||||
Include a spice payment
|
||||
</label>
|
||||
{includePayment && <fieldset>
|
||||
<legend>Payment term</legend>
|
||||
@@ -188,7 +193,7 @@ export function PublicDeals({ game, act, busy }: {
|
||||
<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>
|
||||
<option value="deferredBribe">By the end of a current or future turn</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Paying participant
|
||||
@@ -209,10 +214,15 @@ 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>
|
||||
{paymentKind === 'deferredBribe' && <label>Payment due by the end of turn
|
||||
<input type="number" inputMode="numeric" min={game.turn} step={1} value={paymentDueTurn} disabled={disabled}
|
||||
required aria-invalid={!dueValid} onChange={event => setPaymentDueTurn(event.target.value)} />
|
||||
</label>}
|
||||
{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>}
|
||||
: <p className="fine">After all participants accept, the payer can pay on or before the end of the selected 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 && !paymentPeopleAndAmountValid && <p>Choose two different participants and a positive whole spice amount.</p>}
|
||||
{paymentKind === 'deferredBribe' && !dueValid && <p role="alert">Choose a whole due turn no earlier than turn {game.turn}.</p>}
|
||||
</fieldset>}
|
||||
<label>Public deal terms
|
||||
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
|
||||
|
||||
@@ -16,6 +16,18 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Public-deal payments due on later turns — 28 October. Prototyped /
|
||||
Partial.** Players may set a future turn and settle early or on the deadline.
|
||||
Before that turn the obligation does not reserve current spice or assume future
|
||||
income; once due, existing supported current-turn feasibility applies. Separate
|
||||
deals are paid separately, and unanimous release remains available. The 25
|
||||
related offline files pass 146 cases, including native turn progression, early
|
||||
and due payment, legal autopilot, release and a Basic Mentat shortfall. One
|
||||
isolated authenticated HTTP case covers consent, authority, early payment and
|
||||
replay. Type checking, lint and an isolated production build pass. Broader rule
|
||||
combinations, browser and deployed acceptance remain open.
|
||||
[Scope](PUBLIC_DEALS.md#structured-payments-due-in-a-later-turn--28-october-2026).
|
||||
|
||||
**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
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Public deal records
|
||||
|
||||
## Structured payments due in a later turn — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A structured payment may name its offer turn or a
|
||||
later turn as its deadline. The parties must all accept by that deadline. The
|
||||
payer can make the exact native bribe early or during its due turn; separate
|
||||
agreements require separate payments. Unanimous release cancels an unpaid term.
|
||||
|
||||
Future payments do not reserve today's spice or rely on imagined future income.
|
||||
When the deadline arrives, the existing supported current-turn payment and
|
||||
feasibility policy applies. Unpaid terms expire when the deadline passes or the
|
||||
game ends. Autopilot waits until the due turn before paying. An early payment
|
||||
must respect obligations already due. These deadline and feasibility policies
|
||||
are provisional implementations of public agreements, not publisher rulings.
|
||||
Broader combinations, free-text enforcement, browser and deployed acceptance
|
||||
remain open.
|
||||
|
||||
## Structured payment due this turn — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A public offer may name a payer, recipient and exact
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Structured public-deal payments due later — 28 October 2026
|
||||
|
||||
The publisher permits legal public agreements but does not define an online
|
||||
payment receipt or a forecast across turns. A later deadline, optional early
|
||||
payment and the absence of a future-income or current-funds reservation are
|
||||
provisional software policies. Due-turn payments retain the existing supported
|
||||
feasibility rules. [Scope](PUBLIC_DEALS.md#structured-payments-due-in-a-later-turn--28-october-2026).
|
||||
|
||||
## Structured public-deal payment due this turn — 28 October 2026
|
||||
|
||||
The publisher permits binding legal public agreements but does not define this
|
||||
|
||||
+4
-1
@@ -4299,7 +4299,10 @@ export function botActions(g: GameView): Action[] {
|
||||
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);
|
||||
const payable = Object.entries(g.deferredDealPaymentOptions ?? {}).find(([id,option]) => {
|
||||
const payment = g.deals.find(deal => deal.id === id)?.payment;
|
||||
return !option.blocked && payment?.kind === 'deferredBribe' && payment.dueTurn <= g.turn;
|
||||
});
|
||||
if (payable) return [{type:'dealPay',deal:payable[0]}];
|
||||
}
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
|
||||
+26
-10
@@ -6,7 +6,7 @@ import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactio
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
import { pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError } from './public-deal-obligations';
|
||||
import { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -5929,15 +5929,26 @@ function publicDealInsolventAtBaseMentat(g: Game, player: string): boolean {
|
||||
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],player,g.turn)) >
|
||||
Math.max(0,uncommittedSpice(g,getPlayer(g,player)));
|
||||
}
|
||||
/** An insolvent payer may choose among currently affordable payments, but an
|
||||
* early future payment must not remove that choice at the current deadline. */
|
||||
function protectInsolventDueDeal(g: Game, payer: string, payment: PendingPublicDealPayment): void {
|
||||
if (payment.dueTurn === g.turn || !publicDealInsolventAtBaseMentat(g,payer)) return;
|
||||
const seat = getPlayer(g,payer), available = Math.max(0,uncommittedSpice(g,seat));
|
||||
const affordable = publicDealObligationRule(() => pendingPublicDealPayments(g.deals ?? [],payer,g.turn));
|
||||
requireRule(!affordable.some(debt => debt.recipient !== seat.ally && debt.amount <= available &&
|
||||
debt.amount > available - payment.amount),
|
||||
'Keep the funding for a currently payable deal before paying a later deal.');
|
||||
}
|
||||
/** 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)) {
|
||||
for (const debt of outstandingPublicDealPayments(g.deals ?? [],player,g.turn)) {
|
||||
try {
|
||||
protectInsolventDueDeal(g,player,debt);
|
||||
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)});
|
||||
{insolventDealPayment:debt.dueTurn === g.turn && publicDealInsolventAtBaseMentat(g,player)});
|
||||
result[debt.deal] = {blocked:null};
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
@@ -40343,11 +40354,12 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
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.');
|
||||
requireRule(payment?.kind === 'deferredBribe' && payment.dueTurn >= g.turn,
|
||||
'This deferred payment is no longer available.');
|
||||
protectInsolventDueDeal(state,id,{deal:current.id,...payment});
|
||||
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)});
|
||||
{insolventDealPayment:payment.dueTurn === state.turn && publicDealInsolventAtBaseMentat(state,id)});
|
||||
}
|
||||
if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
|
||||
requireRule(state.status === 'playing', 'Public deals require a game in progress.');
|
||||
@@ -40386,6 +40398,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
let next: PublicDeal;
|
||||
if (action.type === 'dealRespond') {
|
||||
requireRule(typeof action.accept === 'boolean', 'Choose whether to accept or decline.');
|
||||
requireRule(!action.accept || current.payment?.kind !== 'deferredBribe' || current.payment.dueTurn >= g.turn,
|
||||
'The deferred payment deadline has passed; decline or withdraw this offer.');
|
||||
next = publicDealRule(() => respondPublicDeal(current, g.players, id, action.accept as boolean));
|
||||
} else if (action.type === 'dealRelease') {
|
||||
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
|
||||
@@ -40394,11 +40408,13 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
g.deals = deals.map((deal, position) => position === index ? next : deal);
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'deferredBribe') {
|
||||
const payer = getPlayer(g,next.payment.payer);
|
||||
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)) &&
|
||||
requireRule(next.payment.dueTurn >= g.turn &&
|
||||
(next.payment.dueTurn > g.turn || publicDealCompletion(g,payer)),
|
||||
'The agreed payment needs an unexpired deadline and, when due now, a legal funded completion.');
|
||||
if (next.payment.dueTurn === g.turn &&
|
||||
(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}.`);
|
||||
log(g, `${payer.name} agreed to pay ${next.payment.amount} spice to ${getPlayer(g,next.payment.recipient).name} by the end of turn ${next.payment.dueTurn}.`);
|
||||
}
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'immediateBribe') {
|
||||
// The payer consented to these immutable terms. Run the native action
|
||||
|
||||
@@ -23,9 +23,10 @@ const identifier = (value: unknown): value is string =>
|
||||
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(
|
||||
/** Return a payer's unsettled active terms that can still be paid now,
|
||||
* including future deadlines. Past-due rows stay in the authoritative ledger
|
||||
* for the engine's expiry reconciliation; this helper never voids them. */
|
||||
export function outstandingPublicDealPayments(
|
||||
deals: readonly PublicDeal[], payer: string, turn: number,
|
||||
): PendingPublicDealPayment[] {
|
||||
if (!Array.isArray(deals) || !identifier(payer) || !Number.isSafeInteger(turn) || turn < 1)
|
||||
@@ -33,11 +34,19 @@ export function pendingPublicDealPayments(
|
||||
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 [];
|
||||
payment?.kind !== 'deferredBribe' || payment.payer !== payer || payment.dueTurn < turn) return [];
|
||||
return [{ deal: deal.id, payer, recipient: payment.recipient, dueTurn: payment.dueTurn, amount: payment.amount }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Existing turn-specific planners and funding totals consider only payments
|
||||
* due in that turn. They must not reserve every future promise immediately. */
|
||||
export function pendingPublicDealPayments(
|
||||
deals: readonly PublicDeal[], payer: string, turn: number,
|
||||
): PendingPublicDealPayment[] {
|
||||
return outstandingPublicDealPayments(deals,payer,turn).filter(payment => payment.dueTurn === turn);
|
||||
}
|
||||
|
||||
/** Separate deals add, including two deals for the same recipient. */
|
||||
export function publicDealPaymentTotal(deals: readonly PublicDeal[], payer: string, turn: number): number {
|
||||
let total = 0;
|
||||
|
||||
@@ -94,10 +94,11 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
||||
}
|
||||
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.');
|
||||
Number.isSafeInteger(turn) && (turn as number) > 0 &&
|
||||
typeof value.dueTurn === 'number' && Number.isSafeInteger(value.dueTurn) && value.dueTurn >= (turn as number),
|
||||
'Choose a deferred bribe due no earlier than the deal’s turn.');
|
||||
return { kind: 'deferredBribe', payer: value.payer, recipient: value.recipient,
|
||||
amount: value.amount, dueTurn: turn as number };
|
||||
amount: value.amount, dueTurn: value.dueTurn };
|
||||
}
|
||||
|
||||
/** Validate persisted ledger shape and lifecycle without applying current
|
||||
|
||||
+2
-2
@@ -301,13 +301,13 @@ 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.',
|
||||
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
|
||||
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility. Free text alone pays no spice. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and 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: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and deferred deal payments, including later deadlines, use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
|
||||
|
||||
@@ -22,7 +22,7 @@ void test('deferred deal terms bind an exact positive payment to this turn witho
|
||||
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), dueTurn: 2 }, { ...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);
|
||||
@@ -61,7 +61,7 @@ void test('mutual release and proven impossibility end only an unpaid accepted p
|
||||
const bad = [
|
||||
{ ...first, paymentStatus: undefined }, { ...first, paymentStatus: 'released' },
|
||||
{ ...first, status: 'offered', paymentStatus: 'pending' },
|
||||
{ ...first, payment: { ...deferred(4), dueTurn: 4 } },
|
||||
{ ...first, payment: { ...deferred(4), dueTurn: 2 } },
|
||||
{ ...first, payment: { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 4 }, paymentStatus: 'paid' },
|
||||
];
|
||||
for (const deal of bad) assert.throws(() => validatePublicDeals([deal], seats), PublicDealError);
|
||||
|
||||
@@ -46,7 +46,7 @@ void test('deal form exposes human non-allied recipients and clearly describes l
|
||||
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
assert.match(html, /free-text terms do not automatically transfer spice/);
|
||||
assert.match(html, /Include an immediate spice payment/);
|
||||
assert.match(html, /Include a spice payment/);
|
||||
assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
|
||||
assert.match(html, /maxlength="500"/i);
|
||||
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
finishMentatBribe as finish, mentatBribeInventory as inventory, mentatBribePlayer as player,
|
||||
nextMentatBribeOpening as nextMentat,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function offer(f: ReturnType<typeof fixture>, game: Game, amount: number, dueTurn: number) {
|
||||
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Pay the agreed spice.',
|
||||
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount, dueTurn } });
|
||||
const deal = game.deals!.at(-1)!.id;
|
||||
game = act(game, f.target, { type: 'dealRespond', deal, accept: true });
|
||||
return { game, deal };
|
||||
}
|
||||
|
||||
void test('human autopilot waits for the actual due turn before paying a future deal', () => {
|
||||
const f = fixture();
|
||||
const accepted = offer(f, f.game, 3, f.game.turn + 1);
|
||||
let game = act(accepted.game, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
assert.equal(player(game, f.target).autopilot, 'Easy');
|
||||
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[accepted.deal]?.blocked, null);
|
||||
assert.ok(!botActions(viewGame(game, f.target)).some(action => action.type === 'dealPay'));
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
|
||||
game = finish(game);
|
||||
assert.equal(game.turn, f.game.turn + 1);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
const action = botActions(viewGame(game, f.target))[0];
|
||||
assert.deepEqual(action, { type: 'dealPay', deal: accepted.deal });
|
||||
game = act(game, f.target, action);
|
||||
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.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('an insolvent Mentat payer cannot sacrifice an individually payable current deal for a future one', () => {
|
||||
const f = fixture();
|
||||
const first = offer(f, f.game, 6, f.game.turn + 1);
|
||||
const second = offer(f, first.game, 6, f.game.turn + 1);
|
||||
const future = offer(f, second.game, 2, f.game.turn + 2);
|
||||
const smallerFuture = offer(f, future.game, 1, f.game.turn + 2);
|
||||
let game = act(smallerFuture.game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
game = nextMentat(finish(game));
|
||||
assert.equal(game.turn, f.game.turn + 1);
|
||||
assert.equal(game.phase, 8);
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
for (const deal of [first.deal, second.deal])
|
||||
assert.equal(game.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
|
||||
const before = structuredClone(game);
|
||||
assert.ok(viewGame(game, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
|
||||
assert.throws(() => act(game, f.target, { type: 'dealPay', deal: future.deal }));
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[smallerFuture.deal]?.blocked, null);
|
||||
const sparePaid = act(game, f.target, { type: 'dealPay', deal: smallerFuture.deal });
|
||||
assert.equal(player(sparePaid, f.target).spice, 6);
|
||||
assert.equal(sparePaid.deals!.find(row => row.id === smallerFuture.deal)!.paymentStatus, 'paid');
|
||||
for (const deal of [first.deal, second.deal])
|
||||
assert.equal(sparePaid.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
|
||||
const paid = act(game, f.target, { type: 'dealPay', deal: first.deal });
|
||||
assert.equal(player(paid, f.target).spice, 1);
|
||||
assert.equal(paid.deals!.find(row => row.id === first.deal)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(row => row.id === second.deal)!.paymentStatus, 'void');
|
||||
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
|
||||
void test('early future payment cannot spend the funds needed for a currently due deal', () => {
|
||||
const f = fixture();
|
||||
const due = offer(f, f.game, 8, f.game.turn);
|
||||
const future = offer(f, due.game, 3, f.game.turn + 1);
|
||||
const before = structuredClone(future.game);
|
||||
assert.ok(viewGame(before, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
|
||||
assert.throws(() => act(future.game, f.target, { type: 'dealPay', deal: future.deal }));
|
||||
assert.deepEqual(future.game, before);
|
||||
assert.equal(player(future.game, f.target).spice, 10);
|
||||
assert.equal(future.game.deals!.find(row => row.id === due.deal)!.paymentStatus, 'pending');
|
||||
assert.equal(future.game.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
|
||||
const paid = act(future.game, f.target, { type: 'dealPay', deal: due.deal });
|
||||
assert.equal(player(paid, f.target).spice, 2);
|
||||
assert.equal(player(paid, f.asker).bribes, 8);
|
||||
assert.equal(paid.deals!.find(row => row.id === due.deal)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import * as catalog from '../game/catalog';
|
||||
import * as publicDeals from '../game/public-deals';
|
||||
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: 5 } 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 buttons = (game: GameView, calls: Action[]) => elements(PublicDealList({ game, act: action => calls.push(action), disabled: false }))
|
||||
.filter(element => typeof element.props.onClick === 'function');
|
||||
const find = (nodes: Element[], label: string) => nodes.find(node => node.props.children === label);
|
||||
const html = (game: GameView) => renderToStaticMarkup(createElement(PublicDealList, { game, act() {}, disabled: false }));
|
||||
|
||||
void test('offers accept a whole current or future deadline without an invented maximum', () => {
|
||||
for (const dueTurn of [2, 5, 1_000_000]) {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', { ...payment, dueTurn }, 'a', 2),
|
||||
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, dueTurn } });
|
||||
}
|
||||
for (const dueTurn of [0, 1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', { ...payment, dueTurn }, 'a', 2), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('future offers remain acceptable through the deadline, then can only be declined', () => {
|
||||
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[] = [];
|
||||
for (const turn of [2, 5]) {
|
||||
assert.equal(find(buttons({ ...game, turn }, calls), 'Accept deal')?.props.disabled, false);
|
||||
assert.doesNotMatch(html({ ...game, turn }), /due turn has passed/);
|
||||
}
|
||||
const expired = { ...game, turn: 6 };
|
||||
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);
|
||||
});
|
||||
|
||||
void test('a future pending payment stays visible and can be paid early when projected legal', () => {
|
||||
const calls: Action[] = [];
|
||||
assert.match(html(base), /on or before the end of turn 5/);
|
||||
assert.match(html(base), /Payment status: pending/);
|
||||
const pay = find(buttons(base, calls), 'Pay agreed amount');
|
||||
assert.ok(pay); assert.equal(pay.props.disabled, false);
|
||||
(pay.props.onClick as () => void)();
|
||||
assert.deepEqual(calls, [{ type: 'dealPay', deal: 'deal' }]);
|
||||
const blocked = { ...base, deferredDealPaymentOptions: { deal: { blocked: 'Finish the current decision.' } } } as unknown as GameView;
|
||||
assert.match(html(blocked), /Finish the current decision/);
|
||||
assert.equal(find(buttons(blocked, calls), 'Pay agreed amount')?.props.disabled, true);
|
||||
});
|
||||
|
||||
void test('payment attention starts on the due turn while future payment remains in history', () => {
|
||||
assert.deepEqual(publicDealRequests(base), []);
|
||||
const future = renderToStaticMarkup(createElement(PublicDeals, { game: base, act() {}, busy: false }));
|
||||
assert.doesNotMatch(future, /Public deals · payment due/);
|
||||
assert.match(future, /Payment status: pending/);
|
||||
for (const turn of [5, 6]) assert.deepEqual(publicDealRequests({ ...base, turn }), [['payment', 'deal']]);
|
||||
const released = { ...base, deals: [{ ...deal, status: 'released', paymentStatus: 'released' }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests({ ...released, turn: 5 }), []);
|
||||
const withRelease = { ...base, deals: [{ ...deal, releaseVotes: ['b'] }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(withRelease), [['release', 'deal', 'b']]);
|
||||
});
|
||||
|
||||
/** Exercise the real controlled form callbacks without claiming browser hydration. */
|
||||
function controller(game: GameView) {
|
||||
const states: unknown[] = [], sent: Action[] = [];
|
||||
let cursor = 0;
|
||||
const exports: { PublicDeals?: typeof PublicDeals } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText, {
|
||||
exports,
|
||||
require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return {
|
||||
useState(initial: unknown) {
|
||||
const index = cursor++;
|
||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [states[index], (next: unknown) => { states[index] = typeof next === 'function' ? next(states[index]) : next; }];
|
||||
},
|
||||
useRef: () => ({ current: null }), useEffect: () => {},
|
||||
};
|
||||
if (name === '@/components/ui/button') return { Button: 'button' };
|
||||
if (name === '@/game/catalog') return catalog;
|
||||
if (name === '@/game/public-deals') return publicDeals;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
assert.ok(exports.PublicDeals);
|
||||
const render = () => { cursor = 0; return elements(exports.PublicDeals!({ game, busy: false, act: action => sent.push(action) })); };
|
||||
return { render, sent };
|
||||
}
|
||||
|
||||
void test('the authoring form selects and validates a cross-turn deadline', () => {
|
||||
const ui = controller(base);
|
||||
const check = (index: number) => {
|
||||
const input = ui.render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
|
||||
assert.ok(input);
|
||||
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
|
||||
};
|
||||
const choose = (index: number, value: string) => {
|
||||
const select = ui.render().filter(element => element.type === 'select')[index]; assert.ok(select);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
check(0); check(1); choose(0, 'deferredBribe'); choose(1, 'a'); choose(2, 'b');
|
||||
const textarea = ui.render().find(element => element.type === 'textarea'); assert.ok(textarea);
|
||||
(textarea.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'Terms' } });
|
||||
const editDue = (value: string) => {
|
||||
const input = ui.render().find(element => element.type === 'input' && element.props.type === 'number' && element.props.min === 2);
|
||||
assert.ok(input); assert.equal(input.props.max, undefined);
|
||||
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
editDue('5');
|
||||
const form = ui.render().find(element => element.type === 'form'); assert.ok(form);
|
||||
(form.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, amount: 1 } });
|
||||
editDue('1');
|
||||
const submit = ui.render().find(element => element.type === 'button' && element.props.type === 'submit');
|
||||
assert.ok(submit); assert.equal(submit.props.disabled, true);
|
||||
const invalidForm = ui.render().find(element => element.type === 'form'); assert.ok(invalidForm);
|
||||
(invalidForm.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
|
||||
assert.equal(ui.sent.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @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 test creates only its own room on the isolated integration server. */
|
||||
void test('authenticated participants consent to a future-due deal and its payer settles early once', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Future 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: 'Future 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: 'Future 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 offerTurn = view.turn;
|
||||
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: offerTurn + 1 };
|
||||
await act(host.cookie, { type: 'dealOffer', targets: [payer.data.me, third.data.me],
|
||||
terms: 'Pay the agreed spice by next turn.', payment });
|
||||
const deal = view.deals.at(-1); assert.ok(deal); assert.equal(deal.status, 'offered');
|
||||
assert.deepEqual(deal.payment, payment);
|
||||
const pay = { type: 'dealPay', deal: deal.id };
|
||||
const premature = await request(path, payer.cookie, { version: view.version, action: pay });
|
||||
assert.equal(premature.status, 409, JSON.stringify(premature.data));
|
||||
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));
|
||||
const visible: GameView['deals'][number] | undefined = read.data.deals.find(row => row.id === deal.id);
|
||||
assert.deepEqual(visible?.payment, payment); assert.equal(visible?.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 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.turn, offerTurn); assert.ok(view.turn < payment.dueTurn);
|
||||
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,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createPublicDeal, markPublicDealPaid, parsePublicDealPayment, PublicDealError,
|
||||
releasePublicDeal, respondPublicDeal, validatePublicDeals,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
import {
|
||||
outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal,
|
||||
} from '../game/public-deal-obligations';
|
||||
|
||||
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const payment = (dueTurn: number, amount = 3) => ({
|
||||
kind: 'deferredBribe' as const, payer: 'a', recipient: 'b', amount, dueTurn,
|
||||
});
|
||||
const offer = (id: string, dueTurn: number, turn = 3) =>
|
||||
createPublicDeal(seats, 'a', 'b', 'Pay by the agreed turn.', turn, id, payment(dueTurn));
|
||||
const accept = (id: string, dueTurn: number, turn = 3) =>
|
||||
respondPublicDeal(offer(id, dueTurn, turn), seats, 'b', true);
|
||||
|
||||
void test('a deferred term retains an exact safe deadline at or after its offer turn', () => {
|
||||
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 2 } as const;
|
||||
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
|
||||
assert.deepEqual(parsePublicDealPayment(payment(3), ['a', 'b'], 3), payment(3));
|
||||
assert.deepEqual(parsePublicDealPayment(payment(7), ['a', 'b'], 3), payment(7));
|
||||
assert.deepEqual(parsePublicDealPayment(payment(100), ['a', 'b'], 3), payment(100),
|
||||
'A later agreed game extension may make a distant deadline reachable.');
|
||||
for (const invalid of [payment(2), payment(0), payment(3.5), payment(Infinity),
|
||||
payment(Number.MAX_SAFE_INTEGER + 1), { ...payment(4), extra: true }])
|
||||
assert.throws(() => parsePublicDealPayment(invalid, ['a', 'b'], 3), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b']), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b'], 0), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment({ ...payment(4), recipient: 'c' }, ['a', 'b'], 3), PublicDealError);
|
||||
});
|
||||
|
||||
void test('separate pending and outstanding views preserve current-turn semantics', () => {
|
||||
const current = accept('current', 3);
|
||||
const later = accept('later', 5);
|
||||
const farther = accept('farther', 9);
|
||||
const paid = markPublicDealPaid(accept('paid', 8), seats, 'a');
|
||||
const declined = respondPublicDeal(offer('declined', 6), seats, 'b', false);
|
||||
const offered = offer('offered', 7);
|
||||
const deals: PublicDeal[] = [current, later, farther, paid, declined, offered];
|
||||
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current']);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 3), 3);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current', 'later', 'farther']);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 4).map(row => row.deal), ['later', 'farther']);
|
||||
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 5).map(row => row.deal), ['later']);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 5), 3);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 10), []);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'c', 3), []);
|
||||
assert.equal(deals[0].paymentStatus, 'pending', 'Reading after a deadline never mutates a receipt.');
|
||||
});
|
||||
|
||||
void test('early settlement and mutual release preserve exact future receipt history', () => {
|
||||
const pending = accept('future', 6);
|
||||
const copied = JSON.parse(JSON.stringify(pending)) as PublicDeal;
|
||||
validatePublicDeals([copied], seats);
|
||||
const paid = markPublicDealPaid(copied, seats, 'a');
|
||||
assert.equal(paid.paymentStatus, 'paid');
|
||||
const stillPaid = releasePublicDeal(releasePublicDeal(paid, seats, 'a', true), seats, 'b', true);
|
||||
assert.equal(stillPaid.status, 'released');
|
||||
assert.equal(stillPaid.paymentStatus, 'paid');
|
||||
const canceled = releasePublicDeal(releasePublicDeal(pending, seats, 'a', true), seats, 'b', true);
|
||||
assert.equal(canceled.paymentStatus, 'released');
|
||||
assert.deepEqual(outstandingPublicDealPayments([stillPaid, canceled], 'a', 3), []);
|
||||
assert.equal(pending.paymentStatus, 'pending', 'Pure transitions preserve the original record.');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, advanceMentatBribe as advance,
|
||||
createMentatBribeFixture as fixture, finishMentatBribe as finish,
|
||||
mentatBribeClean as clean, mentatBribeInventory as inventory,
|
||||
mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function createDeal(game: Game, proposer: string, payer: string, recipient: string,
|
||||
amount: number, dueTurn: number) {
|
||||
const offered = act(game, proposer, { type: 'dealOffer', target: payer,
|
||||
terms: 'Pay the agreed spice by the stated turn.',
|
||||
payment: { kind: 'deferredBribe', payer, recipient, amount, dueTurn } });
|
||||
const deal = offered.deals?.at(-1); assert.ok(deal);
|
||||
const accepted = act(offered, payer, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(accepted.deals?.find(row => row.id === deal.id)?.paymentStatus, 'pending');
|
||||
return { game: accepted, id: deal.id };
|
||||
}
|
||||
function status(game: Game, id: string) {
|
||||
const deal = game.deals?.find(row => row.id === id); assert.ok(deal);
|
||||
return deal.paymentStatus;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected payment leaves the original game intact.');
|
||||
}
|
||||
|
||||
/** Each route begins at the original H/A/E Basic turn-one Mentat, earned by
|
||||
* native setup, bidding and collection. The shared fixture selects only
|
||||
* unopened physical card/Spice positions; no hand or wallet is installed. */
|
||||
void test('an accepted next-turn deal survives the real turn boundary and may be paid at its due turn', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const initialSpice = player(f.game, payer).spice;
|
||||
const { game: accepted, id } = createDeal(f.game, recipient, payer, recipient, 3, f.game.turn + 1);
|
||||
assert.equal(player(accepted,payer).spice,initialSpice);
|
||||
const next = finish(accepted);
|
||||
assert.equal(next.turn,f.game.turn + 1); assert.equal(status(next,id),'pending');
|
||||
assert.equal(player(next,payer).spice,initialSpice);
|
||||
assert.equal(viewGame(next,payer).deferredDealPaymentOptions[id]?.blocked,null);
|
||||
const paid = act(next,payer,{type:'dealPay',deal:id});
|
||||
assert.equal(status(paid,id),'paid');
|
||||
assert.equal(player(paid,payer).spice,initialSpice - 3);
|
||||
assert.equal(player(paid,recipient).bribes,3);
|
||||
reject(paid,payer,{type:'dealPay',deal:id});
|
||||
const beforeOpening = advance(paid, game => game.turn === next.turn && game.phase === 7 && clean(game));
|
||||
const recipientBefore = player(beforeOpening,recipient).spice;
|
||||
const opening = advance(beforeOpening, game => game.turn === next.turn && game.phase === 8 && clean(game));
|
||||
assert.equal(player(opening,recipient).spice,recipientBefore + 3);
|
||||
assert.equal(player(opening,recipient).bribes,0);
|
||||
assert.equal(status(opening,id),'paid');
|
||||
assert.deepEqual(inventory(opening),f.originalCards);
|
||||
});
|
||||
|
||||
void test('a future-due deal can be paid early, and its actual late Mentat receipt waits until the next opening', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const recipientSpice = player(accepted,recipient).spice;
|
||||
const paid = act(accepted,payer,{type:'dealPay',deal:id});
|
||||
assert.equal(status(paid,id),'paid');
|
||||
assert.equal(player(paid,recipient).bribes,3);
|
||||
assert.equal(player(paid,recipient).spice,recipientSpice);
|
||||
const beforeOpening = advance(paid, game => game.turn === f.game.turn + 1 && game.phase === 7 && clean(game));
|
||||
const priorSpice = player(beforeOpening,recipient).spice;
|
||||
const opening = advance(beforeOpening, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
|
||||
assert.equal(player(opening,recipient).spice,priorSpice + 3);
|
||||
assert.equal(player(opening,recipient).bribes,0);
|
||||
assert.equal(status(opening,id),'paid');
|
||||
assert.deepEqual(inventory(opening),f.originalCards);
|
||||
});
|
||||
|
||||
void test('before the due turn an accepted debt neither reserves current spice nor blocks ending the turn', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,6,f.game.turn + 1);
|
||||
const spent = act(accepted,payer,{type:'bribe',target:f.opponent,amount:8});
|
||||
assert.equal(player(spent,payer).spice,2);
|
||||
assert.equal(status(spent,id),'pending');
|
||||
const next = finish(spent);
|
||||
assert.equal(next.turn,f.game.turn + 1);
|
||||
assert.equal(status(next,id),'pending', 'Future funding is unknown, not automatically impossible.');
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('a payable debt blocks its payer from ending the actual due-turn Mentat', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const due = advance(accepted, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
|
||||
assert.equal(status(due,id),'pending');
|
||||
reject(due,payer,{type:'ready'});
|
||||
const paid = act(due,payer,{type:'dealPay',deal:id});
|
||||
const ready = act(paid,payer,{type:'ready'});
|
||||
const next = finish(ready);
|
||||
assert.equal(next.turn,f.game.turn + 2);
|
||||
assert.equal(status(next,id),'paid');
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('unanimous early release cancels only the future unpaid term', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const requested = act(accepted,recipient,{type:'dealRelease',deal:id,agree:true});
|
||||
assert.equal(status(requested,id),'pending');
|
||||
const released = act(requested,payer,{type:'dealRelease',deal:id,agree:true});
|
||||
assert.equal(status(released,id),'released');
|
||||
reject(released,payer,{type:'dealPay',deal:id});
|
||||
const next = finish(released);
|
||||
assert.equal(status(next,id),'released');
|
||||
assert.equal(player(next,payer).spice,player(f.game,payer).spice);
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('different due turns remain separate and may be settled in either order', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker, turn = f.game.turn;
|
||||
const first = createDeal(f.game,recipient,payer,recipient,3,turn + 1);
|
||||
const second = createDeal(first.game,recipient,payer,recipient,4,turn + 2);
|
||||
const next = finish(second.game);
|
||||
assert.equal(status(next,first.id),'pending'); assert.equal(status(next,second.id),'pending');
|
||||
const paidSecond = act(next,payer,{type:'dealPay',deal:second.id});
|
||||
assert.equal(status(paidSecond,first.id),'pending'); assert.equal(status(paidSecond,second.id),'paid');
|
||||
assert.equal(player(paidSecond,recipient).bribes,4);
|
||||
const paidFirst = act(paidSecond,payer,{type:'dealPay',deal:first.id});
|
||||
assert.equal(status(paidFirst,first.id),'paid');
|
||||
assert.equal(player(paidFirst,recipient).bribes,7);
|
||||
assert.deepEqual(inventory(paidFirst),f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user