Support multiparty public deals
This commit is contained in:
1 parent
86c51b4aa7
commit
e133a5fe2b
14 files changed
+676
-98
No files matched your search
@@ -25,11 +25,13 @@ to swap with an AI seat, which currently accepts immediately. Accepted swaps
|
|||||||
clear human readiness. See
|
clear human readiness. See
|
||||||
[faction allocation](docs/FACTION_ALLOCATION.md).
|
[faction allocation](docs/FACTION_ALLOCATION.md).
|
||||||
|
|
||||||
During play, **Public deals** lets two nonallied human seats record terms,
|
During play, **Public deals** lets two to six human seats record terms,
|
||||||
accept or decline an offer, and agree to release it. Terms are visible to the
|
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
|
table and rely on players to honor them; the game does not interpret promises
|
||||||
or determine impossibility. Use **Bribes** separately for an actual spice
|
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).
|
[public deals](docs/PUBLIC_DEALS.md).
|
||||||
|
|
||||||
For the provisional two-person setup, the host enables **Two players, two
|
For the provisional two-person setup, the host enables **Two players, two
|
||||||
|
|||||||
+70
-29
@@ -2,11 +2,21 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { faction } from '@/game/catalog';
|
import { faction } from '@/game/catalog';
|
||||||
|
import { publicDealAcceptVotes, publicDealParticipants } from '@/game/public-deals';
|
||||||
import type { Action, GameView } from '@/game/engine';
|
import type { Action, GameView } from '@/game/engine';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
type Deal = GameView['deals'][number];
|
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. */
|
/** The whole table can read an offer and its history; only its parties act. */
|
||||||
export function PublicDealList({ game, act, disabled }: {
|
export function PublicDealList({ game, act, disabled }: {
|
||||||
game: GameView;
|
game: GameView;
|
||||||
@@ -22,24 +32,33 @@ export function PublicDealList({ game, act, disabled }: {
|
|||||||
if (!deals.length) return <p>No public deals have been proposed.</p>;
|
if (!deals.length) return <p>No public deals have been proposed.</p>;
|
||||||
return <ol>
|
return <ol>
|
||||||
{deals.map(deal => {
|
{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 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 <li key={deal.id}>
|
return <li key={deal.id}>
|
||||||
<p><strong>{name(deal.from)} → {name(deal.to)}</strong> · Turn {deal.turn} · {deal.status}</p>
|
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
|
||||||
<p>{deal.terms}</p>
|
<p>{deal.terms}</p>
|
||||||
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
|
{deal.status === 'offered' && <p>
|
||||||
Release requested by {deal.releaseVotes.map(name).join(', ')}. Both parties must agree to release the deal.
|
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
|
||||||
</p>}
|
</p>}
|
||||||
{canAct && deal.status === 'offered' && deal.to === game.me && <div className="flex flex-wrap gap-2">
|
{deal.status === 'offered' && !eligible && <p>Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.</p>}
|
||||||
<Button type="button" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>
|
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
|
||||||
|
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
|
||||||
|
</p>}
|
||||||
|
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
|
||||||
|
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
|
||||||
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
|
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
|
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
|
||||||
onClick={() => act({ type: 'dealWithdraw', deal: deal.id })}>Withdraw offer</Button>}
|
onClick={() => act({ type: 'dealWithdraw', deal: deal.id })}>Withdraw offer</Button>}
|
||||||
{canAct && deal.status === 'active' && party && <Button type="button" variant="outline" disabled={disabled}
|
{canAct && deal.status === 'active' && party && <Button type="button" variant="outline" disabled={disabled}
|
||||||
onClick={() => act({ type: 'dealRelease', deal: deal.id, agree: !voted })}>
|
onClick={() => act({ type: 'dealRelease', deal: deal.id, agree: !voted })}>
|
||||||
{voted ? 'Withdraw release request' : deal.releaseVotes.includes(partner) ? 'Agree to release deal' : 'Request release of deal'}
|
{voted ? 'Withdraw release request' : deal.releaseVotes.length > 0 ? 'Agree to release deal' : 'Request release of deal'}
|
||||||
</Button>}
|
</Button>}
|
||||||
</li>;
|
</li>;
|
||||||
})}
|
})}
|
||||||
@@ -51,43 +70,65 @@ export function PublicDeals({ game, act, busy }: {
|
|||||||
act: (action: Action) => void;
|
act: (action: Action) => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [target, setTarget] = useState('');
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [terms, setTerms] = useState('');
|
const [terms, setTerms] = useState('');
|
||||||
const needsResponse = game.status === 'playing' && game.deals.some(deal =>
|
const requests = game.status === 'playing' ? game.deals.flatMap(deal => {
|
||||||
deal.status === 'offered' && deal.to === game.me ||
|
const participant = publicDealParticipants(deal).includes(game.me);
|
||||||
deal.status === 'active' && (deal.from === game.me || deal.to === game.me) &&
|
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
|
||||||
!deal.releaseVotes.includes(game.me) && deal.releaseVotes.length > 0);
|
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<HTMLDetailsElement>(null);
|
const panel = useRef<HTMLDetailsElement>(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;
|
if (game.status !== 'playing' && game.status !== 'finished') return null;
|
||||||
const me = game.players.find(player => player.id === game.me);
|
const me = game.players.find(player => player.id === game.me);
|
||||||
const targets = game.players.filter(player => me && player.id !== me.id && !player.bot &&
|
const targets = game.players.filter(player => me && player.id !== me.id && !player.bot);
|
||||||
player.id !== me.ally && player.ally !== me.id);
|
const selectedPlayers = targets.filter(player => selected.includes(player.id));
|
||||||
const recipient = targets.find(player => player.id === target);
|
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 disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
|
||||||
const mayOffer = game.status === 'playing' && me && !me.bot;
|
const mayOffer = game.status === 'playing' && me && !me.bot;
|
||||||
return <details ref={panel} className="notice public-deals-panel">
|
return <details ref={panel} className="notice public-deals-panel">
|
||||||
<summary>Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
|
<summary>Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p>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. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
|
<p>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. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
|
||||||
{mayOffer && <form onSubmit={event => {
|
{mayOffer && <form onSubmit={event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = terms.trim();
|
if (!disabled && offer) act(offer);
|
||||||
if (!disabled && recipient && text && text.length <= 500) act({ type: 'dealOffer', target: recipient.id, terms: text });
|
|
||||||
}}>
|
}}>
|
||||||
<label>Deal recipient
|
<fieldset>
|
||||||
<select value={recipient ? target : ''} onChange={event => setTarget(event.target.value)} disabled={disabled}>
|
<legend>Deal recipients</legend>
|
||||||
<option value="">Choose a faction</option>
|
{targets.map(player => {
|
||||||
{targets.map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
|
const checked = selected.includes(player.id);
|
||||||
</select>
|
const conflicts = !!me && (allied(me, player) || selectedPlayers.some(other => other.id !== player.id && allied(other, player)));
|
||||||
</label>
|
return <label key={player.id} className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={checked} disabled={disabled || (!checked && conflicts)}
|
||||||
|
onChange={event => {
|
||||||
|
const nextChecked = event.target.checked;
|
||||||
|
setSelected(current => nextChecked
|
||||||
|
? current.includes(player.id) ? current : [...current, player.id] : current.filter(id => id !== player.id));
|
||||||
|
}} />
|
||||||
|
{player.name} · {faction(player.faction).name}{conflicts ? ' (allied with a selected party)' : ''}
|
||||||
|
</label>;
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
<label>Public deal terms
|
<label>Public deal terms
|
||||||
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
|
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
|
||||||
placeholder="Describe the agreement for the table" />
|
placeholder="Describe the agreement for the table" />
|
||||||
</label>
|
</label>
|
||||||
<p className="fine">Up to 500 characters. Offers are available to human non-allied players; AI negotiation is unfinished.</p>
|
<p className="fine">Up to 500 characters. Select human recipients who are not allied with each other or with you; AI negotiation is unfinished.</p>
|
||||||
<Button type="submit" disabled={disabled || !recipient || !terms.trim()}>Offer public deal</Button>
|
<Button type="submit" disabled={disabled || !offer}>Offer public deal</Button>
|
||||||
{targets.length === 0 && <p>No eligible human recipient is available.</p>}
|
{targets.length === 0 && <p>No human recipient is available.</p>}
|
||||||
|
{selected.length > 0 && !legalSelection && <p>Clear or change any recipient whose seat or alliance is no longer eligible.</p>}
|
||||||
|
{selected.length > 0 && <Button type="button" variant="outline" disabled={disabled} onClick={() => setSelected([])}>Clear recipients</Button>}
|
||||||
</form>}
|
</form>}
|
||||||
<h3>Deal history</h3>
|
<h3>Deal history</h3>
|
||||||
<PublicDealList game={game} act={act} disabled={disabled} />
|
<PublicDealList game={game} act={act} disabled={disabled} />
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ 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
|
Chat messages and verbal promises do not themselves transfer spice. Emperor
|
||||||
gifts to allies remain a separate power.
|
gifts to allies remain a separate power.
|
||||||
|
|
||||||
The separate [public deal record](PUBLIC_DEALS.md) can capture a two-human
|
The separate [public deal record](PUBLIC_DEALS.md) can capture a two-to-six-human
|
||||||
offer, acceptance and mutual release. Its written terms do not automatically
|
offer, unanimous acceptance and mutual release. Its written terms do not automatically
|
||||||
transfer spice or enforce future promises; use this native action to pay.
|
transfer spice or enforce future promises; use this native action to pay.
|
||||||
|
|
||||||
The server derives the owner's limits from existing uncommitted spice, excluding
|
The server derives the owner's limits from existing uncommitted spice, excluding
|
||||||
|
|||||||
+13
-1
@@ -16,13 +16,25 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Multiparty public deals — 28 October. Prototyped / Partial.** Two to six human
|
||||||
|
seats can record public terms; every recipient must accept, any may decline and
|
||||||
|
all parties must agree to release. Existing two-party records and actions retain
|
||||||
|
their shape. Pairwise nonalliance at offer and acceptance is provisional. Four
|
||||||
|
related unit files pass 28 cases, and two isolated HTTP cases cover two- and
|
||||||
|
three-party consent, independent seat access and continued play. Prose remains
|
||||||
|
player-managed; AI negotiation, automatic enforcement and deployed acceptance
|
||||||
|
remain open. An isolated browser room exercised three-party acceptance and
|
||||||
|
unanimous release using four human faction seats; the later allied-acceptance
|
||||||
|
control is covered by a focused component test. [Scope](PUBLIC_DEALS.md).
|
||||||
|
|
||||||
**Public deal records — 28 October. Prototyped / Partial.** Two nonallied human
|
**Public deal records — 28 October. Prototyped / Partial.** Two nonallied human
|
||||||
seats can record an offer, acceptance or decline, withdrawal and mutual release,
|
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
|
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
|
honor them; the record does not enforce prose, determine impossibility or pay
|
||||||
spice. Seven related unit files pass 43 cases; an isolated authenticated HTTP
|
spice. Seven related unit files pass 43 cases; an isolated authenticated HTTP
|
||||||
case covers public visibility, consent, release and resumed play. AI negotiation,
|
case covers public visibility, consent, release and resumed play. AI negotiation,
|
||||||
multiparty deals, automatic enforcement and deployed acceptance remain open.
|
automatic enforcement and deployed acceptance remain open; the multiparty
|
||||||
|
follow-up above expands this original two-seat checkpoint.
|
||||||
An isolated browser room also exercised offer, acceptance and mutual release
|
An isolated browser room also exercised offer, acceptance and mutual release
|
||||||
between two separately authenticated faction seats, with a persistent result.
|
between two separately authenticated faction seats, with a persistent result.
|
||||||
[Scope](PUBLIC_DEALS.md).
|
[Scope](PUBLIC_DEALS.md).
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 28 October 2026 — Multiparty public deals
|
||||||
|
|
||||||
|
Two to six human seats can agree to public terms. Every recipient must accept,
|
||||||
|
and all participants must agree to release; an outstanding offer may still be
|
||||||
|
declined or withdrawn. Existing two-party records and actions retain their
|
||||||
|
original format. Four focused unit files pass 28 cases; two isolated HTTP cases
|
||||||
|
cover authenticated two- and three-party flows and continued play. Pairwise
|
||||||
|
nonalliance is provisional; prose enforcement, AI and deployed acceptance remain
|
||||||
|
open. An isolated browser room exercised three-party acceptance and release;
|
||||||
|
the later allied-acceptance control has focused component coverage.
|
||||||
|
[Scope](PUBLIC_DEALS.md).
|
||||||
|
|
||||||
## 28 October 2026 — Public human deal records
|
## 28 October 2026 — Public human deal records
|
||||||
|
|
||||||
Two nonallied humans can publish terms, accept or decline an offer, withdraw it
|
Two nonallied humans can publish terms, accept or decline an offer, withdraw it
|
||||||
|
|||||||
+22
-11
@@ -10,27 +10,38 @@ terms that contradict the rules. The user-supplied revised rulebook, physical
|
|||||||
p. 16 (printed p. 14), records the same principles. This implementation does
|
p. 16 (printed p. 14), records the same principles. This implementation does
|
||||||
not interpret written terms or decide whether they are legal or impossible.
|
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
|
Two to six human seats can record public written terms during play, including
|
||||||
terms during play, including while a game decision is open. The recipient may
|
while a game decision is open. Every pair must be nonallied when the offer is
|
||||||
accept or decline and the proposer may withdraw an unanswered offer. Both
|
made and when each acceptance is recorded. This pairwise interpretation of a
|
||||||
participants can vote to release an accepted deal; either may withdraw a
|
multi-party offer is provisional; the printed rules do not settle its exact
|
||||||
pending release request. Once both agree, the record is released. Existing
|
online form. The proposer is implicitly in agreement. All recipients must
|
||||||
agreements can be released if participants subsequently become allies. Records
|
accept for the record to become active; any may decline while it remains open,
|
||||||
remain visible and active until both release them, even after the players
|
and the proposer may withdraw it. Each participant can request or withdraw a
|
||||||
consider the terms fulfilled. Their text is public to the whole table.
|
release; all must agree before release. Existing agreements can still be
|
||||||
|
released if participants subsequently become allies. Records remain active
|
||||||
|
until unanimously released, even after the players consider terms fulfilled.
|
||||||
|
Their text is public to the whole table.
|
||||||
|
|
||||||
The record itself moves no spice and creates no automatically enforced game
|
The record itself moves no spice and creates no automatically enforced game
|
||||||
action. Players remain responsible for honoring valid terms, and an impossible
|
action. Players remain responsible for honoring valid terms, and an impossible
|
||||||
part is void under the rules. Existing [bribe controls](BRIBE_CONTROLS.md)
|
part is void under the rules. Existing [bribe controls](BRIBE_CONTROLS.md)
|
||||||
make actual eligible payments into the recipient's deferred Mentat supply.
|
make actual eligible payments into the recipient's deferred Mentat supply.
|
||||||
Automatic interpretation, enforcement and impossibility handling, more than
|
Automatic interpretation, enforcement and impossibility handling,
|
||||||
two parties, AI negotiation and complete multiplayer/deployed acceptance remain
|
AI negotiation and complete multiplayer/deployed acceptance remain
|
||||||
open. The text interface is an online convention, not a publisher ruling.
|
open. The text interface is an online convention, not a publisher ruling.
|
||||||
|
|
||||||
The action uses the normal seat authentication and room version fence. Game
|
The action uses the normal seat authentication and room version fence. Older
|
||||||
|
two-party records retain their original serialized form and actions. Game
|
||||||
state carries an additive public ledger; it does not replace the native phase,
|
state carries an additive public ledger; it does not replace the native phase,
|
||||||
decision, private information or physical components.
|
decision, private information or physical components.
|
||||||
|
|
||||||
|
The multiparty follow-up passes 28 cases in four related unit files and two
|
||||||
|
isolated authenticated HTTP cases, including three signatories across separately
|
||||||
|
authenticated faction seats. This does not certify all game combinations.
|
||||||
|
An isolated browser room exercised three-party acceptance and unanimous release
|
||||||
|
across four human faction seats. The subsequent allied-acceptance control was
|
||||||
|
verified by a focused component test rather than that browser run.
|
||||||
|
|
||||||
Seven related unit files pass 43 cases, including a native Truthtrance response
|
Seven related unit files pass 43 cases, including a native Truthtrance response
|
||||||
continuation; an isolated authenticated HTTP case covers three public views,
|
continuation; an isolated authenticated HTTP case covers three public views,
|
||||||
seat-authorized consent, release and subsequent play. The UI tests cover
|
seat-authorized consent, release and subsequent play. The UI tests cover
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
The publisher's 2019 rulebook permits public nonallied deals, requires their
|
The publisher's 2019 rulebook permits public nonallied deals, requires their
|
||||||
legal terms to be honored, voids impossible parts and permits mutual release.
|
legal terms to be honored, voids impossible parts and permits mutual release.
|
||||||
The initial two-human written record models offer, acceptance and mutual
|
Written records allow two to six human seats to consent and mutually release;
|
||||||
release; compliance with prose and impossibility are player-managed, and spice
|
pairwise nonalliance at offer and acceptance is a conservative provisional
|
||||||
still requires the native bribe action. AI negotiation and multiparty terms
|
interpretation, not an explicit publisher ruling on multiparty offers. Prose
|
||||||
remain open. [Scope](PUBLIC_DEALS.md).
|
and impossibility are player-managed, and spice still requires the native bribe
|
||||||
|
action. AI negotiation and automatic enforcement remain open. [Scope](PUBLIC_DEALS.md).
|
||||||
|
|
||||||
## Truthtrance and Basic Ixian auction knowledge — 28 October 2026
|
## Truthtrance and Basic Ixian auction knowledge — 28 October 2026
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -4,7 +4,7 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
|
|||||||
FactionDraftError, type FactionDraft } from './faction-draft';
|
FactionDraftError, type FactionDraft } from './faction-draft';
|
||||||
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
|
||||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||||
import { createPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||||
releasePublicDeal, PublicDealError, type PublicDeal } from './public-deals';
|
releasePublicDeal, PublicDealError, type PublicDeal } from './public-deals';
|
||||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||||
@@ -40092,10 +40092,14 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
const deals = g.deals ?? [];
|
const deals = g.deals ?? [];
|
||||||
if (action.type === 'dealOffer') {
|
if (action.type === 'dealOffer') {
|
||||||
requireRule(Object.keys(action).sort().join(',') === 'target,terms,type' &&
|
const keys = Object.keys(action).sort().join(',');
|
||||||
typeof action.target === 'string' && typeof action.terms === 'string', 'Choose a recipient and write the public terms.');
|
const single = keys === 'target,terms,type' && typeof action.target === 'string';
|
||||||
const offer = publicDealRule(() => createPublicDeal(g.players, id, action.target as string,
|
const multiple = keys === 'targets,terms,type' && Array.isArray(action.targets) &&
|
||||||
action.terms as string, g.turn, crypto.randomUUID()));
|
action.targets.every(target => typeof target === 'string');
|
||||||
|
requireRule((single || multiple) && typeof action.terms === 'string', 'Choose recipients and write the public terms.');
|
||||||
|
const offer = publicDealRule(() => single
|
||||||
|
? createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, crypto.randomUUID())
|
||||||
|
: createMultipartyPublicDeal(g.players, id, action.targets 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.');
|
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
|
||||||
g.deals = [...deals, offer];
|
g.deals = [...deals, offer];
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+95
-34
@@ -1,5 +1,5 @@
|
|||||||
/** A public agreement between two human seats. Terms are recorded, not parsed
|
/** Public agreements record written terms without parsing or enforcing them.
|
||||||
* or automatically enforced by the game engine. */
|
* Older two-seat records omit participants and acceptVotes entirely. */
|
||||||
export type PublicDeal = {
|
export type PublicDeal = {
|
||||||
id: string;
|
id: string;
|
||||||
from: string;
|
from: string;
|
||||||
@@ -8,6 +8,9 @@ export type PublicDeal = {
|
|||||||
turn: number;
|
turn: number;
|
||||||
status: 'offered' | 'active' | 'declined' | 'withdrawn' | 'released';
|
status: 'offered' | 'active' | 'declined' | 'withdrawn' | 'released';
|
||||||
releaseVotes: string[];
|
releaseVotes: string[];
|
||||||
|
/** Present together only on the multi-party record format. */
|
||||||
|
participants?: string[];
|
||||||
|
acceptVotes?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
|
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
|
||||||
@@ -33,26 +36,44 @@ function roster(players: readonly PublicDealSeat[]): void {
|
|||||||
new Set(players.map(player => player.id)).size === players.length,
|
new Set(players.map(player => player.id)).size === players.length,
|
||||||
'A deal requires distinct seated players.');
|
'A deal requires distinct seated players.');
|
||||||
}
|
}
|
||||||
function participants(players: readonly PublicDealSeat[], from: string, to: string): [PublicDealSeat, PublicDealSeat] {
|
function participants(players: readonly PublicDealSeat[], ids: readonly string[]): PublicDealSeat[] {
|
||||||
const proposer = players.find(player => player.id === from);
|
const seats = ids.map(seat => players.find(player => player.id === seat));
|
||||||
const recipient = players.find(player => player.id === to);
|
requireDeal(ids.length >= 2 && ids.length <= 6 && ids.every(id) &&
|
||||||
requireDeal(proposer && recipient && proposer !== recipient && !proposer.bot && !recipient.bot,
|
new Set(ids).size === ids.length && seats.every(seat => seat && !seat.bot),
|
||||||
'A deal requires two different human seats.');
|
'A deal requires distinct human seats.');
|
||||||
return [proposer, recipient];
|
return seats as PublicDealSeat[];
|
||||||
}
|
}
|
||||||
function notAllied(from: PublicDealSeat, to: PublicDealSeat): void {
|
function notAllied(seats: readonly PublicDealSeat[]): void {
|
||||||
requireDeal(from.ally !== to.id && to.ally !== from.id,
|
requireDeal(seats.every((seat, index) => seats.slice(index + 1).every(other =>
|
||||||
'This deal is available only between seats that are not already allied.');
|
seat.ally !== other.id && other.ally !== seat.id)),
|
||||||
|
'A deal is available only between seats that are not allied with each other.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate the persisted public ledger without imposing current alliance state
|
/** These accessors do not rewrite legacy two-party saves. Call after validation. */
|
||||||
* on earlier offers or active agreements. */
|
export function publicDealParticipants(deal: PublicDeal): string[] {
|
||||||
|
return deal.participants ? [...deal.participants] : [deal.from, deal.to];
|
||||||
|
}
|
||||||
|
export function publicDealAcceptVotes(deal: PublicDeal): string[] {
|
||||||
|
if (deal.acceptVotes) return [...deal.acceptVotes];
|
||||||
|
return deal.status === 'active' || deal.status === 'released'
|
||||||
|
? [deal.from, deal.to] : [deal.from];
|
||||||
|
}
|
||||||
|
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
|
||||||
|
return new Set(votes).size === votes.length &&
|
||||||
|
votes.every(vote => parties.includes(vote)) &&
|
||||||
|
votes.every((vote, index) => index === 0 || parties.indexOf(votes[index - 1]) < parties.indexOf(vote));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate persisted ledger shape and lifecycle without applying current
|
||||||
|
* alliance restrictions retroactively to existing agreements. */
|
||||||
export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
|
export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
|
||||||
roster(players);
|
roster(players);
|
||||||
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
|
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const deal of value) {
|
for (const deal of value) {
|
||||||
requireDeal(object(deal) && Object.keys(deal).sort().join(',') === 'from,id,releaseVotes,status,terms,to,turn' &&
|
const keys = object(deal) ? Object.keys(deal).sort().join(',') : '';
|
||||||
|
const multi = keys === 'acceptVotes,from,id,participants,releaseVotes,status,terms,to,turn';
|
||||||
|
requireDeal(object(deal) && (multi || keys === 'from,id,releaseVotes,status,terms,to,turn') &&
|
||||||
id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
|
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() &&
|
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 &&
|
Number.isSafeInteger(deal.turn) && (deal.turn as number) > 0 &&
|
||||||
@@ -61,33 +82,72 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
|
|||||||
'A public deal is malformed.');
|
'A public deal is malformed.');
|
||||||
requireDeal(!ids.has(deal.id), 'Public deal IDs must be unique.');
|
requireDeal(!ids.has(deal.id), 'Public deal IDs must be unique.');
|
||||||
ids.add(deal.id);
|
ids.add(deal.id);
|
||||||
participants(players, deal.from, deal.to);
|
|
||||||
|
let parties: string[];
|
||||||
|
if (multi) {
|
||||||
|
requireDeal(Array.isArray(deal.participants) && deal.participants.length >= 3 &&
|
||||||
|
deal.participants.length <= 6 && deal.participants.every(id) &&
|
||||||
|
deal.participants[0] === deal.from && deal.participants[1] === deal.to &&
|
||||||
|
Array.isArray(deal.acceptVotes) && deal.acceptVotes.every(id), 'The public deal participants are malformed.');
|
||||||
|
parties = deal.participants;
|
||||||
|
participants(players, parties);
|
||||||
|
const accepts = deal.acceptVotes as string[];
|
||||||
|
requireDeal(accepts[0] === deal.from && orderedSubset(accepts, parties) &&
|
||||||
|
(deal.status === 'active' || deal.status === 'released'
|
||||||
|
? accepts.length === parties.length : accepts.length < parties.length),
|
||||||
|
'The public deal has invalid acceptance votes.');
|
||||||
|
} else {
|
||||||
|
parties = [deal.from, deal.to];
|
||||||
|
participants(players, parties);
|
||||||
|
}
|
||||||
const votes = deal.releaseVotes as string[];
|
const votes = deal.releaseVotes as string[];
|
||||||
requireDeal(new Set(votes).size === votes.length && votes.every(vote => vote === deal.from || vote === deal.to) &&
|
requireDeal(new Set(votes).size === votes.length && votes.every(vote => parties.includes(vote)) &&
|
||||||
(deal.status === 'active' ? votes.length <= 1 : deal.status === 'released' ? votes.length === 2 : votes.length === 0),
|
(!multi || orderedSubset(votes, parties)) &&
|
||||||
|
(deal.status === 'active' ? votes.length < parties.length :
|
||||||
|
deal.status === 'released' ? votes.length === parties.length : votes.length === 0),
|
||||||
'The public deal has invalid release votes.');
|
'The public deal has invalid release votes.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validOffer(players: readonly PublicDealSeat[], actor: string, targets: readonly string[], terms: string, turn: number, event: string): string {
|
||||||
|
roster(players);
|
||||||
|
requireDeal(id(actor) && id(event) && Number.isSafeInteger(turn) && turn > 0,
|
||||||
|
'Choose a current public deal and seated participants.');
|
||||||
|
requireDeal(typeof terms === 'string' && terms.trim().length > 0 && terms.trim().length <= 500,
|
||||||
|
'Write deal terms between 1 and 500 characters.');
|
||||||
|
notAllied(participants(players, [actor, ...targets]));
|
||||||
|
return terms.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function createPublicDeal(
|
export function createPublicDeal(
|
||||||
players: readonly PublicDealSeat[], actor: string, target: string, terms: string, turn: number, event: string,
|
players: readonly PublicDealSeat[], actor: string, target: string, terms: string, turn: number, event: string,
|
||||||
): PublicDeal {
|
): PublicDeal {
|
||||||
roster(players);
|
const text = validOffer(players, actor, [target], terms, turn, event);
|
||||||
requireDeal(id(actor) && id(target) && actor !== target && id(event) && Number.isSafeInteger(turn) && turn > 0,
|
return { id: event, from: actor, to: target, terms: text, turn, status: 'offered', releaseVotes: [] };
|
||||||
'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.');
|
export function createMultipartyPublicDeal(
|
||||||
const [from, to] = participants(players, actor, target);
|
players: readonly PublicDealSeat[], actor: string, targets: readonly string[], terms: string, turn: number, event: string,
|
||||||
notAllied(from, to);
|
): PublicDeal {
|
||||||
return { id: event, from: actor, to: target, terms: terms.trim(), turn, status: 'offered', releaseVotes: [] };
|
requireDeal(Array.isArray(targets) && targets.length >= 2 && targets.length <= 5,
|
||||||
|
'Choose two through five other participants.');
|
||||||
|
const text = validOffer(players, actor, targets, terms, turn, event);
|
||||||
|
return { id: event, from: actor, to: targets[0], terms: text, turn, status: 'offered',
|
||||||
|
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
|
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
|
||||||
validatePublicDeals([deal], players);
|
validatePublicDeals([deal], players);
|
||||||
requireDeal(deal.status === 'offered' && actor === deal.to && typeof accept === 'boolean',
|
const parties = publicDealParticipants(deal);
|
||||||
'Only the recipient may accept or decline an outstanding deal.');
|
requireDeal(deal.status === 'offered' && actor !== deal.from && parties.includes(actor) && typeof accept === 'boolean',
|
||||||
if (accept) notAllied(...participants(players, deal.from, deal.to));
|
'Only an invited recipient may accept or decline an outstanding deal.');
|
||||||
return { ...deal, status: accept ? 'active' : 'declined', releaseVotes: [] };
|
if (!accept) return { ...deal, status: 'declined', releaseVotes: [] };
|
||||||
|
notAllied(participants(players, parties));
|
||||||
|
if (!deal.participants) return { ...deal, status: 'active', releaseVotes: [] };
|
||||||
|
const alreadyAccepted = publicDealAcceptVotes(deal);
|
||||||
|
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
|
||||||
|
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
|
||||||
|
return { ...deal, acceptVotes: accepts, status: accepts.length === parties.length ? 'active' : 'offered', releaseVotes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
|
export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
|
||||||
@@ -97,13 +157,14 @@ export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDea
|
|||||||
return { ...deal, status: 'withdrawn', releaseVotes: [] };
|
return { ...deal, status: 'withdrawn', releaseVotes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each participant can vote to release an active deal or withdraw their vote.
|
/** All participants must agree to release; either may withdraw a pending 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 {
|
export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, agree: boolean): PublicDeal {
|
||||||
validatePublicDeals([deal], players);
|
validatePublicDeals([deal], players);
|
||||||
requireDeal(deal.status === 'active' && (actor === deal.from || actor === deal.to) && typeof agree === 'boolean',
|
const parties = publicDealParticipants(deal);
|
||||||
|
requireDeal(deal.status === 'active' && parties.includes(actor) && typeof agree === 'boolean',
|
||||||
'Only a participant may change a release vote on an active deal.');
|
'Only a participant may change a release vote on an active deal.');
|
||||||
const votes = deal.releaseVotes.filter(vote => vote !== actor);
|
let votes = deal.releaseVotes.filter(vote => vote !== actor);
|
||||||
if (agree) votes.push(actor);
|
if (agree) votes.push(actor);
|
||||||
return { ...deal, status: votes.length === 2 ? 'released' : 'active', releaseVotes: votes };
|
if (deal.participants) votes = parties.filter(seat => votes.includes(seat));
|
||||||
|
return { ...deal, status: votes.length === parties.length ? 'released' : 'active', releaseVotes: votes };
|
||||||
}
|
}
|
||||||
+5
-5
@@ -292,24 +292,24 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
category: 'Getting started',
|
category: 'Getting started',
|
||||||
coverage: 'Partial',
|
coverage: 'Partial',
|
||||||
developmentStage: 'Prototyped',
|
developmentStage: 'Prototyped',
|
||||||
summary: 'Record a public agreement with another human, or pay a non-allied faction a bribe collected at the next Mentat opening.',
|
summary: 'Record a public agreement with other humans, or pay a non-allied faction a bribe collected at the next Mentat opening.',
|
||||||
steps: [
|
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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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 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.',
|
'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.',
|
||||||
'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.',
|
'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.',
|
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
|
||||||
],
|
],
|
||||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||||
checklist: [
|
checklist: [
|
||||||
{ 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: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous 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: '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: '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: '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'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, pending decisions and authenticated visibility 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-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { PublicDealList, PublicDeals } from '../components/public-deals';
|
import { PublicDealList, PublicDeals, publicDealOfferAction } from '../components/public-deals';
|
||||||
import type { Action, GameView } from '../game/engine';
|
import type { Action, GameView } from '../game/engine';
|
||||||
|
|
||||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||||
@@ -42,7 +42,7 @@ void test('deal form exposes human non-allied recipients and clearly describes l
|
|||||||
assert.match(html, /Public deals/);
|
assert.match(html, /Public deals/);
|
||||||
assert.match(html, /Basil · Harkonnen/);
|
assert.match(html, /Basil · Harkonnen/);
|
||||||
assert.match(html, /Emperor · Emperor/);
|
assert.match(html, /Emperor · Emperor/);
|
||||||
assert.doesNotMatch(html, /Chani · Fremen/);
|
assert.match(html, /Chani · Fremen \(allied with a selected party\)/);
|
||||||
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
||||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||||
assert.match(html, /does not automatically transfer spice/);
|
assert.match(html, /does not automatically transfer spice/);
|
||||||
@@ -90,7 +90,66 @@ void test('either party can request, agree to, or withdraw mutual release', () =
|
|||||||
]);
|
]);
|
||||||
const html = render(requested);
|
const html = render(requested);
|
||||||
assert.match(html, /Release requested by Alice/);
|
assert.match(html, /Release requested by Alice/);
|
||||||
assert.match(html, /Both parties must agree/);
|
assert.match(html, /All parties must agree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the form keeps the legacy packet for one seat and sends all selected seats for multiparty offers', () => {
|
||||||
|
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), { type: 'dealOffer', target: 'b', terms: 'Terms' });
|
||||||
|
assert.deepEqual(publicDealOfferAction(['b', 'e'], 'Terms'), { type: 'dealOffer', targets: ['b', 'e'], terms: 'Terms' });
|
||||||
|
assert.equal(publicDealOfferAction([], 'Terms'), null);
|
||||||
|
assert.equal(publicDealOfferAction(['b', 'b'], 'Terms'), null);
|
||||||
|
assert.equal(publicDealOfferAction(['b'], ' '), null);
|
||||||
|
assert.equal(publicDealOfferAction(['b'], 'x'.repeat(501)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('multiparty offers expose all pending consent and let an accepted recipient decline', () => {
|
||||||
|
const multi = {
|
||||||
|
...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a', 'b'],
|
||||||
|
};
|
||||||
|
const game = { ...base, players: base.players.map(player =>
|
||||||
|
player.id === 'b' || player.id === 'e' ? { ...player, ally: null } : player), deals: [multi] } as unknown as GameView;
|
||||||
|
const html = render(game);
|
||||||
|
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\), Emperor \(Emperor\)/);
|
||||||
|
assert.match(html, /Accepted by Alice \(Atreides\), Basil \(Harkonnen\)/);
|
||||||
|
assert.match(html, /Waiting for Emperor \(Emperor\)/);
|
||||||
|
const calls: Action[] = [];
|
||||||
|
const accepted = actions({ ...game, me: 'b' }, calls);
|
||||||
|
assert.equal(accepted.length, 1);
|
||||||
|
click(accepted, 'Decline deal');
|
||||||
|
assert.doesNotMatch(render({ ...game, me: 'b' }), /your response requested/);
|
||||||
|
const waiting = { ...game, me: 'e' };
|
||||||
|
assert.match(render(waiting), /your response requested/);
|
||||||
|
click(actions(waiting, calls), 'Accept deal');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ type: 'dealRespond', deal: 'deal-1', accept: false },
|
||||||
|
{ type: 'dealRespond', deal: 'deal-1', accept: true },
|
||||||
|
]);
|
||||||
|
assert.equal(actions({ ...game, me: 'c' }, calls).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an offer remains declinable when new alliances make acceptance unavailable', () => {
|
||||||
|
const multi = { ...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a'] };
|
||||||
|
const game = { ...base, me: 'e', deals: [multi] } as unknown as GameView;
|
||||||
|
const choices = actions(game, []);
|
||||||
|
assert.equal(choices.find(node => node.props.children === 'Accept deal')?.props.disabled, true);
|
||||||
|
assert.equal(choices.find(node => node.props.children === 'Decline deal')?.props.disabled, false);
|
||||||
|
assert.match(render(game), /Acceptance is unavailable while any participants are allied/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all multiparty signatories participate in mutual release', () => {
|
||||||
|
const multi = {
|
||||||
|
...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a', 'b', 'e'],
|
||||||
|
status: 'active', releaseVotes: ['a', 'b'],
|
||||||
|
};
|
||||||
|
const game = { ...base, deals: [multi] } as unknown as GameView;
|
||||||
|
const calls: Action[] = [];
|
||||||
|
assert.match(render({ ...game, me: 'e' }), /your response requested/);
|
||||||
|
click(actions({ ...game, me: 'e' }, calls), 'Agree to release deal');
|
||||||
|
click(actions({ ...game, me: 'b' }, calls), 'Withdraw release request');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ type: 'dealRelease', deal: 'deal-1', agree: true },
|
||||||
|
{ type: 'dealRelease', deal: 'deal-1', agree: false },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('all statuses and public text remain readable without treating text as markup', () => {
|
void test('all statuses and public text remain readable without treating text as markup', () => {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame,
|
||||||
|
type Action, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { knowledgeGame } from './truthtrance-knowledge-fixture';
|
||||||
|
|
||||||
|
/** Genuine two-controller/four-faction Basic setup. Each faction keeps its own seat. */
|
||||||
|
function playing(): Game {
|
||||||
|
let game = createGame('DEALMANY', newPlayer('a', 'First', 'atreides'));
|
||||||
|
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||||
|
joinControlledFaction(game, newPlayer('b', 'First', 'emperor'), 'a');
|
||||||
|
joinGame(game, newPlayer('c', 'Second', 'guild'));
|
||||||
|
joinControlledFaction(game, newPlayer('d', 'Second', 'harkonnen'), 'c');
|
||||||
|
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
game = applyAction(game, 'a', { type: 'start' });
|
||||||
|
for (const player of game.players) if (player.traitorChoices.length)
|
||||||
|
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(game: Game, actor: string, action: Action): void {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => applyAction(game, actor, action), `${action.type} must reject`);
|
||||||
|
assert.deepEqual(game, before, `${action.type} must not mutate a rejected input`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proposal(game: Game, targets = ['b', 'c']) {
|
||||||
|
const next = applyAction(game, 'a', { type: 'dealOffer', targets, terms: 'All three will share information.' });
|
||||||
|
const deal = next.deals?.at(-1);
|
||||||
|
assert.ok(deal);
|
||||||
|
return { game: next, deal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserved(game: Game) {
|
||||||
|
const { players, playerPositions, twoPlayerControllers, phase, turn, active, response, decision,
|
||||||
|
phaseOpening, auction, deck, discard } = game;
|
||||||
|
return structuredClone({ players, playerPositions, twoPlayerControllers, phase, turn, active, response,
|
||||||
|
decision, phaseOpening, auction, deck, discard });
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('every named faction accepts and releases a three-party deal independently', () => {
|
||||||
|
const initial = playing();
|
||||||
|
const baseline = preserved(initial);
|
||||||
|
const offered = proposal(initial);
|
||||||
|
let game = offered.game;
|
||||||
|
const id = offered.deal.id;
|
||||||
|
assert.deepEqual(offered.deal.participants, ['a', 'b', 'c']);
|
||||||
|
assert.deepEqual(offered.deal.acceptVotes, ['a']);
|
||||||
|
assert.equal(offered.deal.status, 'offered');
|
||||||
|
for (const player of game.players)
|
||||||
|
assert.deepEqual(viewGame(game, player.id).deals.find(deal => deal.id === id), offered.deal);
|
||||||
|
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
|
||||||
|
reject(game, 'd', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
reject(game, 'a', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
game = applyAction(game, 'c', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'offered');
|
||||||
|
assert.deepEqual([...game.deals!.at(-1)!.acceptVotes!].sort(), ['a', 'c']);
|
||||||
|
reject(game, 'c', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
game = applyAction(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||||
|
assert.deepEqual([...game.deals!.at(-1)!.acceptVotes!].sort(), ['a', 'b', 'c']);
|
||||||
|
assert.deepEqual(preserved(game), baseline);
|
||||||
|
|
||||||
|
reject(game, 'd', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
game = applyAction(game, 'a', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||||
|
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: false });
|
||||||
|
assert.deepEqual(game.deals!.at(-1)!.releaseVotes, ['a']);
|
||||||
|
game = applyAction(game, 'c', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||||
|
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'released');
|
||||||
|
assert.deepEqual([...game.deals!.at(-1)!.releaseVotes].sort(), ['a', 'b', 'c']);
|
||||||
|
assert.deepEqual(preserved(game), baseline);
|
||||||
|
reject(game, 'a', { type: 'dealRelease', deal: id, agree: true });
|
||||||
|
|
||||||
|
// The agreement never consumes or replays the original native Storm choice.
|
||||||
|
const dialer = game.stormDialers[0];
|
||||||
|
assert.ok(dialer);
|
||||||
|
game = applyAction(game, dialer, { type: 'stormDial', amount: 0 });
|
||||||
|
assert.ok(game.phase !== initial.phase || game.stormDials[dialer] === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a recipient may decline while other votes are pending and only the proposer may withdraw', () => {
|
||||||
|
let { game, deal } = proposal(playing());
|
||||||
|
const id = deal.id;
|
||||||
|
game = applyAction(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
game = applyAction(game, 'c', { type: 'dealRespond', deal: id, accept: false });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'declined');
|
||||||
|
reject(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||||
|
({ game, deal } = proposal(game));
|
||||||
|
reject(game, 'c', { type: 'dealWithdraw', deal: deal.id });
|
||||||
|
game = applyAction(game, 'a', { type: 'dealWithdraw', deal: deal.id });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'withdrawn');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('every participant pair must be nonallied at offer and acceptance, while existing deals remain releasable', () => {
|
||||||
|
const initial = playing();
|
||||||
|
const pairAllied = structuredClone(initial);
|
||||||
|
pairAllied.players.find(player => player.id === 'b')!.ally = 'c';
|
||||||
|
pairAllied.players.find(player => player.id === 'c')!.ally = 'b';
|
||||||
|
reject(pairAllied, 'a', { type: 'dealOffer', targets: ['b', 'c'], terms: 'Terms' });
|
||||||
|
const offered = proposal(initial);
|
||||||
|
const changed = structuredClone(offered.game);
|
||||||
|
changed.players.find(player => player.id === 'b')!.ally = 'c';
|
||||||
|
changed.players.find(player => player.id === 'c')!.ally = 'b';
|
||||||
|
reject(changed, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||||
|
const declined = applyAction(changed, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: false });
|
||||||
|
assert.equal(declined.deals!.at(-1)!.status, 'declined');
|
||||||
|
|
||||||
|
let game = applyAction(offered.game, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||||
|
game = applyAction(game, 'c', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||||
|
game.players.find(player => player.id === 'b')!.ally = 'c';
|
||||||
|
game.players.find(player => player.id === 'c')!.ally = 'b';
|
||||||
|
for (const id of ['a', 'b', 'c']) game = applyAction(game, id, { type: 'dealRelease', deal: offered.deal.id, agree: true });
|
||||||
|
assert.equal(game.deals!.at(-1)!.status, 'released');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('multiparty packets reject empty, duplicate, forged and incompatible targets', () => {
|
||||||
|
const game = playing();
|
||||||
|
for (const action of [
|
||||||
|
{ type: 'dealOffer', targets: [], terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', targets: ['a', 'b'], terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', targets: ['b', 'b'], terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', targets: ['b', 'unknown'], terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', targets: 'b', terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', target: 'b', targets: ['b', 'c'], terms: 'Terms' },
|
||||||
|
{ type: 'dealOffer', targets: ['b', 'c'], terms: 'Terms', from: 'd' },
|
||||||
|
] as Action[]) reject(game, 'a', action);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('multiparty negotiation preserves and resumes a pending Truthtrance priority response', () => {
|
||||||
|
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 before = structuredClone(paused.truthtrance);
|
||||||
|
const offered = applyAction(paused, 'b', { type: 'dealOffer', targets: ['f', 'o'], terms: 'We will disclose our intentions.' });
|
||||||
|
const deal = offered.deals!.at(-1)!;
|
||||||
|
const acceptedOne = applyAction(offered, 'f', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||||
|
const acceptedAll = applyAction(acceptedOne, 'o', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||||
|
assert.equal(acceptedAll.deals!.at(-1)!.status, 'active');
|
||||||
|
assert.deepEqual(acceptedAll.truthtrance, before);
|
||||||
|
const passer = acceptedAll.players.find(player => !acceptedAll.truthtrance!.passed.includes(player.id));
|
||||||
|
assert.ok(passer);
|
||||||
|
const resumed = applyAction(acceptedAll, passer.id, { type: 'truthPass' });
|
||||||
|
assert.ok(resumed.truthtrance?.passed.includes(passer.id));
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @dune-suite integration
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import type { GameView } from '../game/engine';
|
||||||
|
|
||||||
|
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||||
|
const entry = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
|
||||||
|
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('three-party deals require each authenticated faction seat and leave native play intact', async () => {
|
||||||
|
const host = await request('/api/rooms', undefined, {
|
||||||
|
name: 'Many-deal first', faction: 'atreides', advanced: false, expansions: [], entry: entry(),
|
||||||
|
});
|
||||||
|
assert.equal(host.status, 201, JSON.stringify(host.data));
|
||||||
|
assert.ok(host.cookie);
|
||||||
|
const path = `/api/rooms/${host.data.code}`;
|
||||||
|
const enabled = await request(path, host.cookie, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.data.me);
|
||||||
|
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
|
||||||
|
const hostSecond = await request(path, host.cookie, { type: 'addControlledFaction', version: enabled.data.version,
|
||||||
|
name: 'Many-deal first', faction: 'emperor', entry: entry() }, host.data.me);
|
||||||
|
assert.equal(hostSecond.status, 200, JSON.stringify(hostSecond.data));
|
||||||
|
assert.ok(hostSecond.cookie);
|
||||||
|
const hostCookies = `${host.cookie}; ${hostSecond.cookie}`;
|
||||||
|
const guest = await request(path, undefined, { type: 'join', name: 'Many-deal second', faction: 'guild', entry: entry() });
|
||||||
|
assert.equal(guest.status, 200, JSON.stringify(guest.data));
|
||||||
|
assert.ok(guest.cookie);
|
||||||
|
const guestSecond = await request(path, guest.cookie, { type: 'addControlledFaction', version: guest.data.version,
|
||||||
|
name: 'Many-deal second', faction: 'harkonnen', entry: entry() }, guest.data.me);
|
||||||
|
assert.equal(guestSecond.status, 200, JSON.stringify(guestSecond.data));
|
||||||
|
assert.ok(guestSecond.cookie);
|
||||||
|
const guestCookies = `${guest.cookie}; ${guestSecond.cookie}`;
|
||||||
|
const seats = [
|
||||||
|
{ id: host.data.me, cookie: hostCookies }, { id: hostSecond.data.me, cookie: hostCookies },
|
||||||
|
{ id: guest.data.me, cookie: guestCookies }, { id: guestSecond.data.me, cookie: guestCookies },
|
||||||
|
];
|
||||||
|
let view = guestSecond.data;
|
||||||
|
const act = async (seat: typeof seats[number], action: object) => {
|
||||||
|
const next = await request(path, seat.cookie, { version: view.version, action }, seat.id);
|
||||||
|
assert.equal(next.status, 200, JSON.stringify(next.data));
|
||||||
|
view = next.data;
|
||||||
|
};
|
||||||
|
for (const seat of seats) await act(seat, { type: 'ready' });
|
||||||
|
await act(seats[0], { type: 'start' });
|
||||||
|
for (const seat of seats) {
|
||||||
|
const own = await request(path, seat.cookie, undefined, seat.id);
|
||||||
|
assert.equal(own.status, 200, JSON.stringify(own.data));
|
||||||
|
view = own.data;
|
||||||
|
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
|
||||||
|
if (choices?.length) await act(seat, { 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 })),
|
||||||
|
controllers: view.twoPlayerControllers };
|
||||||
|
|
||||||
|
await act(seats[0], { type: 'dealOffer', targets: [seats[1].id, seats[2].id], terms: 'The three factions will share information.' });
|
||||||
|
const offer = view.deals.at(-1);
|
||||||
|
assert.ok(offer);
|
||||||
|
assert.deepEqual(offer.participants, [seats[0].id, seats[1].id, seats[2].id]);
|
||||||
|
assert.deepEqual(offer.acceptVotes, [seats[0].id]);
|
||||||
|
assert.equal(offer.status, 'offered');
|
||||||
|
for (const seat of seats) {
|
||||||
|
const read = await request(path, seat.cookie, undefined, seat.id);
|
||||||
|
assert.equal(read.status, 200, JSON.stringify(read.data));
|
||||||
|
assert.deepEqual(read.data.deals.find(deal => deal.id === offer.id), offer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yes = { version: view.version, action: { type: 'dealRespond', deal: offer.id, accept: true } };
|
||||||
|
// Another controller's seat selector is not a credential. The fourth seat
|
||||||
|
// is authenticated but is not one of this agreement's participants.
|
||||||
|
assert.equal((await request(path, hostCookies, yes, seats[2].id)).status, 409);
|
||||||
|
assert.equal((await request(path, guestCookies, yes, seats[3].id)).status, 409);
|
||||||
|
// The same controller's second faction still needs its own seat credential.
|
||||||
|
assert.equal((await request(path, host.cookie, yes, seats[1].id)).status, 409);
|
||||||
|
await act(seats[1], yes.action);
|
||||||
|
assert.equal(view.deals.at(-1)?.status, 'offered');
|
||||||
|
assert.deepEqual([...view.deals.at(-1)!.acceptVotes!].sort(), [seats[0].id, seats[1].id].sort());
|
||||||
|
await act(seats[2], yes.action);
|
||||||
|
assert.equal(view.deals.at(-1)?.status, 'active');
|
||||||
|
|
||||||
|
await act(seats[2], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||||
|
await act(seats[1], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||||
|
assert.equal(view.deals.at(-1)?.status, 'active');
|
||||||
|
await act(seats[0], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||||
|
assert.equal(view.deals.at(-1)?.status, 'released');
|
||||||
|
const observer = await request(path, seats[3].cookie, undefined, seats[3].id);
|
||||||
|
assert.equal(observer.status, 200, JSON.stringify(observer.data));
|
||||||
|
assert.equal(observer.data.deals.find(deal => deal.id === offer.id)?.status, 'released');
|
||||||
|
assert.deepEqual({ phase: observer.data.phase, turn: observer.data.turn, active: observer.data.active,
|
||||||
|
storm: observer.data.storm, stormDialers: observer.data.stormDialers, stormSubmitted: observer.data.stormSubmitted,
|
||||||
|
players: observer.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
|
||||||
|
controllers: observer.data.twoPlayerControllers }, baseline);
|
||||||
|
const dialer = seats.find(seat => observer.data.stormDialers.includes(seat.id));
|
||||||
|
assert.ok(dialer);
|
||||||
|
view = observer.data;
|
||||||
|
await act(dialer, { type: 'stormDial', amount: 0 });
|
||||||
|
assert.ok(view.stormSubmitted.includes(dialer.id) || view.phase !== 0);
|
||||||
|
});
|
||||||
+109
-1
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
PublicDealError, createPublicDeal, releasePublicDeal, respondPublicDeal,
|
PublicDealError, createPublicDeal, createMultipartyPublicDeal, publicDealAcceptVotes,
|
||||||
|
publicDealParticipants, releasePublicDeal, respondPublicDeal,
|
||||||
validatePublicDeals, withdrawPublicDeal,
|
validatePublicDeals, withdrawPublicDeal,
|
||||||
} from '../game/public-deals';
|
} from '../game/public-deals';
|
||||||
|
|
||||||
@@ -26,6 +27,113 @@ void test('an explicit offer and recipient response preserve input and do not fo
|
|||||||
assert.throws(() => respondPublicDeal(active, players, 'b', true), PublicDealError);
|
assert.throws(() => respondPublicDeal(active, players, 'b', true), PublicDealError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('legacy two-party JSON keeps its exact representation and implicit votes', () => {
|
||||||
|
const pending = JSON.parse(JSON.stringify(offer()));
|
||||||
|
const before = JSON.stringify(pending);
|
||||||
|
validatePublicDeals([pending], players);
|
||||||
|
assert.deepEqual(publicDealParticipants(pending), ['a', 'b']);
|
||||||
|
assert.deepEqual(publicDealAcceptVotes(pending), ['a']);
|
||||||
|
assert.equal(JSON.stringify(pending), before);
|
||||||
|
const active = respondPublicDeal(pending, players, 'b', true);
|
||||||
|
assert.deepEqual(publicDealAcceptVotes(active), ['a', 'b']);
|
||||||
|
assert.equal('participants' in active, false);
|
||||||
|
assert.equal('acceptVotes' in active, false);
|
||||||
|
const released = releasePublicDeal(releasePublicDeal(active, players, 'b', true), players, 'a', true);
|
||||||
|
assert.deepEqual(released.releaseVotes, ['b', 'a']);
|
||||||
|
assert.equal('participants' in released, false);
|
||||||
|
validatePublicDeals(JSON.parse(JSON.stringify([pending, { ...active, id: 'legacy-active' },
|
||||||
|
{ ...released, id: 'legacy-released' }])), players);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('multiparty deals activate only after every invited human accepts and release unanimously', () => {
|
||||||
|
const seats = [...players, { id: 'd', ally: null }, { id: 'e', ally: null }];
|
||||||
|
const initial = createMultipartyPublicDeal(seats, 'a', ['b', 'c', 'd'], ' No raids this turn. ', 3, 'multi-1');
|
||||||
|
assert.deepEqual(initial, { id: 'multi-1', from: 'a', to: 'b', terms: 'No raids this turn.', turn: 3,
|
||||||
|
status: 'offered', releaseVotes: [], participants: ['a', 'b', 'c', 'd'], acceptVotes: ['a'] });
|
||||||
|
const first = respondPublicDeal(initial, seats, 'd', true);
|
||||||
|
assert.equal(first.status, 'offered');
|
||||||
|
assert.deepEqual(first.acceptVotes, ['a', 'd']);
|
||||||
|
assert.deepEqual(initial.acceptVotes, ['a']);
|
||||||
|
assert.throws(() => respondPublicDeal(first, seats, 'd', true), PublicDealError);
|
||||||
|
assert.throws(() => respondPublicDeal(first, seats, 'a', true), PublicDealError);
|
||||||
|
assert.throws(() => respondPublicDeal(first, seats, 'e', true), PublicDealError);
|
||||||
|
const next = respondPublicDeal(first, seats, 'b', true);
|
||||||
|
assert.deepEqual(next.acceptVotes, ['a', 'b', 'd']);
|
||||||
|
const active = respondPublicDeal(next, seats, 'c', true);
|
||||||
|
assert.equal(active.status, 'active');
|
||||||
|
assert.deepEqual(publicDealAcceptVotes(active), ['a', 'b', 'c', 'd']);
|
||||||
|
let current = releasePublicDeal(active, seats, 'd', true);
|
||||||
|
current = releasePublicDeal(current, seats, 'a', true);
|
||||||
|
assert.deepEqual(current.releaseVotes, ['a', 'd']);
|
||||||
|
current = releasePublicDeal(current, seats, 'd', false);
|
||||||
|
assert.deepEqual(current.releaseVotes, ['a']);
|
||||||
|
for (const actor of ['c', 'b']) current = releasePublicDeal(current, seats, actor, true);
|
||||||
|
assert.equal(current.status, 'active');
|
||||||
|
current = releasePublicDeal(current, seats, 'd', true);
|
||||||
|
assert.equal(current.status, 'released');
|
||||||
|
assert.deepEqual(current.releaseVotes, ['a', 'b', 'c', 'd']);
|
||||||
|
validatePublicDeals([initial, { ...active, id: 'multi-active' }, { ...current, id: 'multi-released' }], seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a recipient can decline after accepting and a proposer can withdraw a partial offer', () => {
|
||||||
|
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'decline');
|
||||||
|
const accepted = respondPublicDeal(initial, players, 'b', true);
|
||||||
|
const declined = respondPublicDeal(accepted, players, 'b', false);
|
||||||
|
const withdrawn = withdrawPublicDeal(accepted, players, 'a');
|
||||||
|
assert.equal(declined.status, 'declined');
|
||||||
|
assert.equal(withdrawn.status, 'withdrawn');
|
||||||
|
assert.deepEqual(declined.acceptVotes, ['a', 'b']);
|
||||||
|
assert.deepEqual(withdrawn.acceptVotes, ['a', 'b']);
|
||||||
|
assert.throws(() => respondPublicDeal(declined, players, 'c', true), PublicDealError);
|
||||||
|
validatePublicDeals([{ ...declined, id: 'declined' }, withdrawn], players);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a six-seat deal requires all five responses and all six release votes', () => {
|
||||||
|
const seats = ['a', 'b', 'c', 'd', 'e', 'f'].map(id => ({ id, ally: null }));
|
||||||
|
let deal = createMultipartyPublicDeal(seats, 'a', ['b', 'c', 'd', 'e', 'f'], 'Terms', 1, 'six');
|
||||||
|
for (const actor of ['b', 'c', 'd', 'e']) deal = respondPublicDeal(deal, seats, actor, true);
|
||||||
|
assert.equal(deal.status, 'offered');
|
||||||
|
deal = respondPublicDeal(deal, seats, 'f', true);
|
||||||
|
assert.equal(deal.status, 'active');
|
||||||
|
for (const actor of ['a', 'b', 'c', 'd', 'e']) deal = releasePublicDeal(deal, seats, actor, true);
|
||||||
|
assert.equal(deal.status, 'active');
|
||||||
|
deal = releasePublicDeal(deal, seats, 'f', true);
|
||||||
|
assert.equal(deal.status, 'released');
|
||||||
|
validatePublicDeals([deal], seats);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a new alliance between any invitees blocks acceptance but not decline or mutual release', () => {
|
||||||
|
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'alliance');
|
||||||
|
const allied = players.map(player => player.id === 'b' ? { ...player, ally: 'c' } : player);
|
||||||
|
assert.throws(() => createMultipartyPublicDeal(allied, 'a', ['b', 'c'], 'terms', 2, 'bad'), PublicDealError);
|
||||||
|
assert.throws(() => respondPublicDeal(initial, allied, 'b', true), PublicDealError);
|
||||||
|
assert.equal(respondPublicDeal(initial, allied, 'b', false).status, 'declined');
|
||||||
|
let active = respondPublicDeal(respondPublicDeal(initial, players, 'b', true), players, 'c', true);
|
||||||
|
for (const actor of ['b', 'a', 'c']) active = releasePublicDeal(active, allied, actor, true);
|
||||||
|
assert.equal(active.status, 'released');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('multiparty shape rejects duplicate, noncanonical and impossible lifecycle votes', () => {
|
||||||
|
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'shape');
|
||||||
|
for (const targets of [['b'], ['b', 'b'], ['a', 'b'], ['b', 'bot'], ['b', 'missing'],
|
||||||
|
['a', 'b', 'c', 'bot', 'missing', 'extra']])
|
||||||
|
assert.throws(() => createMultipartyPublicDeal(players, 'a', targets, 'terms', 2, 'invalid'), PublicDealError);
|
||||||
|
for (const invalid of [
|
||||||
|
{ ...initial, participants: ['a', 'c', 'b'] },
|
||||||
|
{ ...initial, participants: ['a', 'b', 'b'] },
|
||||||
|
{ ...initial, acceptVotes: ['b'] },
|
||||||
|
{ ...initial, acceptVotes: ['a', 'c', 'b'] },
|
||||||
|
{ ...initial, acceptVotes: ['a', 'b', 'b'] },
|
||||||
|
{ ...initial, acceptVotes: ['a', 'b', 'c'] },
|
||||||
|
{ ...initial, status: 'active', acceptVotes: ['a', 'b'] },
|
||||||
|
{ ...initial, status: 'active', acceptVotes: ['a', 'b', 'c'], releaseVotes: ['c', 'b'] },
|
||||||
|
{ ...initial, status: 'released', acceptVotes: ['a', 'b', 'c'], releaseVotes: ['a', 'b'] },
|
||||||
|
{ ...initial, releaseVotes: ['a'] },
|
||||||
|
{ ...initial, acceptVotes: undefined },
|
||||||
|
{ ...initial, extra: true },
|
||||||
|
]) assert.throws(() => validatePublicDeals([invalid], players), PublicDealError);
|
||||||
|
});
|
||||||
|
|
||||||
void test('decline and withdrawal require their respective participant and are terminal', () => {
|
void test('decline and withdrawal require their respective participant and are terminal', () => {
|
||||||
const pending = offer();
|
const pending = offer();
|
||||||
assert.throws(() => withdrawPublicDeal(pending, players, 'b'), PublicDealError);
|
assert.throws(() => withdrawPublicDeal(pending, players, 'b'), PublicDealError);
|
||||||
|
|||||||
Reference in new issue
Block a user