From 86c51b4aa7bba41bf11ef0296c63f7d07179467e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:40:59 +1100 Subject: [PATCH] Prototype public deal agreements --- README.md | 7 ++ components/game-table.tsx | 3 + components/public-deals.tsx | 95 +++++++++++++++++ docs/BRIBE_CONTROLS.md | 4 + docs/CURRENT_STATUS.md | 11 ++ docs/IMPLEMENTATION_STATUS.md | 12 +++ docs/PUBLIC_DEALS.md | 41 ++++++++ docs/RULE_DECISIONS.md | 9 ++ game/engine.ts | 52 ++++++++++ game/public-deals.ts | 109 ++++++++++++++++++++ game/reference.ts | 16 +-- tests/public-deals-controls.test.tsx | 107 +++++++++++++++++++ tests/public-deals-engine.test.ts | 148 +++++++++++++++++++++++++++ tests/public-deals-http.test.ts | 105 +++++++++++++++++++ tests/public-deals.test.ts | 87 ++++++++++++++++ 15 files changed, 799 insertions(+), 7 deletions(-) create mode 100644 components/public-deals.tsx create mode 100644 docs/PUBLIC_DEALS.md create mode 100644 game/public-deals.ts create mode 100644 tests/public-deals-controls.test.tsx create mode 100644 tests/public-deals-engine.test.ts create mode 100644 tests/public-deals-http.test.ts create mode 100644 tests/public-deals.test.ts diff --git a/README.md b/README.md index eec1a504..6bb3a75f 100644 --- a/README.md +++ b/README.md @@ -25,6 +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 +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 +[public deals](docs/PUBLIC_DEALS.md). + For the provisional two-person setup, the host enables **Two players, two factions each** in the lobby. Each person joins with one faction, then adds a second and uses **Active faction** to switch seats. Ready each faction before diff --git a/components/game-table.tsx b/components/game-table.tsx index 2ad4f4e0..ee184c63 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -119,6 +119,7 @@ import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { MentatQuestion, MentatHistory } from './mentat-question'; import { BureaucratPayment } from './bureaucrat-payment'; import { Bribes } from './bribes'; +import { PublicDeals } from './public-deals'; import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { SemutaReaction } from './semuta-reaction'; @@ -5906,6 +5907,8 @@ export function GameTable({ : ' · Flips at the next Mentat Pause'}

)} + { void send(action); }} + busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} /> {!reactionOwnsControls && } diff --git a/components/public-deals.tsx b/components/public-deals.tsx new file mode 100644 index 00000000..5bcc2942 --- /dev/null +++ b/components/public-deals.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { faction } from '@/game/catalog'; +import type { Action, GameView } from '@/game/engine'; +import { Button } from '@/components/ui/button'; + +type Deal = GameView['deals'][number]; + +/** The whole table can read an offer and its history; only its parties act. */ +export function PublicDealList({ game, act, disabled }: { + game: GameView; + act: (action: Action) => void; + disabled: boolean; +}) { + const name = (id: string) => { + const player = game.players.find(candidate => candidate.id === id); + return player ? `${player.name} (${faction(player.faction).name})` : 'Unknown player'; + }; + const canAct = game.status === 'playing' && !game.players.find(player => player.id === game.me)?.bot; + const deals: Deal[] = game.deals ?? []; + 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 voted = deal.releaseVotes.includes(game.me); + const partner = game.me === deal.from ? deal.to : deal.from; + return
  1. +

    {name(deal.from)} → {name(deal.to)} · 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. +

    } + {canAct && deal.status === 'offered' && deal.to === game.me &&
    + + +
    } + {canAct && deal.status === 'offered' && deal.from === game.me && } + {canAct && deal.status === 'active' && party && } +
  2. ; + })} +
; +} + +export function PublicDeals({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const [target, setTarget] = 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 panel = useRef(null); + useEffect(() => { if (needsResponse && panel.current) panel.current.open = true; }, [needsResponse]); + 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 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.

+

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 }); + }}> + +