From e133a5fe2bef06d9821f1e8ba601c92700dac9c5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:48:40 +1100 Subject: [PATCH] Support multiparty public deals --- README.md | 8 +- components/public-deals.tsx | 99 ++++++++---- docs/BRIBE_CONTROLS.md | 4 +- docs/CURRENT_STATUS.md | 14 +- docs/IMPLEMENTATION_STATUS.md | 12 ++ docs/PUBLIC_DEALS.md | 33 ++-- docs/RULE_DECISIONS.md | 9 +- game/engine.ts | 14 +- game/public-deals.ts | 129 +++++++++++----- game/reference.ts | 10 +- tests/public-deals-controls.test.tsx | 65 +++++++- tests/public-deals-multiparty-engine.test.ts | 153 +++++++++++++++++++ tests/public-deals-multiparty-http.test.ts | 114 ++++++++++++++ tests/public-deals.test.ts | 110 ++++++++++++- 14 files changed, 676 insertions(+), 98 deletions(-) create mode 100644 tests/public-deals-multiparty-engine.test.ts create mode 100644 tests/public-deals-multiparty-http.test.ts diff --git a/README.md b/README.md index 6bb3a75f..1cdeeb47 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,13 @@ to swap with an AI seat, which currently accepts immediately. Accepted swaps clear human readiness. See [faction allocation](docs/FACTION_ALLOCATION.md). -During play, **Public deals** lets two nonallied human seats record terms, -accept or decline an offer, and agree to release it. Terms are visible to the +During play, **Public deals** lets two to six human seats record terms, +accept or decline an offer, and agree to release it. Every party must accept, +and every pair of parties must be nonallied when the offer is made or accepted. +Terms are visible to the table and rely on players to honor them; the game does not interpret promises or determine impossibility. Use **Bribes** separately for an actual spice -payment. AI and multiparty deals remain unfinished. See +payment. AI negotiation and automatic enforcement remain unfinished. See [public deals](docs/PUBLIC_DEALS.md). For the provisional two-person setup, the host enables **Two players, two diff --git a/components/public-deals.tsx b/components/public-deals.tsx index 5bcc2942..93451c38 100644 --- a/components/public-deals.tsx +++ b/components/public-deals.tsx @@ -2,11 +2,21 @@ import { useEffect, useRef, useState } from 'react'; import { faction } from '@/game/catalog'; +import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals'; import type { Action, GameView } from '@/game/engine'; import { Button } from '@/components/ui/button'; type Deal = GameView['deals'][number]; +/** Keep the legacy single-recipient wire packet when only one seat is chosen. */ +export function publicDealOfferAction(targets: readonly string[], terms: string): Action | null { + const text = terms.trim(); + if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null; + return targets.length === 1 + ? { type: 'dealOffer', target: targets[0], terms: text } + : { type: 'dealOffer', targets: [...targets], terms: text }; +} + /** The whole table can read an offer and its history; only its parties act. */ export function PublicDealList({ game, act, disabled }: { game: GameView; @@ -22,24 +32,33 @@ export function PublicDealList({ game, act, disabled }: { if (!deals.length) return

No public deals have been proposed.

; return
    {deals.map(deal => { - const party = game.me === deal.from || game.me === deal.to; + const participants = publicDealParticipants(deal); + const accepted = publicDealAcceptVotes(deal); + const party = participants.includes(game.me); const voted = deal.releaseVotes.includes(game.me); - const partner = game.me === deal.from ? deal.to : deal.from; + 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)); return
  1. -

    {name(deal.from)} → {name(deal.to)} · Turn {deal.turn} · {deal.status}

    +

    {name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')} · Turn {deal.turn} · {deal.status}

    {deal.terms}

    - {deal.status === 'active' && deal.releaseVotes.length > 0 &&

    - Release requested by {deal.releaseVotes.map(name).join(', ')}. Both parties must agree to release the deal. + {deal.status === 'offered' &&

    + Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.

    } - {canAct && deal.status === 'offered' && deal.to === game.me &&
    - + {deal.status === 'offered' && !eligible &&

    Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.

    } + {deal.status === 'active' && deal.releaseVotes.length > 0 &&

    + Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal. +

    } + {canAct && deal.status === 'offered' && recipient &&
    + {!accepted.includes(game.me) && }
    } {canAct && deal.status === 'offered' && deal.from === game.me && } {canAct && deal.status === 'active' && party && }
  2. ; })} @@ -51,43 +70,65 @@ export function PublicDeals({ game, act, busy }: { act: (action: Action) => void; busy: boolean; }) { - const [target, setTarget] = useState(''); + const [selected, setSelected] = useState([]); const [terms, setTerms] = useState(''); - const needsResponse = game.status === 'playing' && game.deals.some(deal => - deal.status === 'offered' && deal.to === game.me || - deal.status === 'active' && (deal.from === game.me || deal.to === game.me) && - !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0); + const requests = game.status === 'playing' ? game.deals.flatMap(deal => { + const participant = publicDealParticipants(deal).includes(game.me); + if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me)) + return [['offer', deal.id]]; + if (deal.status === 'active' && participant && !deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0) + return [['release', deal.id, ...deal.releaseVotes]]; + return []; + }) : []; + const needsResponse = requests.length > 0; + const requestKey = JSON.stringify(requests); const panel = useRef(null); - useEffect(() => { if (needsResponse && panel.current) panel.current.open = true; }, [needsResponse]); + useEffect(() => { if (requestKey !== '[]' && panel.current) panel.current.open = true; }, [requestKey]); if (game.status !== 'playing' && game.status !== 'finished') return null; const me = game.players.find(player => player.id === game.me); - const targets = game.players.filter(player => me && player.id !== me.id && !player.bot && - player.id !== me.ally && player.ally !== me.id); - const recipient = targets.find(player => player.id === target); + 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))); + const offer = legalSelection ? publicDealOfferAction(selected, terms) : null; const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot; const mayOffer = game.status === 'playing' && me && !me.bot; return
    Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''} -

    Deals are public. A recipient must accept an offer. Players must honor valid terms; an impossible part is void, and both parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.

    +

    Deals are public. Every recipient must accept an offer. Players must honor valid terms; an impossible part is void, and all parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.

    Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. A deal does not automatically transfer spice; use the Bribes control to make an eligible payment. Bribe rules

    {mayOffer &&
    { event.preventDefault(); - const text = terms.trim(); - if (!disabled && recipient && text && text.length <= 500) act({ type: 'dealOffer', target: recipient.id, terms: text }); + if (!disabled && offer) act(offer); }}> - +
    + 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))); + return ; + })} +