Allow group departures with bribe commitments
Verify and publish checkpoint container / container (push) Successful in 10m51s

This commit is contained in:
Quintine committed 2026-10-29 00:46:59 +11:00
1 parent 3e0fa2a8a2
commit 0faa7923b0
13 files changed
+437 -37

No files matched your search

+8 -2
View File
@@ -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 ? <ul>{view.groups.map(group =>
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
{proposal ? <div aria-live="polite">
{view.admissionBlocked && <p>Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.</p>}
<p>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
@@ -81,6 +85,8 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
'Decline and cancel invitation'}</button>}
</div> : <div>
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
Finish the commitment or wait until its binding period ends.</p>}
{options.invitations.length > 0 && <p>{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.'}</p>}
+15 -5
View File
@@ -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).
+28 -10
View File
@@ -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.
+9 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+16 -7
View File
@@ -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<T>(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)
+1 -1
View File
@@ -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',
@@ -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);
});
+111
View File
@@ -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);
});
@@ -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/);
});
+2 -2
View File
@@ -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});
@@ -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<string,unknown>):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);
});
+6 -6
View File
@@ -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}));