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
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 && }
+
Deal history
+
+ ;
+}
diff --git a/docs/BRIBE_CONTROLS.md b/docs/BRIBE_CONTROLS.md
index 199b397b..391f7119 100644
--- a/docs/BRIBE_CONTROLS.md
+++ b/docs/BRIBE_CONTROLS.md
@@ -23,6 +23,10 @@ places paid bribes in front of the recipient's shield until the next Mentat Paus
Chat messages and verbal promises do not themselves transfer spice. Emperor
gifts to allies remain a separate power.
+The separate [public deal record](PUBLIC_DEALS.md) can capture a two-human
+offer, acceptance and mutual release. Its written terms do not automatically
+transfer spice or enforce future promises; use this native action to pay.
+
The server derives the owner's limits from existing uncommitted spice, excluding
sealed bids, paid support, Banker spending and other reserved costs. Existing
Truthtrance completion checks further constrain voluntary spending on isolated
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 9cf17bc1..82715b0f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,17 @@ open mode gates.
## Current checkpoint and work
+**Public deal records — 28 October. Prototyped / Partial.** Two nonallied human
+seats can record an offer, acceptance or decline, withdrawal and mutual release,
+including while a native decision is pending. Public terms rely on players to
+honor them; the record does not enforce prose, determine impossibility or pay
+spice. Seven related unit files pass 43 cases; an isolated authenticated HTTP
+case covers public visibility, consent, release and resumed play. AI negotiation,
+multiparty deals, automatic enforcement and deployed acceptance remain open.
+An isolated browser room also exercised offer, acceptance and mutual release
+between two separately authenticated faction seats, with a persistent result.
+[Scope](PUBLIC_DEALS.md).
+
**Basic Truthtrance and the Ixian auction pool — 28 October. Prototyped /
Partial.** A named card can be compared with the respondent's recorded inspected
group in Basic Bidding; the question does not claim the card remains for sale.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 6c43abd0..abf93505 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,17 @@
# Dune implementation status
+## 28 October 2026 — Public human deal records
+
+Two nonallied humans can publish terms, accept or decline an offer, withdraw it
+or agree to release it, without interrupting a native pending decision. Terms
+remain public and player-enforced; a record does not transfer spice or determine
+when a term is impossible. Seven related unit files pass 43 cases, and an
+isolated authenticated HTTP case covers public visibility, consent, release and
+continued play. Multiparty and AI deals, automatic enforcement and deployed
+acceptance remain open. An isolated browser room exercised the lifecycle between
+two controlled seats, with faction labels and persistent result visibility.
+[Scope](PUBLIC_DEALS.md).
+
## 28 October 2026 — Basic Ixian auction-group Truthtrance
A player may ask whether a named card appears in the Basic Ixian's recorded
diff --git a/docs/PUBLIC_DEALS.md b/docs/PUBLIC_DEALS.md
new file mode 100644
index 00000000..315bc6d2
--- /dev/null
+++ b/docs/PUBLIC_DEALS.md
@@ -0,0 +1,41 @@
+# Public deal records
+
+## Source and prototype scope — 28 October 2026
+
+**Prototyped / Partial.** The [publisher's 2019 rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
+permits public deals between nonallied players, requires legal terms to be
+honored, voids impossible parts and permits release by agreement. Its bribery
+rules prohibit transfers of cards, leaders, forces and faction powers and
+terms that contradict the rules. The user-supplied revised rulebook, physical
+p. 16 (printed p. 14), records the same principles. This implementation does
+not interpret written terms or decide whether they are legal or impossible.
+
+Two human seats that are not allied with each other can record public written
+terms during play, including while a game decision is open. The recipient may
+accept or decline and the proposer may withdraw an unanswered offer. Both
+participants can vote to release an accepted deal; either may withdraw a
+pending release request. Once both agree, the record is released. Existing
+agreements can be released if participants subsequently become allies. Records
+remain visible and active until both release them, even after the players
+consider the terms fulfilled. Their text is public to the whole table.
+
+The record itself moves no spice and creates no automatically enforced game
+action. Players remain responsible for honoring valid terms, and an impossible
+part is void under the rules. Existing [bribe controls](BRIBE_CONTROLS.md)
+make actual eligible payments into the recipient's deferred Mentat supply.
+Automatic interpretation, enforcement and impossibility handling, more than
+two parties, AI negotiation and complete multiplayer/deployed acceptance remain
+open. The text interface is an online convention, not a publisher ruling.
+
+The action uses the normal seat authentication and room version fence. Game
+state carries an additive public ledger; it does not replace the native phase,
+decision, private information or physical components.
+
+Seven related unit files pass 43 cases, including a native Truthtrance response
+continuation; an isolated authenticated HTTP case covers three public views,
+seat-authorized consent, release and subsequent play. The UI tests cover
+proposer and recipient controls and a persistent action-needed notice. This
+evidence does not certify arbitrary written terms or complete game modes.
+An isolated browser room exercised offer, acceptance and mutual release between
+two controlled faction seats, including the automatic response notice, distinct
+faction labels and a result that remains visible until the player closes it.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 09399656..76857a8e 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,14 @@
# Rule decision index
+## Public deals — 28 October 2026
+
+The publisher's 2019 rulebook permits public nonallied deals, requires their
+legal terms to be honored, voids impossible parts and permits mutual release.
+The initial two-human written record models offer, acceptance and mutual
+release; compliance with prose and impossibility are player-managed, and spice
+still requires the native bribe action. AI negotiation and multiparty terms
+remain open. [Scope](PUBLIC_DEALS.md).
+
## Truthtrance and Basic Ixian auction knowledge — 28 October 2026
The publisher FAQ permits game-related yes/no and compound questions. A named
diff --git a/game/engine.ts b/game/engine.ts
index 0ff8a615..d06e8d15 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -4,6 +4,8 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
FactionDraftError, type FactionDraft } from './faction-draft';
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
FactionSwapError, type FactionSwap } from './faction-swap';
+import { createPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
+ releasePublicDeal, PublicDealError, type PublicDeal } from './public-deals';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
@@ -1413,6 +1415,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
+ /** Public written agreements. Their prose is honored by players, not parsed. */
+ deals?: PublicDeal[];
/** A human-to-human lobby offer, pending the recipient's consent. */
factionSwap?: FactionSwap;
/** Public prediction-number faction selection, present only while in progress. */
@@ -5796,6 +5800,16 @@ function factionSwapIntegrity(g: Game): void {
g.players.some(player => player.id === g.factionSwap!.to && !player.bot),
'Faction swap participants must be human seats.');
}
+function publicDealRule(operation: () => T): T {
+ try { return operation(); }
+ catch (error) {
+ if (error instanceof PublicDealError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function publicDealIntegrity(g: Game): void {
+ if (g.deals !== undefined) publicDealRule(() => validatePublicDeals(g.deals, g.players));
+}
function assignLobbyFaction(player: Player, chosen: FactionId): void {
player.faction = chosen;
if (player.bot) player.name = `${faction(chosen).name} AI`;
@@ -40001,6 +40015,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
+ publicDealIntegrity(state);
if (state.factionSwap) requireRule(
['factionSwapRespond', 'factionSwapCancel', 'advanceBots'].includes(action?.type),
'Resolve the faction swap before changing the lobby.',
@@ -40070,6 +40085,40 @@ export function applyAction(state: Game, id: string, action: Action): Game {
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
+ if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
+ requireRule(state.status === 'playing', 'Public deals require a game in progress.');
+ const player = getPlayer(state, id);
+ requireRule(!player.bot && !player.autopilot, 'Only a human controlling their seat can manage a deal.');
+ const g = structuredClone(state);
+ const deals = g.deals ?? [];
+ if (action.type === 'dealOffer') {
+ requireRule(Object.keys(action).sort().join(',') === 'target,terms,type' &&
+ typeof action.target === 'string' && typeof action.terms === 'string', 'Choose a recipient and write the public terms.');
+ const offer = publicDealRule(() => createPublicDeal(g.players, id, action.target as string,
+ action.terms as string, g.turn, crypto.randomUUID()));
+ requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
+ g.deals = [...deals, offer];
+ } else {
+ const keys = action.type === 'dealRespond' ? 'accept,deal,type' :
+ action.type === 'dealRelease' ? 'agree,deal,type' : 'deal,type';
+ requireRule(Object.keys(action).sort().join(',') === keys && typeof action.deal === 'string',
+ 'Choose the current deal action.');
+ const index = deals.findIndex(deal => deal.id === action.deal);
+ requireRule(index >= 0, 'This deal is no longer available.');
+ const current = deals[index];
+ let next: PublicDeal;
+ if (action.type === 'dealRespond') {
+ requireRule(typeof action.accept === 'boolean', 'Choose whether to accept or decline.');
+ next = publicDealRule(() => respondPublicDeal(current, g.players, 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));
+ } else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
+ g.deals = deals.map((deal, position) => position === index ? next : deal);
+ }
+ publicDealIntegrity(g);
+ return g;
+ }
// Seat control is independent of gameplay locks and must preserve their exact continuation.
if (action?.type === 'setAutopilot') {
requireRule(
@@ -40627,6 +40676,7 @@ export function normalizeAutomaticGame(state: Game): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
+ publicDealIntegrity(state);
victoryContinuationIntegrity(state);
if (state.victoryContinuation) return state;
bidPromiseIntegrity(state);
@@ -44960,6 +45010,7 @@ export function viewGame(state: Game, id: string, now?: number) {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
+ publicDealIntegrity(state);
victoryContinuationIntegrity(state);
revivalOrderIntegrity(state);
charityOrderIntegrity(state);
@@ -45258,6 +45309,7 @@ export function viewGame(state: Game, id: string, now?: number) {
version: g.version,
host: g.host,
status: g.status,
+ deals: g.deals ?? [],
factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
diff --git a/game/public-deals.ts b/game/public-deals.ts
new file mode 100644
index 00000000..6ced5237
--- /dev/null
+++ b/game/public-deals.ts
@@ -0,0 +1,109 @@
+/** A public agreement between two human seats. Terms are recorded, not parsed
+ * or automatically enforced by the game engine. */
+export type PublicDeal = {
+ id: string;
+ from: string;
+ to: string;
+ terms: string;
+ turn: number;
+ status: 'offered' | 'active' | 'declined' | 'withdrawn' | 'released';
+ releaseVotes: string[];
+};
+
+export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
+
+export class PublicDealError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'PublicDealError';
+ }
+}
+
+const id = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0 && value === value.trim();
+const object = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+function requireDeal(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new PublicDealError(message);
+}
+
+function roster(players: readonly PublicDealSeat[]): void {
+ requireDeal(Array.isArray(players) && players.length >= 1 && players.length <= 6 &&
+ players.every(player => object(player) && id(player.id)) &&
+ new Set(players.map(player => player.id)).size === players.length,
+ 'A deal requires distinct seated players.');
+}
+function participants(players: readonly PublicDealSeat[], from: string, to: string): [PublicDealSeat, PublicDealSeat] {
+ const proposer = players.find(player => player.id === from);
+ const recipient = players.find(player => player.id === to);
+ requireDeal(proposer && recipient && proposer !== recipient && !proposer.bot && !recipient.bot,
+ 'A deal requires two different human seats.');
+ return [proposer, recipient];
+}
+function notAllied(from: PublicDealSeat, to: PublicDealSeat): void {
+ requireDeal(from.ally !== to.id && to.ally !== from.id,
+ 'This deal is available only between seats that are not already allied.');
+}
+
+/** Validate the persisted public ledger without imposing current alliance state
+ * on earlier offers or active agreements. */
+export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
+ roster(players);
+ requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
+ const ids = new Set();
+ for (const deal of value) {
+ requireDeal(object(deal) && Object.keys(deal).sort().join(',') === 'from,id,releaseVotes,status,terms,to,turn' &&
+ id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
+ typeof deal.terms === 'string' && deal.terms.length > 0 && deal.terms.length <= 500 && deal.terms === deal.terms.trim() &&
+ Number.isSafeInteger(deal.turn) && (deal.turn as number) > 0 &&
+ typeof deal.status === 'string' && ['offered', 'active', 'declined', 'withdrawn', 'released'].includes(deal.status) &&
+ Array.isArray(deal.releaseVotes) && deal.releaseVotes.every(id),
+ 'A public deal is malformed.');
+ requireDeal(!ids.has(deal.id), 'Public deal IDs must be unique.');
+ ids.add(deal.id);
+ participants(players, deal.from, deal.to);
+ const votes = deal.releaseVotes as string[];
+ requireDeal(new Set(votes).size === votes.length && votes.every(vote => vote === deal.from || vote === deal.to) &&
+ (deal.status === 'active' ? votes.length <= 1 : deal.status === 'released' ? votes.length === 2 : votes.length === 0),
+ 'The public deal has invalid release votes.');
+ }
+}
+
+export function createPublicDeal(
+ players: readonly PublicDealSeat[], actor: string, target: string, terms: string, turn: number, event: string,
+): PublicDeal {
+ roster(players);
+ requireDeal(id(actor) && id(target) && actor !== target && id(event) && Number.isSafeInteger(turn) && turn > 0,
+ 'Choose a current public deal and two different seats.');
+ requireDeal(typeof terms === 'string' && terms.trim().length > 0 && terms.trim().length <= 500,
+ 'Write deal terms between 1 and 500 characters.');
+ const [from, to] = participants(players, actor, target);
+ notAllied(from, to);
+ return { id: event, from: actor, to: target, terms: terms.trim(), turn, status: 'offered', releaseVotes: [] };
+}
+
+export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
+ validatePublicDeals([deal], players);
+ requireDeal(deal.status === 'offered' && actor === deal.to && typeof accept === 'boolean',
+ 'Only the recipient may accept or decline an outstanding deal.');
+ if (accept) notAllied(...participants(players, deal.from, deal.to));
+ return { ...deal, status: accept ? 'active' : 'declined', releaseVotes: [] };
+}
+
+export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
+ validatePublicDeals([deal], players);
+ requireDeal(deal.status === 'offered' && actor === deal.from,
+ 'Only the proposer may withdraw an outstanding deal.');
+ return { ...deal, status: 'withdrawn', releaseVotes: [] };
+}
+
+/** Each participant can vote to release an active deal or withdraw their vote.
+ * Both votes release it permanently, even if the players later become allies. */
+export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, agree: boolean): PublicDeal {
+ validatePublicDeals([deal], players);
+ requireDeal(deal.status === 'active' && (actor === deal.from || actor === deal.to) && typeof agree === 'boolean',
+ 'Only a participant may change a release vote on an active deal.');
+ const votes = deal.releaseVotes.filter(vote => vote !== actor);
+ if (agree) votes.push(actor);
+ return { ...deal, status: votes.length === 2 ? 'released' : 'active', releaseVotes: votes };
+}
diff --git a/game/reference.ts b/game/reference.ts
index e7cfde92..56217b96 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -288,26 +288,28 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'bribes',
- title: 'Paying and collecting bribes',
+ title: 'Public deals and bribes',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Pay a non-allied faction now; its recipient share becomes spendable at the next Mentat opening, including next turn for a late Mentat payment.',
+ summary: 'Record a public agreement with another human, or pay a non-allied faction a bribe collected at the next Mentat opening.',
steps: [
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
'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.',
'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.',
- 'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. Strategic bribe initiation and negotiation remain deferred. Discuss other agreements with human players; broader prose deals are not automatically enforced.',
+ 'In Public deals, propose written terms to another non-allied human. The recipient can accept or decline, and the proposer can withdraw an outstanding offer. Once accepted, either party may request release; both must agree. The public record remains active until mutually released, even when players consider it fulfilled.',
+ 'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility, and an agreement does not itself pay spice. Use Bribes for an actual payment. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
+ 'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation, automatic enforcement of prose, and agreements with more than two parties remain unfinished.',
],
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Existing payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/bribe-options.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Implemented', detail: 'Named recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/bribes.tsx'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Public two-human offer, consent and mutual release are recorded, while prose compliance remains player managed. Bribe payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
+ { 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: 'Implemented', detail: 'Payment, escrow, restrictions, saved continuation and remaining boundaries are recorded.', evidence: ['docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Original late Mentat/next-turn receipt journeys supplement existing projection/JSON, committed budgets, privacy, target validation, Bureaucrat, controls and concurrent HTTP evidence. Full integrated acceptance remains open.', evidence: ['tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts', 'tests/battle-promises.test.ts', 'tests/shipment-promises.test.ts'] },
+ { 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, pending decisions and authenticated visibility have focused coverage. 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-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
],
},
{
diff --git a/tests/public-deals-controls.test.tsx b/tests/public-deals-controls.test.tsx
new file mode 100644
index 00000000..199bf6d8
--- /dev/null
+++ b/tests/public-deals-controls.test.tsx
@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { PublicDealList, PublicDeals } from '../components/public-deals';
+import type { Action, GameView } from '../game/engine';
+
+type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+const offer = { id: 'deal-1', from: 'a', to: 'b', terms: 'Pay 3 spice later', turn: 2, status: 'offered', releaseVotes: [] };
+const base = {
+ status: 'playing', me: 'a', roomControl: null,
+ players: [
+ { id: 'a', name: 'Alice', faction: 'atreides', ally: 'c' },
+ { id: 'b', name: 'Basil', faction: 'harkonnen' },
+ { id: 'c', name: 'Chani', faction: 'fremen', ally: 'a' },
+ { id: 'd', name: 'Duncan', faction: 'guild', bot: 'Medium' },
+ { id: 'e', name: 'Emperor', faction: 'emperor', ally: 'b' },
+ ],
+ deals: [offer],
+} as unknown as GameView;
+const record = (calls: Action[]) => (action: Action) => { calls.push(action); };
+function actions(game: GameView, calls: Action[], disabled = false) {
+ return elements(PublicDealList({ game, act: record(calls), disabled }))
+ .filter(element => typeof element.props.onClick === 'function');
+}
+function click(nodes: Element[], label: string) {
+ const node = nodes.find(element => element.props.children === label);
+ assert.ok(node, `Missing ${label}`);
+ (node.props.onClick as () => void)();
+}
+function render(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(PublicDeals, { game, act() {}, busy }));
+}
+
+void test('deal form exposes human non-allied recipients and clearly describes limits', () => {
+ const html = render(base);
+ assert.match(html, /Public deals/);
+ assert.match(html, /Basil · Harkonnen/);
+ assert.match(html, /Emperor · Emperor/);
+ assert.doesNotMatch(html, /Chani · Fremen/);
+ assert.doesNotMatch(html, /Duncan · Spacing Guild/);
+ assert.match(html, /cannot automatically enforce free-text promises/);
+ assert.match(html, /does not automatically transfer spice/);
+ assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
+ assert.match(html, /maxlength="500"/i);
+ assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
+ assert.match(html, /Offer public deal/);
+ assert.equal(render({ ...base, status: 'lobby' }), '');
+ assert.doesNotMatch(render({ ...base, status: 'finished' }), /Offer public deal/);
+ assert.doesNotMatch(render({ ...base, me: 'd' }), /Offer public deal/);
+ assert.ok((render(base, true).match(/disabled=""/g) ?? []).length >= 3);
+});
+
+void test('offer actions are available only to the corresponding parties', () => {
+ const calls: Action[] = [];
+ click(actions(base, calls), 'Withdraw offer');
+ const recipient = { ...base, me: 'b' };
+ assert.match(render(recipient), /your response requested/);
+ const choices = actions(recipient, calls);
+ click(choices, 'Accept deal');
+ click(choices, 'Decline deal');
+ assert.deepEqual(calls, [
+ { type: 'dealWithdraw', deal: 'deal-1' },
+ { type: 'dealRespond', deal: 'deal-1', accept: true },
+ { type: 'dealRespond', deal: 'deal-1', accept: false },
+ ]);
+ assert.equal(actions({ ...base, me: 'e' }, calls).length, 0);
+ assert.ok(actions(recipient, calls, true).every(node => node.props.disabled));
+});
+
+void test('either party can request, agree to, or withdraw mutual release', () => {
+ const calls: Action[] = [];
+ const active = { ...offer, status: 'active' as const, releaseVotes: [] };
+ const current = { ...base, deals: [active] } as unknown as GameView;
+ click(actions(current, calls), 'Request release of deal');
+ const requested = { ...current, deals: [{ ...active, releaseVotes: ['a'] }] } as unknown as GameView;
+ assert.match(render({ ...requested, me: 'b' }), /your response requested/);
+ click(actions(requested, calls), 'Withdraw release request');
+ click(actions({ ...requested, me: 'b' }, calls), 'Agree to release deal');
+ assert.equal(actions({ ...requested, me: 'c' }, calls).length, 0);
+ assert.deepEqual(calls, [
+ { type: 'dealRelease', deal: 'deal-1', agree: true },
+ { type: 'dealRelease', deal: 'deal-1', agree: false },
+ { type: 'dealRelease', deal: 'deal-1', agree: true },
+ ]);
+ const html = render(requested);
+ assert.match(html, /Release requested by Alice/);
+ assert.match(html, /Both parties must agree/);
+});
+
+void test('all statuses and public text remain readable without treating text as markup', () => {
+ const game = { ...base, deals: [
+ { ...offer, terms: 'example', status: 'active' },
+ { ...offer, id: 'deal-2', status: 'released', releaseVotes: ['a', 'b'] },
+ { ...offer, id: 'deal-3', status: 'declined' },
+ { ...offer, id: 'deal-4', status: 'withdrawn' },
+ ] } as unknown as GameView;
+ const html = render(game);
+ assert.match(html, /<b>example<\/b>/);
+ for (const status of ['active', 'released', 'declined', 'withdrawn']) assert.match(html, new RegExp(status));
+ assert.equal(actions({ ...game, status: 'finished' }, []).length, 0);
+});
diff --git a/tests/public-deals-engine.test.ts b/tests/public-deals-engine.test.ts
new file mode 100644
index 00000000..8675c93a
--- /dev/null
+++ b/tests/public-deals-engine.test.ts
@@ -0,0 +1,148 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { knowledgeGame } from './truthtrance-knowledge-fixture';
+
+/** Complete a genuine Basic setup with distinct nonallied human factions. */
+function playing(withBot = false): Game {
+ let game = createGame('DEALS', newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
+ joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
+ if (withBot) game = applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' });
+ for (const p of game.players.filter(player => !player.bot)) game = applyAction(game, p.id, { type: 'ready' });
+ game = applyAction(game, 'a', { type: 'start' });
+ for (const p of game.players) if (p.traitorChoices.length)
+ game = applyAction(game, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
+ assert.equal(game.status, 'playing');
+ return game;
+}
+
+function reject(game: Game, actor: string, action: Action) {
+ const original = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action), `${action.type} must reject`);
+ assert.deepEqual(game, original, `${action.type} must not mutate rejected input`);
+}
+
+function offer(game: Game, from = 'a', to = 'h') {
+ const terms = 'I will not attack your forces this turn.';
+ const next = applyAction(game, from, { type: 'dealOffer', target: to, terms });
+ const deal = next.deals?.at(-1);
+ assert.ok(deal);
+ assert.equal(deal.from, from);
+ assert.equal(deal.to, to);
+ assert.equal(deal.terms, terms);
+ assert.equal(deal.turn, game.turn);
+ assert.equal(deal.status, 'offered');
+ assert.deepEqual(deal.releaseVotes, []);
+ return { game: next, deal };
+}
+
+function preservedRules(game: Game) {
+ const { players, phase, turn, active, response, decision, phaseOpening, auction, deck, discard } = game;
+ return structuredClone({ players, phase, turn, active, response, decision, phaseOpening, auction, deck, discard });
+}
+
+void test('public offer, consent and mutual release preserve the native game', () => {
+ const original = playing();
+ const baseline = preservedRules(original);
+ const offered = offer(original);
+ let game = offered.game;
+ const deal = offered.deal;
+ assert.ok(deal.id);
+ for (const player of game.players)
+ assert.deepEqual(viewGame(game, player.id).deals.find(item => item.id === deal.id), deal);
+ assert.deepEqual(preservedRules(game), baseline);
+
+ reject(game, 'a', { type: 'dealRespond', deal: deal.id, accept: true });
+ reject(game, 'e', { type: 'dealRespond', deal: deal.id, accept: true });
+ game = applyAction(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
+ assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'active');
+ assert.deepEqual(preservedRules(game), baseline);
+ reject(game, 'a', { type: 'dealWithdraw', deal: deal.id });
+ reject(game, 'e', { type: 'dealRelease', deal: deal.id, agree: true });
+
+ game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: true });
+ let active = game.deals!.find(item => item.id === deal.id)!;
+ assert.equal(active.status, 'active');
+ assert.deepEqual(active.releaseVotes, ['a']);
+ game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: false });
+ active = game.deals!.find(item => item.id === deal.id)!;
+ assert.equal(active.status, 'active');
+ assert.deepEqual(active.releaseVotes, []);
+ game = applyAction(game, 'h', { type: 'dealRelease', deal: deal.id, agree: true });
+ assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'active');
+ game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: true });
+ assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'released');
+ assert.deepEqual(preservedRules(game), baseline);
+ reject(game, 'h', { type: 'dealRelease', deal: deal.id, agree: true });
+});
+
+void test('declined and withdrawn offers remain public without changing game pieces', () => {
+ const original = playing();
+ const baseline = preservedRules(original);
+ let { game, deal } = offer(original);
+ reject(game, 'h', { type: 'dealWithdraw', deal: deal.id });
+ game = applyAction(game, 'h', { type: 'dealRespond', deal: deal.id, accept: false });
+ assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'declined');
+ reject(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
+ ({ game, deal } = offer(game));
+ game = applyAction(game, 'a', { type: 'dealWithdraw', deal: deal.id });
+ assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'withdrawn');
+ reject(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
+ assert.deepEqual(preservedRules(game), baseline);
+});
+
+void test('deals reject self, ally, bot, unknown and malformed parties or packets', () => {
+ const game = playing(true);
+ const bot = game.players.find(player => player.bot)!;
+ for (const [actor, action] of [
+ ['a', { type: 'dealOffer', target: 'a', terms: 'Self' }],
+ ['a', { type: 'dealOffer', target: bot.id, terms: 'Bot' }],
+ [bot.id, { type: 'dealOffer', target: 'a', terms: 'Bot' }],
+ ['missing', { type: 'dealOffer', target: 'a', terms: 'Forged' }],
+ ['a', { type: 'dealOffer', target: 'missing', terms: 'Forged' }],
+ ['a', { type: 'dealOffer', target: 'h', terms: '' }],
+ ['a', { type: 'dealOffer', target: 'h', terms: ' ' }],
+ ['a', { type: 'dealOffer', target: 'h', terms: null }],
+ ['a', { type: 'dealOffer', target: 'h', terms: 'Terms', from: 'e' }],
+ ['h', { type: 'dealRespond', deal: 'stale', accept: true }],
+ ] as Array<[string, Action]>) reject(game, actor, action);
+
+ const allied = structuredClone(game);
+ allied.players.find(player => player.id === 'a')!.ally = 'h';
+ allied.players.find(player => player.id === 'h')!.ally = 'a';
+ reject(allied, 'a', { type: 'dealOffer', target: 'h', terms: 'Agreement' });
+ const separatelyAllied = structuredClone(game);
+ separatelyAllied.players.find(player => player.id === 'a')!.ally = 'e';
+ separatelyAllied.players.find(player => player.id === 'e')!.ally = 'a';
+ const allowed = offer(separatelyAllied, 'a', 'h');
+ assert.equal(allowed.deal.status, 'offered');
+ const proposed = offer(game);
+ const formed = structuredClone(proposed.game);
+ formed.players.find(player => player.id === 'a')!.ally = 'h';
+ formed.players.find(player => player.id === 'h')!.ally = 'a';
+ reject(formed, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: true });
+ reject(proposed.game, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: 'yes' });
+ reject(proposed.game, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: true, from: 'e' });
+ reject(proposed.game, 'a', { type: 'dealWithdraw', deal: proposed.deal.id, extra: true });
+});
+
+void test('public deal negotiation leaves a native Truthtrance priority response resumable', () => {
+ const initial = knowledgeGame();
+ const card = initial.players[0].hand.find(item => item.effect === 'truthtrance');
+ assert.ok(card);
+ const paused = applyAction(initial, 'a', { type: 'card', card: card.id });
+ assert.equal(paused.truthtrance?.stage, 'priority');
+ const original = structuredClone(paused.truthtrance);
+ const before = preservedRules(paused);
+ const proposal = offer(paused, 'b', 'f');
+ assert.deepEqual(proposal.game.truthtrance, original);
+ assert.deepEqual(preservedRules(proposal.game), before);
+ const declined = applyAction(proposal.game, 'f', { type: 'dealRespond', deal: proposal.deal.id, accept: false });
+ assert.deepEqual(declined.truthtrance, original);
+ const passer = declined.players.find(player => !declined.truthtrance!.passed.includes(player.id));
+ assert.ok(passer);
+ const resumed = applyAction(declined, passer.id, { type: 'truthPass' });
+ assert.ok(resumed.truthtrance);
+ assert.ok(resumed.truthtrance.passed.includes(passer.id));
+});
diff --git a/tests/public-deals-http.test.ts b/tests/public-deals-http.test.ts
new file mode 100644
index 00000000..7ce07f33
--- /dev/null
+++ b/tests/public-deals-http.test.ts
@@ -0,0 +1,105 @@
+// @dune-suite integration
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { GameView } from '../game/engine';
+
+const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
+async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
+ const response = await fetch(base + path, {
+ method: body === undefined ? 'GET' : 'POST',
+ signal: AbortSignal.timeout(15_000),
+ headers: {
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
+ ...(cookie ? { cookie } : {}),
+ ...(selected ? { 'X-Dune-Seat': selected } : {}),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ return {
+ status: response.status,
+ cookie: response.headers.get('set-cookie')?.split(';')[0],
+ data: await response.json() as GameView & { error?: string },
+ };
+}
+
+void test('public deals use authenticated seats, persist for observers, and preserve normal gameplay', async () => {
+ const host = await request('/api/rooms', undefined, {
+ name: 'Deal host', faction: 'atreides', advanced: false, expansions: [],
+ });
+ assert.equal(host.status, 201); assert.ok(host.cookie);
+ const path = `/api/rooms/${host.data.code}`;
+ const guest = await request(path, undefined, { type: 'join', name: 'Deal recipient', faction: 'harkonnen' });
+ assert.equal(guest.status, 200); assert.ok(guest.cookie);
+ const observer = await request(path, undefined, { type: 'join', name: 'Deal observer', faction: 'emperor' });
+ assert.equal(observer.status, 200); assert.ok(observer.cookie);
+ const seats = [
+ { id: host.data.me, cookie: host.cookie },
+ { id: guest.data.me, cookie: guest.cookie },
+ { id: observer.data.me, cookie: observer.cookie },
+ ];
+ let view = observer.data;
+ const act = async (cookie: string, action: object) => {
+ const next = await request(path, cookie, { version: view.version, action });
+ assert.equal(next.status, 200, JSON.stringify(next.data));
+ view = next.data;
+ };
+ for (const seat of seats) await act(seat.cookie, { type: 'ready' });
+ await act(host.cookie, { type: 'start' });
+ for (const seat of seats) {
+ const own = await request(path, seat.cookie);
+ assert.equal(own.status, 200);
+ view = own.data;
+ const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
+ if (choices?.length) await act(seat.cookie, { type: 'traitor', leader: choices[0] });
+ }
+ assert.equal(view.status, 'playing');
+ assert.equal(view.phase, 0);
+ const baseline = {
+ phase: view.phase, turn: view.turn, active: view.active, storm: view.storm,
+ stormDialers: view.stormDialers, stormSubmitted: view.stormSubmitted,
+ players: view.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
+ };
+
+ await act(host.cookie, { type: 'dealOffer', target: guest.data.me, terms: 'I will not attack your forces this turn.' });
+ const offer = view.deals.at(-1);
+ assert.ok(offer);
+ assert.equal(offer.status, 'offered');
+ assert.equal(offer.from, host.data.me);
+ assert.equal(offer.to, guest.data.me);
+ for (const seat of seats) {
+ const read = await request(path, seat.cookie);
+ assert.equal(read.status, 200);
+ assert.deepEqual(read.data.deals.find(deal => deal.id === offer.id), offer);
+ }
+
+ // The selected seat header is not a credential, and an authenticated
+ // bystander cannot answer on the recipient's behalf.
+ const accept = { version: view.version, action: { type: 'dealRespond', deal: offer.id, accept: true } };
+ assert.equal((await request(path, host.cookie, accept, guest.data.me)).status, 409);
+ assert.equal((await request(path, observer.cookie, accept)).status, 409);
+ const unchanged = await request(path, guest.cookie);
+ assert.equal(unchanged.status, 200);
+ assert.equal(unchanged.data.version, view.version);
+ assert.equal(unchanged.data.deals.find(deal => deal.id === offer.id)?.status, 'offered');
+ await act(guest.cookie, accept.action);
+ assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'active');
+ await act(host.cookie, { type: 'dealRelease', deal: offer.id, agree: true });
+ assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'active');
+ await act(guest.cookie, { type: 'dealRelease', deal: offer.id, agree: true });
+ assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'released');
+ const restored = await request(path, observer.cookie);
+ assert.equal(restored.status, 200);
+ assert.equal(restored.data.deals.find(deal => deal.id === offer.id)?.status, 'released');
+ assert.deepEqual({
+ phase: restored.data.phase, turn: restored.data.turn, active: restored.data.active, storm: restored.data.storm,
+ stormDialers: restored.data.stormDialers, stormSubmitted: restored.data.stormSubmitted,
+ players: restored.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
+ }, baseline);
+
+ // A native storm dial still succeeds after the agreement lifecycle.
+ const dialer = seats.find(seat => restored.data.stormDialers.includes(seat.id));
+ assert.ok(dialer);
+ view = restored.data;
+ await act(dialer.cookie, { type: 'stormDial', amount: 0 });
+ assert.ok(view.stormSubmitted.includes(dialer.id) || view.phase !== 0);
+});
diff --git a/tests/public-deals.test.ts b/tests/public-deals.test.ts
new file mode 100644
index 00000000..1b2553a4
--- /dev/null
+++ b/tests/public-deals.test.ts
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ PublicDealError, createPublicDeal, releasePublicDeal, respondPublicDeal,
+ validatePublicDeals, withdrawPublicDeal,
+} from '../game/public-deals';
+
+const players = [
+ { id: 'a', ally: null }, { id: 'b', ally: null }, { id: 'c', ally: null },
+ { id: 'bot', bot: 'Medium', ally: null },
+];
+const offer = () => createPublicDeal(players, 'a', 'b', ' Trade two spice later. ', 2, 'event-1');
+
+void test('an explicit offer and recipient response preserve input and do not form an alliance', () => {
+ const before = structuredClone(players);
+ const pending = offer();
+ assert.deepEqual(pending, { id: 'event-1', from: 'a', to: 'b', terms: 'Trade two spice later.', turn: 2, status: 'offered', releaseVotes: [] });
+ const original = structuredClone(pending);
+ assert.throws(() => respondPublicDeal(pending, players, 'a', true), PublicDealError);
+ assert.throws(() => respondPublicDeal(pending, players, 'c', false), PublicDealError);
+ const active = respondPublicDeal(pending, players, 'b', true);
+ assert.equal(active.status, 'active');
+ assert.deepEqual(pending, original);
+ assert.deepEqual(players, before);
+ validatePublicDeals([pending, { ...active, id: 'event-2' }], players);
+ assert.throws(() => respondPublicDeal(active, players, 'b', true), PublicDealError);
+});
+
+void test('decline and withdrawal require their respective participant and are terminal', () => {
+ const pending = offer();
+ assert.throws(() => withdrawPublicDeal(pending, players, 'b'), PublicDealError);
+ const declined = respondPublicDeal(pending, players, 'b', false);
+ const withdrawn = withdrawPublicDeal(pending, players, 'a');
+ assert.equal(declined.status, 'declined');
+ assert.equal(withdrawn.status, 'withdrawn');
+ for (const deal of [declined, withdrawn]) {
+ validatePublicDeals([deal], players);
+ assert.throws(() => releasePublicDeal(deal, players, 'a', true), PublicDealError);
+ }
+});
+
+void test('only both participants can release an accepted deal and a prior vote may be withdrawn', () => {
+ const initial = respondPublicDeal(offer(), players, 'b', true);
+ const aVotes = releasePublicDeal(initial, players, 'a', true);
+ assert.deepEqual(aVotes.releaseVotes, ['a']);
+ assert.deepEqual(initial.releaseVotes, []);
+ assert.throws(() => releasePublicDeal(aVotes, players, 'c', true), PublicDealError);
+ const aWithdraws = releasePublicDeal(aVotes, players, 'a', false);
+ assert.deepEqual(aWithdraws.releaseVotes, []);
+ const bVotes = releasePublicDeal(aWithdraws, players, 'b', true);
+ assert.deepEqual(bVotes.releaseVotes, ['b']);
+ const released = releasePublicDeal(bVotes, players, 'a', true);
+ assert.equal(released.status, 'released');
+ assert.deepEqual(new Set(released.releaseVotes), new Set(['a', 'b']));
+ validatePublicDeals([released], players);
+ assert.throws(() => releasePublicDeal(released, players, 'a', false), PublicDealError);
+});
+
+void test('alliance of the two parties prevents new and accepted deals, but not decline or release', () => {
+ const pending = offer();
+ const between = players.map(player => player.id === 'a' ? { ...player, ally: 'b' } : player);
+ assert.throws(() => createPublicDeal(between, 'a', 'b', 'terms', 2, 'x'), PublicDealError);
+ assert.throws(() => respondPublicDeal(pending, between, 'b', true), PublicDealError);
+ assert.equal(respondPublicDeal(pending, between, 'b', false).status, 'declined');
+ const active = respondPublicDeal(pending, players, 'b', true);
+ const first = releasePublicDeal(active, between, 'a', true);
+ assert.equal(releasePublicDeal(first, between, 'b', true).status, 'released');
+ const otherAlliance = players.map(player => player.id === 'a' ? { ...player, ally: 'c' } : player);
+ assert.equal(createPublicDeal(otherAlliance, 'a', 'b', 'terms', 2, 'y').status, 'offered');
+});
+
+void test('strict ledger and creation validation reject malformed or forged records', () => {
+ validatePublicDeals([], [players[0]]);
+ for (const [actor, target, terms, turn, event] of [
+ ['a', 'a', 'x', 1, 'e'], ['a', 'bot', 'x', 1, 'e'], ['bot', 'a', 'x', 1, 'e'],
+ ['a', 'missing', 'x', 1, 'e'], ['a', 'b', ' ', 1, 'e'], ['a', 'b', 'x'.repeat(501), 1, 'e'],
+ ['a', 'b', 'x', 0, 'e'], ['a', 'b', 'x', 1, ' e '],
+ ] as Array<[string, string, string, number, string]>)
+ assert.throws(() => createPublicDeal(players, actor, target, terms, turn, event), PublicDealError);
+ const pending = offer();
+ for (const ledger of [
+ [pending, pending], [{ ...pending, unexpected: true }], [{ ...pending, releaseVotes: ['c'] }],
+ [{ ...pending, status: 'active', releaseVotes: ['a', 'a'] }],
+ [{ ...pending, status: 'released', releaseVotes: ['a'] }],
+ [{ ...pending, status: 'offered', releaseVotes: ['a'] }],
+ ]) assert.throws(() => validatePublicDeals(ledger, players), PublicDealError);
+});