Recognize group allies in bribes and deals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:57:55 +11:00
1 parent 2fe97ddd1f
commit 6e26ceb216
12 files changed
+318 -29

No files matched your search

+2
View File
@@ -1,6 +1,7 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import { bribeAction } from '@/game/bribe-options';
import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises';
import { liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
@@ -27,6 +28,7 @@ export function BribePromiseGuideButton(props: BribePromiseProps) {
const target = typeof action.target === 'string' ? current.players.find(player => player.id === action.target) : undefined;
const limit = options.targets.find(recipient => recipient.id === action.target);
const payment = action.type === 'bribe' && !!me && !!target && target.id !== actor && target.id !== me.ally &&
(!current.coalitionState || !areCoalitionAllies(current.players, current.coalitionState, actor, target.id)) &&
options.blocked === null && Number.isSafeInteger(options.available) &&
typeof action.amount === 'number' && action.amount <= options.available &&
!!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum &&
+3 -1
View File
@@ -1,6 +1,7 @@
'use client';
import type { GameView } from '@/game/engine';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { BRIBE_EXPRESSION_MAX_DEPTH, BRIBE_EXPRESSION_MAX_LEAVES } from '@/game/bribe-expression-promises';
import { parseBribePaymentClaim, type BribePaymentClaim } from '@/game/mixed-bribe-question';
@@ -9,7 +10,8 @@ import { Input } from './ui/input';
const recipients = (game: GameView, target: string) => {
const player = game.players.find(player => player.id === target);
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally &&
(!game.coalitionState || !areCoalitionAllies(game.players, game.coalitionState, target, recipient.id))) : [];
};
const config: MixedActionConfig<BribePaymentClaim> = {
prefix: 'mixed-bribe',
+12 -6
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { faction } from '@/game/catalog';
import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import type { Action, GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
@@ -15,6 +16,13 @@ type DeferredPaymentProjection = GameView & {
deferredDealPaymentOptions?: Record<string, { blocked: string | null }>;
};
/** Alliance membership is public. Scalar links retain legacy pair behavior;
* a staged group uses the same coalition quote as authoritative rules. */
function allied(game: GameView, a: GameView['players'][number], b: GameView['players'][number]): boolean {
return a.ally === b.id || b.ally === a.id ||
!!game.coalitionState && areCoalitionAllies(game.players, game.coalitionState, a.id, b.id);
}
/** 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[][] {
@@ -71,7 +79,7 @@ export function PublicDealList({ game, act, disabled }: {
const recipient = party && game.me !== deal.from;
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));
other && !allied(game, player, other)));
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;
@@ -141,11 +149,9 @@ export function PublicDeals({ game, act, busy }: {
const me = game.players.find(player => player.id === game.me);
const targets = game.players.filter(player => me && player.id !== me.id && !player.bot);
const selectedPlayers = targets.filter(player => selected.includes(player.id));
const allied = (a: typeof game.players[number], b: typeof game.players[number]) =>
a.ally === b.id || b.ally === a.id;
const legalSelection = !!me && selected.length > 0 && selected.length === selectedPlayers.length &&
selectedPlayers.every((player, index) => !allied(me, player) &&
selectedPlayers.slice(index + 1).every(other => !allied(player, other)));
selectedPlayers.every((player, index) => !allied(game, me, player) &&
selectedPlayers.slice(index + 1).every(other => !allied(game, player, other)));
const parties = me ? [me, ...selectedPlayers] : [];
const amount = Number(paymentAmount);
const dueTurn = Number(paymentDueTurn);
@@ -174,7 +180,7 @@ export function PublicDeals({ game, act, busy }: {
<legend>Deal recipients</legend>
{targets.map(player => {
const checked = selected.includes(player.id);
const conflicts = !!me && (allied(me, player) || selectedPlayers.some(other => other.id !== player.id && allied(other, player)));
const conflicts = !!me && (allied(game, me, player) || selectedPlayers.some(other => other.id !== player.id && allied(game, other, player)));
return <label key={player.id} className="checkbox-row">
<input type="checkbox" checked={checked} disabled={disabled || (!checked && conflicts)}
onChange={event => {
+3 -1
View File
@@ -1,4 +1,5 @@
'use client';
import { areCoalitionAllies } from '@/game/coalition-alliance';
import { SaphoTruthOrder } from './sapho-truth-order';
import { TruthFutureBattleLeader } from './truth-future-battle-leader';
import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields';
@@ -156,7 +157,8 @@ export function Truthtrance({
const [bribeSelection, setBribeSelection] = useState<Pick<BribeClaim, 'recipient' | 'minimum'>>({ recipient: g.me, minimum: 3 });
const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection };
const bribeRespondent = g.players.find(player => player.id === target);
const bribeRecipients = g.players.filter(player => player.id !== target && player.id !== bribeRespondent?.ally);
const bribeRecipients = g.players.filter(player => player.id !== target && player.id !== bribeRespondent?.ally &&
(!g.coalitionState || !bribeRespondent || !areCoalitionAllies(g.players, g.coalitionState, target, player.id)));
const [bribeExpression, setBribeExpression] = useState<BribeExpression>({ kind: 'and', terms: [
{ kind: 'payment', recipient: g.me, minimum: 3 }, { kind: 'payment', recipient: '', minimum: 2 },
] });
+13 -1
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Staged group bribe and deal eligibility — 28 October. Prototyped / Partial.**
Bribes, deal offers and acceptance recognize both group allies. Existing
agreements retain their history and release controls; an unpaid deferred bribe
to a newly allied recipient remains pending while temporarily unavailable.
Eight related files pass 63 cases, including real Nexus actions, outsider
payments and rendered deal controls after a staged setup. Group play remains
unexposed and other interactions are unfinished.
[Scope](PUBLIC_DEALS.md#staged-group-alliances--28-october-2026).
**Staged group Guild transport — 28 October. Prototyped / Partial.** Either
Guild ally can use native cross-planet transport, including Fremen southern
reserves, with explicit donor-specific funding. The original permission and
@@ -330,7 +339,10 @@ and published both revision and checkpoint tags with digest
Run 22 was canceled before starting; run 23 for `9081249` succeeded and
published revision and checkpoint tags with digest
`sha256:ca98dec808c4e9709241c5a2e5ebca144941eb700ee538fed89904455600d886`.
Run 24 was canceled before starting; run 25 for `83ccd71` was running at the
Run 24 was canceled before starting; run 25 for `83ccd71` succeeded and
published revision and checkpoint tags with digest
`sha256:4548e1501826946b3edb20ef73cf1d1be53ac15f22b472f0ef1d8b9124d7b836`.
Run 26 was canceled before starting; run 27 for `2fe97dd` was running at the
last check.
Deployment remains unverified.
Existing NAS deployment and its persistent storage remain untouched.
+13
View File
@@ -1,5 +1,18 @@
# Public deal records
## Staged group alliances — 28 October 2026
The unexposed Basic classic group prototype treats each member as allied to
both others for ordinary bribes and for the nonallied public-deal rule.
Targets, offer controls, acceptance and Truthtrance bribe projections use the
same current membership. The existing provisional pairwise policy for
multi-party offers applies to all group edges. Existing records are not
rewritten when alliances change: an offer may still be declined or withdrawn,
an active agreement may be released, and a deferred bribe to a newly allied
recipient stays pending while temporarily unavailable, subject to the existing
expiry policy. No group mode is exposed in the lobby; other group interactions
and complete rules acceptance remain unfinished.
## Structured payments due in a later turn — 28 October 2026
**Prototyped / Partial.** A structured payment may name its offer turn or a
+6
View File
@@ -1,5 +1,11 @@
# Rule decision index
## Group bribes and public deals — 28 October 2026
Each member of a staged group is treated as allied to both others for the
existing nonallied bribe and public-deal restrictions; the multi-party
pairwise policy remains provisional. [Scope](PUBLIC_DEALS.md#staged-group-alliances--28-october-2026).
## Group Guild transport — 28 October 2026
The staged Basic group prototype provisionally gives each Guild ally its
+28 -17
View File
@@ -751,7 +751,7 @@ import {
} from './advisors';
import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness,
type BasicAlliedShipment } from './allied-separation';
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionGroups, coalitionOf,
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf,
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
import { coalitionBattleBeneficiary } from './coalition-battle-preparation';
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership,
@@ -5892,7 +5892,7 @@ 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);
const debts = allDebts.filter(debt => !bribeAllied(g, payer, debt.recipient));
if (!debts.length) return true;
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],payer.id,g.turn));
if (g.status !== 'playing') return false;
@@ -5961,7 +5961,7 @@ function reconcilePublicDealTruth(g: Game, voluntary?: {actor:string;action:Acti
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) &&
pendingPublicDealPayments(before.deals ?? [],p.id,before.turn).some(debt => !bribeAllied(before, beforePayer, debt.recipient)) &&
publicDealCompletion(before,beforePayer);
const expiredByAction = pending.some(deal => deal.payment?.kind === 'deferredBribe' &&
(g.turn > deal.payment.dueTurn || g.status === 'finished'));
@@ -5976,7 +5976,7 @@ function reconcilePublicDealTruth(g: Game, voluntary?: {actor:string;action:Acti
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;
if (!expired && bribeAllied(g, p, payment.recipient)) continue;
const baseMentatShortfall = quiet && g.turn === payment.dueTurn && g.phase === 8 &&
g.expansions.length === 0 && bribePromiseModeSupported(g) && available < payment.amount;
if (expired || baseMentatShortfall) {
@@ -6000,7 +6000,7 @@ function protectInsolventDueDeal(g: Game, payer: string, payment: PendingPublicD
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 &&
requireRule(!affordable.some(debt => !bribeAllied(g, seat, debt.recipient) && debt.amount <= available &&
debt.amount > available - payment.amount),
'Keep the funding for a currently payable deal before paying a later deal.');
}
@@ -16019,6 +16019,16 @@ function coalitionRule<T>(work: () => T): T {
try { return work(); }
catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; }
}
/** Preserve legacy one-sided ally restrictions while recognizing every
* member of an explicitly staged larger alliance. */
function bribeAllied(g: Game, owner: Player, recipient: string): boolean {
return owner.ally === recipient || !!g.coalitionState &&
coalitionRule(() => coalitionAllies(g.players, g.coalitionState, owner.id)).includes(recipient);
}
function publicDealSeats(g: Game) {
return g.coalitionState ? g.players.map(player => ({...player,
allies: coalitionRule(() => coalitionAllies(g.players, g.coalitionState, player.id))})) : g.players;
}
function coalitionMembership(g: Game): CoalitionEscrowMembership {
return { seats: g.players.map(({id, ally, allySinceTurn}) => ({id, ally, allySinceTurn})),
...(g.coalitionState ? {coalitionState: structuredClone(g.coalitionState)} : {}) };
@@ -16443,7 +16453,7 @@ function quoteOrdinaryPayments(g: Game, actor: string, payments: readonly Ordina
const owner = getPlayer(g, actor);
for (const payment of payments) {
const recipient = getPlayer(g, payment.recipient);
requireRule(recipient.id !== actor && recipient.id !== owner.ally &&
requireRule(recipient.id !== actor && !bribeAllied(g, owner, recipient.id) &&
payment.amount <= uncommittedSpice(g, owner), 'Retain legal ordinary payment funding and recipients.');
checkBureaucratContributors(g, recipient.id, [{ payer: actor, amount: payment.amount }]);
recordBribePayment(g, actor, recipient.id, payment.amount);
@@ -16505,7 +16515,7 @@ function bribeCompatibleLaterPromises(g: Game, p: Player,
}
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return [];
bribeAllied(g, p, claim.recipient) || !g.players.some(player => player.id === claim.recipient)) return [];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
@@ -16518,7 +16528,7 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
}
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim)
.some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return [];
.some(recipient => recipient === p.id || bribeAllied(g, p, recipient) || !g.players.some(player => player.id === recipient))) return [];
const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
return (['yes', 'no'] as const).filter(answer => {
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
@@ -16692,7 +16702,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer);
return { available, incoming: p.bribes, blocked,
targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => {
targets: g.players.filter(target => target.id !== p.id && !bribeAllied(g, p, target.id)).map(target => {
let limit = !blocked && constrained ? maximumBribe(available,
amount => bribePaymentPreservesPromises(g, p, target.id, amount)) : available;
let limitation: string | null = limit < available ? 'Your Truthtrance answers limit this recipient’s payment.' : null;
@@ -17011,7 +17021,7 @@ function charityPromiseCompletion(g: Game, player: string): Action | null {
const actor = getPlayer(trial,player), amount = actor.spice - 1;
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > uncommittedSpice(trial,actor)) return null;
for (const recipient of trial.players) {
if (recipient.id === player || recipient.id === actor.ally) continue;
if (recipient.id === player || bribeAllied(trial, actor, recipient.id)) continue;
const action: Action = {type:'bribe',target:recipient.id,amount};
try {
const paid = applyActionInner(trial,player,action,'shipmentPreparation');
@@ -21836,7 +21846,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) ||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
required.some(payment => payment.recipient === actor || bribeAllied(trial, owner, payment.recipient) ||
!trial.players.some(player => player.id === payment.recipient))) continue;
for (const alliancePlan of alliancePlans) {
try {
@@ -40893,13 +40903,14 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
'Choose recipients and write the public terms.');
const payment = hasPayment ? action.payment as PublicDealPayment : undefined;
const event = crypto.randomUUID();
const seats = publicDealSeats(g);
const offer = publicDealRule(() => single
? hasPayment
? createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, event, payment!)
: createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, event)
? createPublicDeal(seats, id, action.target as string, action.terms as string, g.turn, event, payment!)
: createPublicDeal(seats, id, action.target as string, action.terms as string, g.turn, event)
: hasPayment
? createMultipartyPublicDeal(g.players, id, action.targets as string[], action.terms as string, g.turn, event, payment!)
: createMultipartyPublicDeal(g.players, id, action.targets as string[], action.terms as string, g.turn, event));
? createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event, payment!)
: createMultipartyPublicDeal(seats, id, action.targets as string[], action.terms as string, g.turn, event));
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
g.deals = [...deals, offer];
} else {
@@ -40915,7 +40926,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
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));
next = publicDealRule(() => respondPublicDeal(current, publicDealSeats(g), id, action.accept as boolean));
} else if (action.type === 'dealRelease') {
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
@@ -44388,7 +44399,7 @@ function applyActionInner(
requireRule(!blocked, blocked ?? 'Bribes are unavailable.');
const target = getPlayer(g, stringField(action.target));
requireRule(
target.id !== id && target.id !== p.ally,
target.id !== id && !bribeAllied(g, p, target.id),
'Bribes are between different, non-allied factions.',
);
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
+3 -2
View File
@@ -30,7 +30,7 @@ export type PublicDeal = {
paymentStatus?: PublicDealPaymentStatus;
};
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null; allies?: readonly string[] };
export class PublicDealError extends Error {
constructor(message: string) {
@@ -62,7 +62,8 @@ function participants(players: readonly PublicDealSeat[], ids: readonly string[]
}
function notAllied(seats: readonly PublicDealSeat[]): void {
requireDeal(seats.every((seat, index) => seats.slice(index + 1).every(other =>
seat.ally !== other.id && other.ally !== seat.id)),
seat.ally !== other.id && other.ally !== seat.id &&
!seat.allies?.includes(other.id) && !other.allies?.includes(seat.id))),
'A deal is available only between seats that are not allied with each other.');
}
+2 -1
View File
@@ -298,6 +298,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
'In the unexposed Basic group prototype, both group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
'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.',
@@ -311,7 +312,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ 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 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
],
},
{
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PublicDealList, PublicDeals } from '../components/public-deals';
import { bribeAction } from '../game/bribe-options';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** The group mode has no lobby entry. Stage a Basic Nexus and wallets, then
* form the pair and unanimous group using the actual engine actions. */
function fixture(): Game {
const game = createGame('GROUPDEALS', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'),
newPlayer('f', 'Fremen', 'fremen'), newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','e','f','h'],
ready:[], response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
for (const player of game.players) player.spice = 10;
return game;
}
function pair(game: Game): Game {
game = act(game,'a',{type:'alliance',target:'e'});
return act(game,'e',{type:'alliance',target:'a'});
}
function group(game: Game): Game {
game = pair(game);
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
game = act(game,'a',{type:'coalitionPropose',groupId:original.id,entrant:'f',
expectedRevision:game.coalitionState!.revision});
for (const id of ['e','f']) {
const proposal = game.coalitionState!.proposal!;
game = act(game,id,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','e','f']);
return game;
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before);
}
function offer(game: Game, actor: string, target: string) {
const next = act(game,actor,{type:'dealOffer',target,terms:'Share information this turn.'});
const deal = next.deals!.at(-1)!;
return {game:next,id:deal.id};
}
void test('group bribe projections and native payments exclude both allies, but retain outsiders', () => {
let game = group(fixture());
Object.assign(game,{phase:3,nexus:false,active:'h'});
for (const actor of ['a','e','f']) {
const allies = ['a','e','f'].filter(id => id !== actor);
const options = viewGame(game,actor).bribeOptions;
assert.equal(options.blocked,null);
assert.deepEqual(options.targets.map(target => target.id),['h']);
for (const target of allies) {
assert.equal(bribeAction(options,target,1),null);
reject(game,actor,{type:'bribe',target,amount:1});
}
}
assert.deepEqual(viewGame(game,'h').bribeOptions.targets.map(target => target.id),['a','e','f']);
game = act(game,'a',{type:'bribe',target:'h',amount:3});
assert.equal(seat(game,'a').spice,7);
assert.equal(seat(game,'h').spice,10);
assert.equal(seat(game,'h').bribes,3);
});
void test('pair bribe rules and outsider group payments remain available', () => {
let game = pair(fixture());
Object.assign(game,{phase:3,nexus:false,active:'h'});
assert.deepEqual(viewGame(game,'a').bribeOptions.targets.map(target => target.id),['f','h']);
reject(game,'a',{type:'bribe',target:'e',amount:1});
game = act(game,'a',{type:'bribe',target:'f',amount:2});
assert.equal(seat(game,'a').spice,8);
assert.equal(seat(game,'f').bribes,2);
});
void test('group public offers require every pair of participants to be nonallied', () => {
const game = group(fixture());
for (const target of ['e','f'])
reject(game,'a',{type:'dealOffer',target,terms:'Share information.'});
reject(game,'h',{type:'dealOffer',targets:['a','e'],terms:'All three share information.'});
reject(game,'h',{type:'dealOffer',targets:['e','f'],terms:'All three share information.'});
const offered = offer(game,'a','h');
const accepted = act(offered.game,'h',{type:'dealRespond',deal:offered.id,accept:true});
assert.equal(accepted.deals!.at(-1)!.status,'active');
});
void test('an unanswered multiparty offer cannot be accepted after its parties form a group, but can be declined', () => {
let game = fixture();
game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'});
const deal = game.deals!.at(-1)!.id;
game = group(game);
reject(game,'a',{type:'dealRespond',deal,accept:true});
const declined = act(game,'a',{type:'dealRespond',deal,accept:false});
assert.equal(declined.deals!.at(-1)!.status,'declined');
});
void test('an already active agreement remains releasable after group formation', () => {
const offered = offer(fixture(),'a','e');
let game = act(offered.game,'e',{type:'dealRespond',deal:offered.id,accept:true});
game = group(game);
assert.equal(game.deals!.at(-1)!.status,'active');
game = act(game,'a',{type:'dealRelease',deal:offered.id,agree:true});
game = act(game,'e',{type:'dealRelease',deal:offered.id,agree:true});
assert.equal(game.deals!.at(-1)!.status,'released');
});
void test('a deferred payment becomes unavailable between new group allies while another outsider payment remains possible', () => {
let game = fixture();
game = act(game,'a',{type:'dealOffer',target:'e',terms:'Pay for information.',
payment:{kind:'deferredBribe',payer:'a',recipient:'e',amount:2,dueTurn:3}});
const alliedDeal = game.deals!.at(-1)!.id;
game = act(game,'e',{type:'dealRespond',deal:alliedDeal,accept:true});
game = act(game,'a',{type:'dealOffer',target:'h',terms:'Pay for information.',
payment:{kind:'deferredBribe',payer:'a',recipient:'h',amount:3,dueTurn:3}});
const outsideDeal = game.deals!.at(-1)!.id;
game = act(game,'h',{type:'dealRespond',deal:outsideDeal,accept:true});
game = group(game);
Object.assign(game,{phase:3,nexus:false,active:'h'});
assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending');
assert.ok(viewGame(game,'a').deferredDealPaymentOptions[alliedDeal]?.blocked);
assert.equal(viewGame(game,'a').deferredDealPaymentOptions[outsideDeal]?.blocked,null);
reject(game,'a',{type:'dealPay',deal:alliedDeal});
assert.equal(seat(game,'a').spice,10);
assert.equal(seat(game,'e').bribes,0);
game = act(game,'a',{type:'dealPay',deal:outsideDeal});
assert.equal(seat(game,'a').spice,7);
assert.equal(seat(game,'h').bribes,3);
assert.equal(game.deals!.find(deal => deal.id === outsideDeal)!.paymentStatus,'paid');
assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending');
game = act(game,'a',{type:'dealRelease',deal:alliedDeal,agree:true});
game = act(game,'e',{type:'dealRelease',deal:alliedDeal,agree:true});
const released = game.deals!.find(deal => deal.id === alliedDeal)!;
assert.equal(released.status,'released');
assert.equal(released.paymentStatus,'released');
});
void test('public deal controls show group allies as unavailable and still allow decline', () => {
let game = fixture();
game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'});
game = group(game);
const own = viewGame(game,'a');
const list = renderToStaticMarkup(createElement(PublicDealList,{game:own,disabled:false,act() {}}));
assert.match(list,/Acceptance is unavailable while any participants are allied/);
assert.match(list,/<button\b[^>]*disabled=""[^>]*>Accept deal/);
const decline = list.match(/<button\b[^>]*>Decline deal<\/button>/)?.[0];
assert.ok(decline);
assert.doesNotMatch(decline, /\sdisabled=""/);
const form = renderToStaticMarkup(createElement(PublicDeals,{game:own,busy:false,act() {}}));
assert.match(form,/Emperor[^<]* · Emperor \(allied with a selected party\)/);
assert.match(form,/Fremen[^<]* · Fremen \(allied with a selected party\)/);
});
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BribePromiseGuideButton } from '../components/bribe-promises';
import { mixedBribeInputError } from '../components/mixed-bribe-fields';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game:Game, id:string, action:Action) => applyAction(game,id,action);
/** Group entry is staged at a Basic Nexus. Pair/group formation, physical
* Truthtrance transfer, declaration, priority and questions use native actions. */
function source() {
let game = createGame('GROUPTRUTHBRIBE',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('e','Emperor','emperor'),newPlayer('f','Fremen','fremen'),
newPlayer('h','Harkonnen','harkonnen'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','e','f','h'],
ready:[],response:null,decision:null,phaseOpening:null,deck:baseDeck()});
game.coalitionState = emptyCoalitionState();
game = act(game,'a',{type:'alliance',target:'e'});
game = act(game,'e',{type:'alliance',target:'a'});
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game = act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'f',expectedRevision:game.coalitionState!.revision});
for (const id of ['e','f']) {
const invitation = game.coalitionState!.proposal!;
game = act(game,id,{type:'coalitionAnswer',event:invitation.event,nexus:invitation.nexus,
turn:invitation.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
Object.assign(game,{phase:3,nexus:false,active:'h',ready:[]});
for (const player of game.players) { player.spice = 10; player.hand = []; }
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [truth] = game.deck.splice(index,1);
game.players.find(player => player.id === 'h')!.hand.push(truth);
game = act(game,'h',{type:'card',card:truth.id});
for (let count=0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return game;
}
const question = (recipient:string) => ({type:'truthAsk',question:{kind:'bribe',target:'a',turn:2,recipient,minimum:3}} as Action);
void test('group allies cannot be named in a native Truthtrance bribe question, but an outsider can', () => {
let game = source();
for (const recipient of ['e','f']) {
const before = structuredClone(game);
assert.throws(() => act(game,'h',question(recipient)));
assert.deepEqual(game,before);
}
game = act(game,'h',question('h'));
assert.deepEqual(viewGame(game,'a').truthBribeAnswers,['yes','no']);
game = act(game,'a',{type:'truthAnswer',answer:'yes'});
const view = viewGame(game,'a');
assert.equal(view.bribePromiseGuide?.action.target,'h');
const html = renderToStaticMarkup(createElement(BribePromiseGuideButton,{game:view,busy:false,send() {}}));
assert.match(html,/Pay 3 spice to Harkonnen/);
assert.doesNotMatch(html,/disabled=""/);
game = act(game,'a',view.bribePromiseGuide!.action);
assert.equal(game.players.find(player => player.id === 'a')!.spice,7);
assert.equal(game.players.find(player => player.id === 'h')!.bribes,3);
});
void test('mixed bribe fields reject group allies while accepting the outsider', () => {
const view = viewGame(source(),'h');
const expression = (recipient:string) => ({kind:'and',terms:[
{kind:'fact',fact:{kind:'spice',compare:'gte',value:1}},
{kind:'action',action:{recipient,minimum:3}},
]});
assert.ok(mixedBribeInputError(expression('e'),view,'a'));
assert.ok(mixedBribeInputError(expression('f'),view,'a'));
assert.equal(mixedBribeInputError(expression('h'),view,'a'),null);
});