From 6e26ceb216e832ff4b7ed6d16699a9c3b03fcdbc Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:57:55 +1100 Subject: [PATCH] Recognize group allies in bribes and deals --- components/bribe-promises.tsx | 2 + components/mixed-bribe-fields.tsx | 4 +- components/public-deals.tsx | 18 ++-- components/truthtrance.tsx | 4 +- docs/CURRENT_STATUS.md | 14 ++- docs/PUBLIC_DEALS.md | 13 +++ docs/RULE_DECISIONS.md | 6 ++ game/engine.ts | 45 +++++--- game/public-deals.ts | 5 +- game/reference.ts | 3 +- tests/coalition-bribe-deals.test.ts | 158 ++++++++++++++++++++++++++++ tests/coalition-bribe-truth.test.ts | 75 +++++++++++++ 12 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 tests/coalition-bribe-deals.test.ts create mode 100644 tests/coalition-bribe-truth.test.ts diff --git a/components/bribe-promises.tsx b/components/bribe-promises.tsx index 971755b2..9d942b59 100644 --- a/components/bribe-promises.tsx +++ b/components/bribe-promises.tsx @@ -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 && diff --git a/components/mixed-bribe-fields.tsx b/components/mixed-bribe-fields.tsx index b9171265..926d08ca 100644 --- a/components/mixed-bribe-fields.tsx +++ b/components/mixed-bribe-fields.tsx @@ -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 = { prefix: 'mixed-bribe', diff --git a/components/public-deals.tsx b/components/public-deals.tsx index 6bd1aeee..6e7d276c 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -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; }; +/** 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 }: { Deal recipients {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