From 0faa7923b08876f338457826dedd3fe2bf6f2eca Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 00:46:59 +1100
Subject: [PATCH] Allow group departures with bribe commitments
---
components/coalition-nexus.tsx | 10 +-
docs/CURRENT_STATUS.md | 20 ++-
docs/OPTIONAL_ALLIANCE_VICTORY.md | 38 ++++--
docs/RULE_DECISIONS.md | 11 +-
game/bots.ts | 4 +-
game/engine.ts | 23 ++--
game/reference.ts | 2 +-
tests/coalition-bribe-alliance-guides.test.ts | 76 ++++++++++++
tests/coalition-bribe-departure.test.tsx | 111 +++++++++++++++++
tests/coalition-bribe-nexus-controls.test.tsx | 47 +++++++
tests/coalition-nexus-engine.test.ts | 4 +-
...lition-outsider-commitment-funding.test.ts | 116 ++++++++++++++++++
tests/coalition-shipment-funding.test.ts | 12 +-
13 files changed, 437 insertions(+), 37 deletions(-)
create mode 100644 tests/coalition-bribe-alliance-guides.test.ts
create mode 100644 tests/coalition-bribe-departure.test.tsx
create mode 100644 tests/coalition-bribe-nexus-controls.test.tsx
create mode 100644 tests/coalition-outsider-commitment-funding.test.ts
diff --git a/components/coalition-nexus.tsx b/components/coalition-nexus.tsx
index 09b09fd9..d2a3bd3a 100644
--- a/components/coalition-nexus.tsx
+++ b/components/coalition-nexus.tsx
@@ -12,6 +12,9 @@ export type CoalitionNexusView = {
revision: number;
/** Absent preserves the original, at-most-three alliance prototype. */
variant?: 'unlimited';
+ /** Until group admission can reconcile a live bribe commitment, only
+ * departure remains available in this Nexus. */
+ admissionBlocked?: boolean;
};
export type CoalitionNexusIntent =
@@ -29,7 +32,7 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
const seated = view.players.some(player => player.id === view.me);
const ownGroup = view.groups.find(group => group.members.includes(view.me));
const occupied = new Set(view.groups.flatMap(group => group.members));
- const invitations = !seated || view.proposal ? [] : view.groups.flatMap(group =>
+ const invitations = !seated || view.proposal || view.admissionBlocked ? [] : view.groups.flatMap(group =>
(group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players
.filter(player => !occupied.has(player.id) &&
(player.id === view.me || group.members.includes(view.me)))
@@ -40,7 +43,7 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
return {
invitations,
mayLeave: seated && !!ownGroup && !view.proposal,
- mayAccept: mayDecline && !view.proposal!.approvals.includes(view.me),
+ mayAccept: mayDecline && !view.admissionBlocked && !view.proposal!.approvals.includes(view.me),
mayDecline,
};
}
@@ -66,6 +69,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
{view.groups.length ?
There are no alliances yet.
}
{proposal ?
+ {view.admissionBlocked &&
Invitations are unavailable while a bribe commitment remains unresolved.
+ Finish the commitment or wait until its binding period ends.
}
{options.invitations.length > 0 &&
{view.variant === 'unlimited'
? 'Invite an unallied player to an existing alliance, or ask to join one.'
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}
}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c032a6bb..8a7f22b0 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,11 +17,21 @@ open mode gates.
## Current checkpoint and work
+**Staged group departures and unrelated commitments — 29 October. Prototyped / Partial.**
+Group members can leave while bribe answers remain binding; invitations stay
+blocked until those commitments resolve. Group funding remains available when
+only nonmembers have unfinished commitments, while member commitments still
+fence it. Whole-turn Nexus changes, live-bribe admission and member-commitment
+funding remain unfinished. Twelve related files pass 123 cases, including
+real staged group actions, bribe answers, donor payments and rendered controls;
+a separate simultaneous-guide case passes. Typecheck, lint and the production
+build pass. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-commitments--29-october-2026).
+
**Staged group Truthtrance and larger worm decisions — 29 October. Prototyped / Partial.**
Alliance answers and expressions now read all group members rather than the
single-partner field; the respondent's owned leave can satisfy a No. Bribe
-questions reject every current group ally. Group changes with live bribe or
-whole-turn commitments remain fenced. Larger worm response sequences have
+questions reject every current group ally. Group admissions with live bribes and
+changes with whole-turn commitments remain fenced. Larger worm response sequences have
action-level tests with explicitly staged forces. Group lobby entry and complete
interactions remain unfinished. Eighteen related files pass 185 cases,
including the real-action group questions, rendered guides and staged worm
@@ -35,7 +45,7 @@ cover four-member unanimous admission, three-donor funding, departure and
rejoining, a three-stronghold win, Emperor aid, bot admission and actual table
controls. Membership and selected faction helper cases cover up to six seats.
Fourteen related files pass 120 cases, with an additional pair-target case;
-typecheck, lint and the production build pass. Live bribe and whole-turn commitments, other profiles,
+typecheck, lint and the production build pass. Live-bribe admissions and whole-turn commitments, other profiles,
full interactions and lobby entry remain unfinished.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
@@ -89,8 +99,8 @@ real group formation/transport actions.
**Staged group actions after Truthtrance history — 29 October. Prototyped / Partial.**
Settled commitment records no longer permanently disable group Nexus and
-funding. Unresolved records remain blocked until reconciliation, and active
-bribe-expression and revival variants are also fenced. Group play is still
+funding. The later scope above allows certain actions with unresolved outsider
+or bribe records; member-funded obligations remain fenced. Group play is still
unavailable from the lobby. Five related files pass 60 cases; typecheck, lint
and the production build pass. The new cases stage commitment histories and exercise real group
actions, rather than a full Truthtrance journey. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md
index b1ce25fa..fd277a05 100644
--- a/docs/OPTIONAL_ALLIANCE_VICTORY.md
+++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md
@@ -1,5 +1,23 @@
# Optional three-stronghold alliance victory
+## Group departures and unrelated commitments — 29 October 2026
+
+**Staged Basic prototype, not a selectable game mode.** An alliance member
+may leave during a Nexus while ordinary bribe Truthtrance answers remain live.
+The original payment guide and answer remain binding, and existing
+reconciliation still checks the acting player's commitments. New group
+invitations and acceptance remain unavailable while any bribe commitment is
+unresolved; a pending invitation can still be declined. The table explains
+this limit and bots avoid blocked admissions.
+
+Group bid and physical-shipment funding now ignores unfinished commitments
+belonging solely to nonmembers, because those payments do not debit outsiders.
+An unfinished supported commitment belonging to any group member still fences
+the entire funding path, including donor pledges. Whole-turn commitments still
+block group Nexus changes. Admission with live bribes and funding when a member
+has a commitment need further payment and consent preflight; broader profiles
+and lobby entry remain unfinished.
+
## Group Truthtrance and larger worm decisions — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** Final-Nexus
@@ -9,7 +27,7 @@ their group when that owned action satisfies them; the guide does not invent
another player's consent. Another member may leave, with the existing
reconciliation rules releasing an answer only if it becomes impossible. Bribe
questions reject every current group ally as a recipient. Whole-turn alliance
-leaves, group changes with live bribe or whole-turn commitments, and wider
+leaves, group admissions with live bribes or changes with whole-turn commitments, and wider
combinations remain unfinished.
Action-level worm tests additionally exercise three and four threatened group
@@ -38,7 +56,7 @@ pairs any number of allies with three strongholds. Unanimous admission,
departure handling, donor-specific escrow and extension of single-ally powers
are provisional online procedures, not additional publisher rulings. Tests use
an explicitly staged Basic classic-faction ledger, not a normal lobby start.
-Advanced, expansions, optional modules, live bribe and whole-turn commitments and
+Advanced, expansions, optional modules, some live bribe and whole-turn commitments and
complete group interactions remain unfinished. No lobby creates this ledger;
the prototype does not certify or open a complete rules mode.
@@ -46,7 +64,7 @@ the prototype does not certify or open a complete rules mode.
**Staged Basic prototype, not a selectable game mode.** The public roster
names both members of a player's group outside the Nexus. A Guild group ally
-can use its self-funded native transport even when an unrelated unresolved
+can use its self-funded native transport even when an unresolved group-member
Truthtrance commitment hides group-funding controls. The original permission
and native rate cancellation windows remain in force; group-funded transport
continues to use its separate commitment restrictions. Applying the pair
@@ -57,11 +75,11 @@ entry remain unfinished.
**Staged Basic prototype, not a selectable game mode.** Fulfilled and released
Truthtrance records no longer disable later group Nexus negotiations or group
-funding. Unresolved records remain fenced until their native reconciliation
-closes them, including an unresolved older record; funding also fences live
-bribe-expression and leader/expression revival commitments. The existing
-committed-bid settlement exception is retained. Combining group actions with
-unresolved commitments remains unfinished, and no lobby exposes group play.
+funding. The later scope above allows departures with live bribe answers and
+funding with outsider-only commitments; group-member funding with unresolved
+records remains fenced until reconciliation. The existing committed-bid
+settlement exception is retained. Other combinations remain unfinished, and no
+lobby exposes group play.
## Group funding with Karama shipment pricing — 29 October 2026
@@ -212,8 +230,8 @@ The staged table renders the group controls and minimal bots can answer.
Unanimity, cancellation, the pending-Nexus hold and the surviving pair are
provisional online procedures, not publisher rulings. This scope excludes
-expansion decks/factions, Advanced, optional modules, live bribe or whole-turn
-commitments and pledged aid. No lobby creates the group ledger: faction ally
+expansion decks/factions, Advanced, optional modules, live bribe admission or
+whole-turn commitments and pledged aid. No lobby creates the group ledger: faction ally
benefits, group funding and other interactions must be connected before any
player can start the printed larger-alliance option. The later unlimited
prototype above extends this staged groundwork.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 75162a67..0ad11433 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,12 @@
# Rule decision index
+## Group departures and unrelated commitments — 29 October 2026
+
+Staged Basic group departure remains available with live bribe answers;
+invitations remain fenced. Funding can proceed when only an outsider has an
+unfinished commitment, while any group member's unfinished commitment still
+blocks it. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-commitments--29-october-2026).
+
## Group Truthtrance and larger worm decisions — 29 October 2026
Staged Basic group alliance answers use actual group membership, with a
@@ -52,8 +59,8 @@ funding gate. The pair privilege's extension to two allies is provisional.
## Group actions after Truthtrance history — 29 October 2026
Settled commitment records no longer prevent staged group negotiation and
-funding. Unresolved records continue to require native reconciliation before
-these unsupported combinations can proceed. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
+funding. The later scope above permits selected actions with unresolved records;
+other combinations still require native reconciliation. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
## Group funding with Karama shipment pricing — 29 October 2026
diff --git a/game/bots.ts b/game/bots.ts
index 9db9861e..dee388e5 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3189,7 +3189,7 @@ function policyActions(g: GameView): Action[] {
const participants = [...proposal.original.members, proposal.entrant];
return participants.includes(me.id) && !proposal.approvals.includes(me.id)
? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus,
- turn: proposal.turn, accept: true, expectedRevision: group.revision}]
+ turn: proposal.turn, accept: !group.admissionBlocked, expectedRevision: group.revision}]
: [];
}
const thumper = me.hand?.find((c) => c.effect === 'thumper');
@@ -3226,7 +3226,7 @@ function policyActions(g: GameView): Action[] {
const card = me.hand?.find((c) => c.effect === 'harvester');
if (card) return [{ type: 'card', card: card.id }];
}
- if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
+ if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' && !group.admissionBlocked &&
!group.groups.some(alliance => alliance.members.includes(me.id))) {
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
diff --git a/game/engine.ts b/game/engine.ts
index 628f8d6e..786e55c5 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -16036,6 +16036,9 @@ function allianceQuestionWindow(g: Game): boolean {
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
return !!records?.some(record => !record.fulfilled && !record.released);
}
+function coalitionAdmissionBlocked(g: Game): boolean {
+ return [g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises);
+}
/** Group negotiation is deliberately unexposed until its other alliance
* mechanics are integrated. This narrow profile keeps existing pair games
* and unrelated commitments on their original paths. */
@@ -16049,7 +16052,7 @@ function coalitionNexusAvailable(g: Game): boolean {
g.expansions.length === 0 && g.players.every(seat =>
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) &&
Object.values(g.aid).every(credit => credit.amount === 0) &&
- ![g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
+ !unsettledPromises(g.turnActionPromises);
}
function coalitionRule
(work: () => T): T {
try { return work(); }
@@ -16106,12 +16109,14 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
- g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction ||
- (!committedBid && [g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
- g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
- g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises))) return false;
+ g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction) return false;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
- return !!group && group.members.length >= 3;
+ if (!group || group.members.length < 3) return false;
+ const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
+ !!records?.some(record => group.members.includes(record.player) && !record.fulfilled && !record.released);
+ return committedBid || ![g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
+ g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
+ g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
}
function coalitionEmperorGroup(g: Game, p: Player) {
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
@@ -16200,7 +16205,7 @@ function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?
if (group && group.members.length >= 3) {
if (groupAllianceAnswersMatch(g, p, opportunity, promises, expressions))
return {actions: [] as Action[], bribePreparation:false};
- if (g.coalitionState!.proposal || [g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises))
+ if (g.coalitionState!.proposal || unsettledPromises(g.turnActionPromises))
return null;
const trial = structuredClone(g);
try {
@@ -44560,6 +44565,9 @@ function applyActionInner(
}
if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') {
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
+ if (t === 'coalitionPropose' || t === 'coalitionAnswer' && action.accept !== false)
+ requireRule(!coalitionAdmissionBlocked(g),
+ 'Resolve current bribe commitments before inviting an ally.');
const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision');
if (t === 'coalitionPropose') {
initializeCoalitionEscrow(g);
@@ -46864,6 +46872,7 @@ export function viewGame(state: Game, id: string, now?: number) {
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
proposal: g.coalitionState!.proposal,
revision: g.coalitionState!.revision,
+ ...(coalitionAdmissionBlocked(g) ? {admissionBlocked:true} : {}),
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
} : null,
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
diff --git a/game/reference.ts b/game/reference.ts
index c225789f..35951966 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding with outsider-only commitments, group departures with live bribes, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',
diff --git a/tests/coalition-bribe-alliance-guides.test.ts b/tests/coalition-bribe-alliance-guides.test.ts
new file mode 100644
index 00000000..cafe1acd
--- /dev/null
+++ b/tests/coalition-bribe-alliance-guides.test.ts
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { baseDeck } from '../game/cards';
+import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+
+const send = (game:Game, actor:string, action:Action) => applyAction(game, actor, action);
+const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!;
+
+/** Stage the Nexus, respondent's wallet and the two physical Truthtrance
+ * cards. Alliance formation, both questions and all guided actions are real. */
+function initial(): {game:Game; cards:string[]} {
+ let game = createGame('GROUPBOTH', newPlayer('a','Atreides','atreides'), false);
+ game.players.push(newPlayer('b','Emperor','emperor'), newPlayer('c','Fremen','fremen'),
+ newPlayer('d','Harkonnen','harkonnen'), newPlayer('e','Guild','guild'));
+ Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,ready:[],
+ order:['a','b','c','d','e'],deck:baseDeck()});
+ game.coalitionState = emptyCoalitionState('unlimited'); seat(game,'a').spice = 8;
+ game = send(game,'a',{type:'alliance',target:'b'});
+ game = send(game,'b',{type:'alliance',target:'a'});
+ const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
+ game = send(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c',
+ expectedRevision:game.coalitionState!.revision});
+ for (const actor of ['b','c']) {
+ const offer = game.coalitionState!.proposal!;
+ game = send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
+ turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
+ }
+ const cards:string[] = [];
+ for (let count=0;count<2;count++) {
+ const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
+ const [card] = game.deck.splice(index,1); cards.push(card.id); seat(game,'e').hand.push(card);
+ }
+ return {game,cards};
+}
+function ask(game:Game, card:string, question:unknown, answer:'yes'|'no'):Game {
+ game = send(game,'e',{type:'card',card});
+ while(game.truthtrance?.stage === 'priority') {
+ const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
+ game = send(game,next.id,{type:'truthPass'});
+ }
+ game = send(game,'e',{type:'truthAsk',question});
+ const view = viewGame(game,'a');
+ const answers = game.truthtrance?.question?.kind === 'bribe' ? view.truthBribeAnswers : view.truthAllianceAnswers;
+ assert.ok(answers?.includes(answer), `The ${answer} answer must be legal with both commitments.`);
+ return send(game,'a',{type:'truthAnswer',answer});
+}
+
+void test('a group alliance No and positive bribe answer retain both legal guides',()=>{
+ const fixture = initial();
+ let game = ask(fixture.game,fixture.cards[0],
+ {kind:'bribe',target:'a',turn:fixture.game.turn,recipient:'d',minimum:2},'yes');
+ game = ask(game,fixture.cards[1],
+ {kind:'alliance',target:'a',turn:game.turn,partner:'b'},'no');
+ const bribe = game.bribePromises!.find(row=>row.player === 'a')!;
+ const alliance = game.alliancePromises!.find(row=>row.player === 'a')!;
+ assert.ok(!bribe.released && !bribe.fulfilled && !alliance.released && !alliance.fulfilled);
+ const beforeReady = structuredClone(game);
+ assert.throws(()=>send(game,'a',{type:'ready'}));
+ assert.deepEqual(game,beforeReady);
+ const guide = viewGame(game,'a').alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type,'coalitionLeave');
+ game = send(game,'a',guide.action);
+ assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
+ assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
+ assert.ok(!game.alliancePromises!.find(row=>row.event === alliance.event)!.released);
+ assert.ok(!game.bribePromises!.find(row=>row.event === bribe.event)!.released);
+ const payment = viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
+ assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d');
+ game = send(game,'a',payment.action);
+ assert.equal(game.bribePromises!.find(row=>row.event === bribe.event)!.fulfilled,true);
+ assert.equal(seat(game,'d').bribes,2);
+ game = send(game,'a',{type:'ready'});
+ assert.ok(game.ready.includes('a'));
+ for(const card of fixture.cards) assert.equal(game.discard.filter(row=>row.id === card).length,1);
+});
diff --git a/tests/coalition-bribe-departure.test.tsx b/tests/coalition-bribe-departure.test.tsx
new file mode 100644
index 00000000..930d3977
--- /dev/null
+++ b/tests/coalition-bribe-departure.test.tsx
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { baseDeck } from '../game/cards';
+import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
+
+const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
+const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
+
+/** The quiet Nexus, wallet and physical Truthtrance are staged. Pair/group
+ * formation, question, answer, departure and payment are actual actions. */
+function group(): Game {
+ let game = createGame('GROUPBRIBE', newPlayer('a', 'Atreides', 'atreides'), false);
+ game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
+ newPlayer('d', 'Harkonnen', 'harkonnen'), newPlayer('e', 'Guild', 'guild'));
+ Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d','e'],
+ ready:[], deck:baseDeck()});
+ game.coalitionState = emptyCoalitionState('unlimited');
+ seat(game, 'a').spice = 8;
+ game = send(game, 'a', {type:'alliance', target:'b'});
+ game = send(game, 'b', {type:'alliance', target:'a'});
+ const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
+ game = send(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
+ expectedRevision:game.coalitionState!.revision});
+ for (const id of ['b','c']) {
+ const offer = game.coalitionState!.proposal!;
+ game = send(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
+ turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
+ }
+ const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
+ const [card] = game.deck.splice(index, 1); seat(game, 'e').hand.push(card);
+ return game;
+}
+function ask(answer: 'yes' | 'no', expression = false): Game {
+ let game = group();
+ const card = seat(game, 'e').hand.find(row => row.effect === 'truthtrance')!;
+ game = send(game, 'e', {type:'card', card:card.id});
+ while (game.truthtrance?.stage === 'priority') {
+ const passer = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(passer);
+ game = send(game, passer.id, {type:'truthPass'});
+ }
+ const question = expression
+ ? {kind:'bribeExpression', target:'a', turn:game.turn, claim:{kind:'and', terms:[
+ {kind:'payment', recipient:'d', minimum:2}, {kind:'payment', recipient:'e', minimum:1}]}}
+ : {kind:'bribe', target:'a', turn:game.turn, recipient:'d', minimum:2};
+ game = send(game, 'e', {type:'truthAsk', question});
+ const view = viewGame(game, 'a');
+ const answers = expression ? view.truthBribeExpressionAnswers : view.truthBribeAnswers;
+ assert.ok(answers?.includes(answer));
+ return send(game, 'a', {type:'truthAnswer', answer});
+}
+function leave(game:Game, actor:string):Game {
+ return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
+}
+function blockedProposal(game:Game) {
+ const alliance = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(alliance);
+ const before = structuredClone(game);
+ assert.throws(() => send(game, 'a', {type:'coalitionPropose', groupId:alliance.id,
+ entrant:'d', expectedRevision:game.coalitionState!.revision}));
+ assert.deepEqual(game, before);
+}
+
+void test('a positive bribe answer allows owned group departure and remains payable', () => {
+ let game = ask('yes');
+ const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
+ const options = coalitionNexusOptions(projected);
+ assert.equal(options.mayLeave, true); assert.equal(options.invitations.length, 0);
+ const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
+ assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
+ blockedProposal(game);
+ game = leave(game, 'a');
+ assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
+ assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'b')!.members, ['b','c']);
+ const promise = game.bribePromises!.find(row => row.player === 'a')!;
+ assert.equal(promise.released ?? false, false);
+ const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.target, 'd');
+ game = send(game, 'a', guide.action);
+ assert.equal(game.bribePromises!.find(row => row.event === promise.event)!.fulfilled, true);
+ assert.equal(seat(game, 'd').bribes, 2);
+});
+
+void test('a negative bribe answer survives another member departing and retains its cap', () => {
+ let game = ask('no');
+ blockedProposal(game);
+ game = leave(game, 'b');
+ assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
+ const before = structuredClone(game);
+ assert.throws(() => send(game, 'a', {type:'bribe', target:'d', amount:2}));
+ assert.deepEqual(game, before);
+ game = send(game, 'a', {type:'bribe', target:'d', amount:1});
+ assert.equal(seat(game, 'd').bribes, 1);
+ assert.equal(game.bribePromises!.find(row => row.player === 'a')!.released ?? false, false);
+});
+
+void test('a positive bribe expression remains binding and payable after departure', () => {
+ let game = ask('yes', true);
+ blockedProposal(game);
+ game = leave(game, 'a');
+ const promise = game.bribeExpressionPromises!.find(row => row.player === 'a')!;
+ assert.equal(promise.released ?? false, false);
+ for (let step = 0; step < 2; step++) {
+ const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
+ game = send(game, 'a', guide.action);
+ }
+ assert.equal(game.bribeExpressionPromises!.find(row => row.event === promise.event)!.fulfilled, true);
+ assert.equal(seat(game, 'd').bribes, 2); assert.equal(seat(game, 'e').bribes, 1);
+});
diff --git a/tests/coalition-bribe-nexus-controls.test.tsx b/tests/coalition-bribe-nexus-controls.test.tsx
new file mode 100644
index 00000000..67f3bc6e
--- /dev/null
+++ b/tests/coalition-bribe-nexus-controls.test.tsx
@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
+import type { CoalitionGroup } from '../game/coalition-alliance';
+
+const players = ['a', 'b', 'c', 'd'].map(id => ({id, name:id.toUpperCase()}));
+const group: CoalitionGroup = {id:'group', members:['a','b','c'], formedTurn:1, edges:[
+ {members:['a','b'], formedTurn:1}, {members:['a','c'], formedTurn:1},
+ {members:['b','c'], formedTurn:1},
+]};
+const view = (me:string, admissionBlocked = false): CoalitionNexusView => ({
+ me, players, groups:[group], proposal:null, revision:3, variant:'unlimited', admissionBlocked,
+});
+const render = (value:CoalitionNexusView) => renderToStaticMarkup(createElement(CoalitionNexus, {
+ view:value, onIntent() {},
+}));
+
+void test('a live bribe commitment hides new invitations while retaining the owned leave control', () => {
+ const member = view('a', true), choices = coalitionNexusOptions(member), html = render(member);
+ assert.deepEqual(choices.invitations, []);
+ assert.equal(choices.mayLeave, true);
+ assert.match(html, /Invitations are unavailable while a bribe commitment remains unresolved/);
+ assert.match(html, /Leave A, B, C/);
+ assert.doesNotMatch(html, /Invite D to/);
+
+ const entrant = view('d', true);
+ assert.deepEqual(coalitionNexusOptions(entrant).invitations, []);
+ assert.equal(coalitionNexusOptions(entrant).mayLeave, false);
+ assert.doesNotMatch(render(entrant), /Ask to join/);
+});
+
+void test('invitations return after the block clears; a blocked pending offer can be declined', () => {
+ const member = view('a');
+ assert.deepEqual(coalitionNexusOptions(member).invitations.map(row => row.entrant), ['d']);
+ assert.match(render(member), /Invite D to A, B, C/);
+ assert.doesNotMatch(render(member), /bribe commitment remains unresolved/);
+
+ const pending = view('d', true);
+ pending.proposal = {event:'event', nexus:'nexus', turn:2, groupId:group.id,
+ original:group, entrant:'d', proposer:'a', approvals:['a']};
+ assert.equal(coalitionNexusOptions(pending).mayAccept, false);
+ assert.equal(coalitionNexusOptions(pending).mayDecline, true);
+ assert.doesNotMatch(render(pending), /Accept invitation/);
+ assert.match(render(pending), /may decline this invitation/);
+});
diff --git a/tests/coalition-nexus-engine.test.ts b/tests/coalition-nexus-engine.test.ts
index e279de5f..6cbf9ef7 100644
--- a/tests/coalition-nexus-engine.test.ts
+++ b/tests/coalition-nexus-engine.test.ts
@@ -88,7 +88,7 @@ void test('an unresolved prior-turn Truthtrance record still blocks group Nexus'
const game = originalPair();
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
recipient:'c', minimum:1, answer:true, progress:0}];
- assert.equal(viewGame(game, 'a').coalitionNexus, null);
+ assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
@@ -98,7 +98,7 @@ void test('a current unanswered group-incompatible Truthtrance commitment still
const game = originalPair();
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
recipient:'c', minimum:1, answer:true, progress:0}];
- assert.equal(viewGame(game, 'a').coalitionNexus, null);
+ assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
diff --git a/tests/coalition-outsider-commitment-funding.test.ts b/tests/coalition-outsider-commitment-funding.test.ts
new file mode 100644
index 00000000..5384bc3e
--- /dev/null
+++ b/tests/coalition-outsider-commitment-funding.test.ts
@@ -0,0 +1,116 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { baseDeck } from '../game/cards';
+import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+
+const act = (game:Game, id:string, action:Action) => applyAction(game,id,action);
+const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!;
+
+/** The Basic Nexus/ledger, phase boundary and wallets are staged; the pair,
+ * admission, held physical Truthtrance and every question/payment are real. */
+function fixture(phase: 'bidding' | 'movement'):Game {
+ let game=createGame('OUTSIDERFUND',newPlayer('a','Atreides','atreides'),false);
+ game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
+ newPlayer('d','Guild','guild'),newPlayer('e','Harkonnen','harkonnen'));
+ Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','b','c','d','e'],
+ ready:[],storm:18,deck:baseDeck(),discard:[],response:null,decision:null,phaseOpening:null});
+ game.coalitionState=emptyCoalitionState('unlimited');
+ game=act(game,'a',{type:'alliance',target:'b'});
+ game=act(game,'b',{type:'alliance',target:'a'});
+ const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
+ game=act(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'c',
+ expectedRevision:game.coalitionState!.revision});
+ for(const id of ['b','c']) {
+ const offer=game.coalitionState!.proposal!;
+ game=act(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
+ turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
+ }
+ for(const player of game.players) {
+ player.spice=10; player.hand=[]; player.forces={}; player.reserves=20; player.tanks=0;
+ player.traitors=[]; player.traitorChoices=[]; player.shipped=false; player.moved=0;
+ }
+ seat(game,'a').spice=2;
+ if(phase==='bidding') {
+ Object.assign(game,{phase:2,nexus:false,active:null,ready:[]});
+ for(const player of game.players) game=act(game,player.id,{type:'ready'});
+ assert.equal(game.phase,3); assert.equal(game.auction?.active,'a');
+ } else Object.assign(game,{phase:5,nexus:false,active:'a',ready:[],
+ movementRemaining:['a','b','c','d','e'],guildTimingGranted:false,guildTimingLocked:false});
+ const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0);
+ seat(game,'a').hand.push(game.deck.splice(index,1)[0]);
+ return game;
+}
+function ask(game:Game,question:Record):Game {
+ const card=seat(game,'a').hand.find(row=>row.effect==='truthtrance'); assert.ok(card);
+ game=act(game,'a',{type:'card',card:card.id});
+ while(game.truthtrance?.stage==='priority') {
+ const actor=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
+ game=act(game,actor.id,{type:'truthPass'});
+ }
+ return act(game,'a',{type:'truthAsk',question});
+}
+function pledge(game:Game):Game {
+ game=act(game,'b',{type:'coalitionPledge',recipient:'a',amount:4});
+ return act(game,'c',{type:'coalitionPledge',recipient:'a',amount:3});
+}
+function passResponses(game:Game):Game {
+ for(let count=0;game.response && count<30;count++) {
+ const actor=game.players.find(player=>!game.response!.passed.includes(player.id)); assert.ok(actor);
+ game=act(game,actor.id,{type:'passResponse'});
+ }
+ assert.equal(game.response ?? null,null); return game;
+}
+const shipment:Action={type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0,allyPayment:0,
+ coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]};
+
+void test('an unrelated outsider bribe No leaves group shipment funding available',()=>{
+ let game=fixture('movement');
+ game=ask(game,{kind:'bribe',target:'d',turn:game.turn,recipient:'e',minimum:1});
+ assert.ok(viewGame(game,'d').truthBribeAnswers?.includes('no'));
+ game=act(game,'d',{type:'truthAnswer',answer:'no'});
+ const promise=game.bribePromises!.find(row=>row.player==='d')!;
+ assert.equal(promise.fulfilled ?? false,false);
+ game=pledge(game);
+ assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,2);
+ game=passResponses(act(game,'a',shipment));
+ assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
+ assert.equal(seat(game,'d').spice,16);
+ assert.ok(game.bribePromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
+});
+
+void test('an unrelated outsider whole-turn payment No leaves group shipment funding available',()=>{
+ let game=fixture('movement');
+ game=ask(game,{kind:'turnActions',target:'d',turn:game.turn,
+ mixed:{kind:'action',action:{kind:'payment',recipient:'e',minimum:1}}});
+ assert.ok(viewGame(game,'d').truthTurnActionAnswers?.includes('no'));
+ game=act(game,'d',{type:'truthAnswer',answer:'no'});
+ const promise=game.turnActionPromises!.find(row=>row.player==='d')!;
+ game=pledge(game); game=passResponses(act(game,'a',shipment));
+ assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
+ assert.ok(game.turnActionPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
+});
+
+void test('an unrelated outsider phase-bid No leaves group bid funding available',()=>{
+ let game=fixture('bidding');
+ game=ask(game,{kind:'phaseBid',target:'d',turn:game.turn,minimum:10});
+ assert.ok(viewGame(game,'d').truthPhaseBidAnswers?.includes('no'));
+ game=act(game,'d',{type:'truthAnswer',answer:'no'});
+ const promise=game.phaseBidPromises!.find(row=>row.player==='d')!;
+ game=pledge(game);
+ game=act(game,'a',{type:'bid',amount:6,allyPayment:0,
+ coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]});
+ assert.equal(game.auction?.bidder,'a');
+ assert.ok(game.phaseBidPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
+});
+
+void test('a group member commitment still fences the entire group funding path',()=>{
+ let game=fixture('movement');
+ game=ask(game,{kind:'bribe',target:'b',turn:game.turn,recipient:'e',minimum:1});
+ assert.ok(viewGame(game,'b').truthBribeAnswers?.includes('no'));
+ game=act(game,'b',{type:'truthAnswer',answer:'no'});
+ assert.equal(viewGame(game,'a').coalitionFunding,null);
+ const before=structuredClone(game);
+ assert.throws(()=>act(game,'c',{type:'coalitionPledge',recipient:'a',amount:1}));
+ assert.deepEqual(game,before);
+});
diff --git a/tests/coalition-shipment-funding.test.ts b/tests/coalition-shipment-funding.test.ts
index 226a21dc..7eef521b 100644
--- a/tests/coalition-shipment-funding.test.ts
+++ b/tests/coalition-shipment-funding.test.ts
@@ -121,8 +121,8 @@ void test('fulfilled and released Truthtrance records leave group shipment fundi
void test('an unreconciled prior-turn commitment blocks group shipment funding', () => {
const game = fixture();
- game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
- recipient:'c', minimum:1, answer:true, progress:0}];
+ game.bribePromises = [{event:'unreconciled-bribe', player:'b', asker:'a', turn:game.turn - 1,
+ recipient:'d', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
@@ -131,8 +131,8 @@ void test('an unreconciled prior-turn commitment blocks group shipment funding',
void test('a live No bribe-expression commitment blocks group shipment funding', () => {
const game = fixture();
- game.bribeExpressionPromises = [{event:'live-expression-no', player:'d', asker:'a',
- turn:game.turn, claim:{kind:'payment', recipient:'c', minimum:1}, answer:false, progress:[]}];
+ game.bribeExpressionPromises = [{event:'live-expression-no', player:'b', asker:'a',
+ turn:game.turn, claim:{kind:'payment', recipient:'d', minimum:1}, answer:false, progress:[]}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
@@ -141,8 +141,8 @@ void test('a live No bribe-expression commitment blocks group shipment funding',
void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => {
const game = fixture();
- game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
- recipient:'c', minimum:1, answer:true, progress:0}];
+ game.bribePromises = [{event:'live-bribe', player:'b', asker:'a', turn:game.turn,
+ recipient:'d', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));