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

+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'] },
],
},
{