Support group departures in joint commitment plans
Verify and publish checkpoint container / container (push) Successful in 11m20s

This commit is contained in:
Quintine committed 2026-10-29 01:01:51 +11:00
1 parent 6524ed3bf2
commit 52d932002b
12 files changed
+552 -39

No files matched your search

+16 -3
View File
@@ -17,12 +17,25 @@ open mode gates.
## Current checkpoint and work
**Staged joint group commitments — 29 October. Prototyped / Partial.**
The joint planner can retain matching group membership or quote a member's own
departure, including payment to a former ally after leaving. An invitation
pauses joint reconciliation; compatible admission checks combined payment and
travel costs against the same purse. Thirteen related files pass 58 cases,
including real answers, admission, departure, payments and a constrained shared
wallet. Typecheck, lint and the production build pass. Two older whole-turn
affordability cases were corrected: in their fixture, an 11-spice payment can
use an earlier payment and later Charity, while 13 cannot. Actual
whole-turn group changes, member-commitment funding, other profiles and lobby
entry remain unfinished. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026).
**Staged group admission with live commitments — 29 October. Prototyped / Partial.**
Each proposer and accepting member is checked against their own bribe and
alliance answers; unrelated participants can still negotiate and bots decline
blocked consent. Pending invitations preserve alliance answers. Admission is
fenced when the older whole-turn planner could misread combined alliance and
payment/travel answers as a pair-only state. Fourteen related files pass 82
blocked consent. Pending invitations preserve alliance answers. Admission
was initially fenced when the older whole-turn planner could misread combined
alliance and payment/travel answers as a pair-only state; the later prototype
above narrows that fence. Fourteen related files pass 82
cases, and the joint-planner regression passes separately. Typecheck, lint and
the production build pass. Group funding with
member commitments, whole-turn Nexus changes, other profiles and lobby entry
+18 -6
View File
@@ -1,5 +1,18 @@
# Optional three-stronghold alliance victory
## Group admission with joint commitments — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** The joint
turn-action planner recognizes an existing group and can quote the member's
own departure. Pending invitations pause joint reconciliation while departure
is unavailable. Admission checks a detached completed group against the
participant's combined accepted payment and travel answers, so commitments
continue to share the actual resources. Admission requiring departure remains
available when its joint witness can quote that owned departure. Payment to a
former ally can be quoted after leaving. Actual whole-turn group changes,
group funding with member commitments, wider
profiles and lobby entry remain unfinished.
## Group admission with live commitments — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** A proposer or
@@ -14,12 +27,11 @@ decline when their own acceptance is blocked. The pending invitation can be
declined, and alliance answers are not released merely because it temporarily
prevents departure. Unanimous consent remains a provisional online procedure.
Admission stays fenced for a participant with both an alliance answer and an
unresolved payment or travel-family answer when the older whole-turn planner
is present: that planner still assumes pairs. Group funding with a member's
commitment, whole-turn Nexus changes, wider profiles and lobby entry remain
unfinished. These focused staged cases do not certify complete group play or
expansion combinations.
Admission originally stayed fenced for a participant with both an alliance
answer and an unresolved payment or travel-family answer when the older
whole-turn planner was present; the later group-aware route above narrows
that fence. These staged cases do not certify complete group play or expansion
combinations.
## Group departures and unrelated commitments — 29 October 2026
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Group admission with joint commitments — 29 October 2026
Staged Basic admission can retain a matching group alliance answer alongside
jointly feasible payment or travel answers. Joint planning can quote owned
group departure outside a pending invitation, including a later payment to a
former ally. Whole-turn group changes remain fenced.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026).
## Group admission with live commitments — 29 October 2026
Staged Basic group admission checks each proposer's or accepting participant's
+47 -13
View File
@@ -16063,12 +16063,6 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) =>
row.player === actor && !row.fulfilled && !row.released;
if ([...bribes, ...alliances].some(row => unresolved(row) && row.turn !== g.turn)) return false;
// The joint turn-action reconciler still models alliances as pairs. Do not
// let it release another accepted commitment after this player joins a
// group until that planner can represent group membership and departure.
if (g.turnActionPromises && alliances.some(unresolved) &&
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved))) return false;
if (!bribes.some(row => unresolved(row) && row.answer) && !alliances.some(unresolved)) return true;
try {
const trial = structuredClone(g);
@@ -16095,6 +16089,10 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
trial.truthtrance = null;
const player = getPlayer(trial, actor);
const joint = !!g.turnActionPromises && alliances.some(unresolved) &&
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved));
if (joint && !turnActionCompletion(trial, actor)) return false;
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
@@ -21663,8 +21661,10 @@ function initializeTurnActionOrder(g: Game) {
}
/** All admissible final native partners use the original reciprocal quote.
* Exact outcome rows live only in this witness; no route is persisted. */
type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean };
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] {
type TurnActionAllianceRoute = { steps: ({ player: string; target: string | null; kind?: 'pair' } |
{ player: string; kind: 'groupLeave'; revision: number })[]; uncertain: boolean };
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false,
allowGroupLeave = true): (TurnActionAllianceRoute | undefined)[] {
const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? [];
const active = [...scalars, ...groups].some(promise => promise.player === actor &&
promise.turn === g.turn && !promise.fulfilled && !promise.released);
@@ -21673,6 +21673,21 @@ function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false)
if (!opportunity) return [];
if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn &&
!promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return [];
const member = getPlayer(g, actor);
const coalition = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, actor));
if (coalition && coalition.members.length >= 3) {
if (groupAllianceAnswersMatch(g, member, opportunity, scalars, groups))
return [{steps: [], uncertain: false}];
if (g.coalitionState!.proposal || !allowGroupLeave) return [];
const trial = structuredClone(g);
try {
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
{actor, expectedRevision:trial.coalitionState!.revision})));
return groupAllianceAnswersMatch(trial, getPlayer(trial, actor), opportunity, scalars, groups)
? [{steps: [{player: actor, kind: 'groupLeave', revision: g.coalitionState!.revision}], uncertain: false}]
: [];
} catch (error) { if (error instanceof RuleError) return []; throw error; }
}
const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity,
{ players: g.players, offers: g.allianceOffers });
const plans: TurnActionAllianceRoute[] = [], seen = new Set<string>();
@@ -21964,7 +21979,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
const power = turnActionPowerCompletion(trial, actor,
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
if (!power) return null;
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent,
!unsettledPromises(source.turnActionPromises));
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
// A quoted payment follows only a currently owned modal continuation;
@@ -21977,12 +21993,28 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) ||
required.some(payment => payment.recipient === actor || bribeAllied(trial, owner, payment.recipient) ||
required.some(payment => payment.recipient === actor ||
!trial.players.some(player => player.id === payment.recipient))) continue;
for (const alliancePlan of alliancePlans) {
try {
const departure = alliancePlan?.steps.find(step => step.kind === 'groupLeave');
const leaveFirst = required.some(payment => bribeAllied(trial, owner, payment.recipient));
if (leaveFirst && !departure) continue;
const paymentSource = leaveFirst ? structuredClone(trial) : trial;
if (leaveFirst && departure) {
applyCoalitionTransition(paymentSource, coalitionRule(() => leaveCoalition(
paymentSource.players, paymentSource.coalitionState,
{actor:departure.player, expectedRevision:departure.revision})));
if (required.some(payment => bribeAllied(paymentSource, getPlayer(paymentSource, actor), payment.recipient))) continue;
}
const prepareAlliance = (paid: Game) => {
for (const action of alliancePlan?.steps ?? []) {
if (action.kind === 'groupLeave') {
if (leaveFirst) continue;
applyCoalitionTransition(paid, coalitionRule(() => leaveCoalition(paid.players, paid.coalitionState,
{actor:action.player, expectedRevision:action.revision})));
continue;
}
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
for (const player of paid.players) player.ally = quote.allies[player.id];
paid.allianceOffers = quote.offers;
@@ -22000,7 +22032,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
}
};
const voluntary = { actor, action: { type: 'bribe' } };
for (const cardSeed of turnActionFundedPreparations(trial, actor, required, cardRequirements,
for (const cardSeed of turnActionFundedPreparations(paymentSource, actor, required, cardRequirements,
source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) {
const funded = cardSeed.game, payer = getPlayer(funded, actor);
try {
@@ -22051,7 +22083,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(power.action ? { battlePower: power.action } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => step.kind === 'groupLeave'
? {type: 'coalitionLeave', expectedRevision: step.revision}
: { type: 'alliance', target: step.target }) } : {}),
payments: cardSeed.payments };
}
} catch (error) { if (!(error instanceof RuleError)) throw error; }
@@ -22259,7 +22293,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
if (promise.answer) promise.released = true; else promise.fulfilled = true;
}
if (!turnActionPromiseTurn(g) || g.truthtrance) return;
if (!turnActionPromiseTurn(g) || g.truthtrance || g.coalitionState?.proposal) return;
const nexus = allianceOpportunity(g);
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source))
+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, admission with live bribe and alliance answers, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', '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'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers including matching joint commitments, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-admission-joint-stay.test.ts', 'tests/coalition-admission-joint-leave.test.ts', 'tests/coalition-pending-joint-leave.test.ts', 'tests/coalition-joint-leave-payment-order.test.ts', 'tests/coalition-whole-turn-leave-guard.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', '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',
@@ -43,24 +43,17 @@ function commitmentsLive(game: Game) {
assert.equal(game.alliancePromises![0].released ?? false, false);
}
void test('joint accepted-order promises cannot be released by group proposal or approval', () => {
void test('joint accepted-order promises remain binding after group admission', () => {
let game = fixture();
const before = structuredClone(game);
assert.throws(() => act(game, 'a', proposal(game)));
assert.deepEqual(game, before);
game = act(game, 'a', proposal(game));
commitmentsLive(game);
// Another member may propose; the affected player can still decline, but
// cannot approve a group while the scalar reconciliation is unsupported.
game = act(game, 'b', proposal(game));
game = act(game, 'b', answer(game, true));
commitmentsLive(game);
game = act(game, 'c', answer(game, true));
commitmentsLive(game);
const pending = structuredClone(game);
assert.throws(() => act(game, 'a', answer(game, true)));
assert.deepEqual(game, pending);
commitmentsLive(game);
game = act(game, 'a', answer(game, false));
commitmentsLive(game);
assert.equal(game.coalitionState!.proposal, null);
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','c']);
game = act(game, 'a', {type:'bribe', target:'e', amount:1});
assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(game.alliancePromises![0].released ?? false, false);
});
@@ -0,0 +1,77 @@
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, actor:string, action:Action) => applyAction(game,actor,action);
const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!;
/** A quiet Basic Nexus, wallets and physical Truthtrances are staged; pair
* formation, questions, unanimous admission, departure and bribe are actions. */
function fixture():{game:Game; cards:string[]} {
let game=createGame('GROUPADMITLEAVE',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState=emptyCoalitionState('unlimited');
seat(game,'a').spice=8;
game=act(game,'a',{type:'alliance',target:'b'});
game=act(game,'b',{type:'alliance',target:'a'});
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,'f').hand.push(card);
}
return {game,cards};
}
function ask(game:Game, card:string, question:Record<string,unknown>, value:'yes'|'no'):Game {
game=act(game,'f',{type:'card',card});
while (game.truthtrance?.stage==='priority') {
const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game=act(game,next.id,{type:'truthPass'});
}
game=act(game,'f',{type:'truthAsk',question});
return act(game,'a',{type:'truthAnswer',answer:value});
}
function answer(game:Game, actor:string, accept:boolean):Game {
const offer=game.coalitionState!.proposal; assert.ok(offer);
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function stillBinding(game:Game) {
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false);
}
for (const proposer of ['a','b']) void test(`a ${proposer === 'a' ? 'proposer' : 'final voter'} can admit, leave and pay without releasing their answers`,()=>{
const {game:initial,cards}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'c'},'no');
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
// Empty joint ledger invokes the whole accepted-order planner.
game.turnActionPromises=[];
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game=act(game,proposer,{type:'coalitionPropose',groupId:pair.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
if (proposer==='a') game=answer(game,'b',true);
game=answer(game,'c',true);
if (proposer==='b') game=answer(game,'a',true);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
stillBinding(game);
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
assert.equal(leave.action.type,'coalitionLeave');
game=act(game,'a',leave.action);
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
stillBinding(game);
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e');
game=act(game,'a',payment.action);
assert.equal(seat(game,'e').bribes,2);
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
stillBinding(game);
game=act(game,'a',{type:'ready'});
assert.ok(game.ready.includes('a'));
});
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action);
const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!;
/** Stage a quiet Basic Nexus, six wallets and two physical Truthtrance cards.
* Pair formation, questions, admission answers and payments are real actions. */
function fixture(): {game:Game; cards:string[]} {
let game = createGame('GROUPJOINTSTAY',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
seat(game,'a').spice = 8;
game = act(game,'a',{type:'alliance',target:'b'});
game = act(game,'b',{type:'alliance',target:'a'});
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,'f').hand.push(card);
}
return {game,cards};
}
function ask(game:Game, card:string, question:Record<string,unknown>, value:'yes'|'no'):Game {
game = act(game,'f',{type:'card',card});
while (game.truthtrance?.stage==='priority') {
const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game=act(game,next.id,{type:'truthPass'});
}
game=act(game,'f',{type:'truthAsk',question});
return act(game,'a',{type:'truthAnswer',answer:value});
}
function propose(game:Game, actor:string):Game {
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
return act(game,actor,{type:'coalitionPropose',groupId:pair.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
}
function answer(game:Game, actor:string, accept:boolean):Game {
const offer=game.coalitionState!.proposal; assert.ok(offer);
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function live(game:Game) {
assert.ok(game.alliancePromises!.every(row=>!row.released));
assert.ok(game.bribePromises!.every(row=>!row.released));
}
void test('a jointly feasible Yes to an existing ally and outsider bribe survives group admission and payment',()=>{
const {cards,game:initial}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
// Even an empty ledger switches accepted-order reconciliation to the whole
// turn planner; both previously accepted answers must remain binding.
game.turnActionPromises=[];
game=propose(game,'a');
game=answer(game,'b',true);
game=answer(game,'c',true);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
live(game);
const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'e');
game=act(game,'a',guide.action);
assert.equal(seat(game,'e').bribes,2);
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
live(game);
game=act(game,'a',{type:'ready'});
assert.ok(game.ready.includes('a'));
live(game);
});
void test('the respondent can decline admission that conflicts with simultaneous Yes and No',()=>{
const {cards,game:initial}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
game=ask(game,cards[1],{kind:'alliance',target:'a',turn:game.turn,partner:'c'},'no');
// Stage an independently accepted ordinary bribe to exercise the joint
// reconciler without manufacturing a third physical Truthtrance card.
game.bribePromises=[{event:'joint-payment',player:'a',asker:'f',turn:game.turn,
recipient:'e',minimum:2,answer:true,progress:0}];
game.turnActionPromises=[];
game=propose(game,'b');
game=answer(game,'c',true);
const before=structuredClone(game);
assert.throws(()=>answer(game,'a',true));
assert.deepEqual(game,before);
live(game);
for (const difficulty of DIFFICULTIES) {
const view=viewGame(game,'a'); seatView(view,'a').bot=difficulty;
const action=botActions(view)[0]; assert.ok(action);
assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,false);
assert.doesNotThrow(()=>act(game,'a',action));
}
game=answer(game,'a',false);
assert.equal(game.coalitionState!.proposal,null);
live(game);
});
function seatView(view:ReturnType<typeof viewGame>, id:string) {
const player=view.players.find(player=>player.id===id); assert.ok(player); return player;
}
void test('admission checks a matching alliance, bribe and shipment against one physical purse',()=>{
const {cards,game:initial}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
// Stage an already accepted shipment, its respondent's purse and a spent
// charity opportunity; the available physical reserves come from newPlayer.
game.shipmentPromises=[{player:'a',asker:'f',turn:game.turn,territory:'carthag',minimum:3,answer:true}];
game.turnActionPromises=[];
seat(game,'a').charityTurn=game.turn;
seat(game,'a').spice=4; // Two spice for the bribe plus three to ship to Carthag.
const before=structuredClone(game);
assert.throws(()=>propose(game,'a'));
assert.deepEqual(game,before);
live(game);
assert.ok(!game.shipmentPromises![0].released);
seat(game,'a').spice=5;
game=propose(game,'a');
game=answer(game,'b',true);
game=answer(game,'c',true);
live(game);
assert.ok(!game.shipmentPromises![0].released);
const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
game=act(game,'a',guide.action);
assert.equal(seat(game,'a').spice,3);
assert.equal(seat(game,'e').bribes,2);
assert.ok(game.bribePromises![0].fulfilled);
assert.ok(!game.shipmentPromises![0].released);
});
@@ -0,0 +1,82 @@
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,actor:string,action:Action)=>applyAction(game,actor,action);
const seat=(game:Game,id:string)=>game.players.find(player=>player.id===id)!;
/** Only a quiet Basic Nexus, wallets, an exhausted charity opportunity and
* two physical Truthtrances are staged. A later purse reduction represents a
* foreign loss. All group and Truthtrance actions and both bribes are real. */
function fixture():{game:Game;cards:string[]} {
let game=createGame('GROUPPAYORDER',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState=emptyCoalitionState('unlimited');
seat(game,'a').spice=3; seat(game,'a').charityTurn=game.turn;
seat(game,'d').spice=8;
game=act(game,'a',{type:'alliance',target:'b'});
game=act(game,'b',{type:'alliance',target:'a'});
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,'f').hand.push(card);
}
return {game,cards};
}
function ask(game:Game,card:string,question:Record<string,unknown>,value:'yes'|'no'):Game {
game=act(game,'f',{type:'card',card});
while (game.truthtrance?.stage==='priority') {
const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game=act(game,next.id,{type:'truthPass'});
}
game=act(game,'f',{type:'truthAsk',question});
return act(game,'a',{type:'truthAnswer',answer:value});
}
function answer(game:Game,actor:string):Game {
const offer=game.coalitionState!.proposal; assert.ok(offer);
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
function stillBinding(game:Game) {
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
assert.equal(game.bribeExpressionPromises!.find(row=>row.player==='a')!.released ?? false,false);
}
void test('owned departure can precede payment to the former ally when it is the only affordable OR branch',()=>{
const {game:initial,cards}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'d'},'no');
game=ask(game,cards[1],{kind:'bribeExpression',target:'a',turn:game.turn,claim:{kind:'or',terms:[
{kind:'payment',recipient:'d',minimum:1},{kind:'payment',recipient:'e',minimum:3}]}},'yes');
// An empty ledger activates joint reconciliation. The three-spice outsider
// branch is available when A consents to the group including D.
game.turnActionPromises=[];
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
game=answer(game,'b'); game=answer(game,'d');
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','d']);
stillBinding(game);
// Stage a loss after admission. E's branch now costs too much, but A can
// satisfy both answers by leaving and paying the former ally D one spice.
seat(game,'a').spice=1;
game=act(game,'d',{type:'bribe',target:'e',amount:1});
stillBinding(game);
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
assert.equal(leave.action.type,'coalitionLeave');
game=act(game,'a',leave.action);
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','d']);
stillBinding(game);
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d');
game=act(game,'a',payment.action);
assert.equal(seat(game,'d').bribes,1);
assert.ok(game.bribeExpressionPromises!.find(row=>row.player==='a')!.fulfilled);
stillBinding(game);
});
@@ -0,0 +1,84 @@
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, actor:string, action:Action) => applyAction(game,actor,action);
const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!;
/** The quiet Basic Nexus, wallets and physical Truthtrances are staged. Group
* formation, both answers, the invitation, departure and bribes are actions. */
function fixture():{game:Game; cards:string[]} {
let game=createGame('GROUPPENDINGLEAVE',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState=emptyCoalitionState('unlimited');
for (const player of game.players) player.spice=8;
game=act(game,'a',{type:'alliance',target:'b'});
game=act(game,'b',{type:'alliance',target:'a'});
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
game=answer(game,'b',true); game=answer(game,'c',true);
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,'f').hand.push(card);
}
return {game,cards};
}
function ask(game:Game, card:string, question:Record<string,unknown>, value:'yes'|'no'):Game {
game=act(game,'f',{type:'card',card});
while (game.truthtrance?.stage==='priority') {
const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game=act(game,next.id,{type:'truthPass'});
}
game=act(game,'f',{type:'truthAsk',question});
return act(game,'a',{type:'truthAnswer',answer:value});
}
function answer(game:Game, actor:string, accept:boolean):Game {
const offer=game.coalitionState!.proposal; assert.ok(offer);
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function stillBinding(game:Game) {
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false);
}
void test('a pending group invitation preserves an owned leave and joint bribe through unrelated actions',()=>{
const {game:initial,cards}=fixture();
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'no');
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
// An empty ledger invokes the joint accepted-order reconciler.
game.turnActionPromises=[];
assert.equal(viewGame(game,'a').alliancePromiseGuide?.action.type,'coalitionLeave');
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
game=act(game,'c',{type:'coalitionPropose',groupId:group.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
assert.ok(game.coalitionState!.proposal);
stillBinding(game);
game=answer(game,'d',true);
stillBinding(game);
game=answer(game,'a',false);
assert.equal(game.coalitionState!.proposal,null);
stillBinding(game);
game=act(game,'d',{type:'bribe',target:'f',amount:1});
stillBinding(game);
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
assert.equal(leave.action.type,'coalitionLeave');
game=act(game,'a',leave.action);
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
stillBinding(game);
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e');
game=act(game,'a',payment.action);
assert.equal(seat(game,'e').bribes,2);
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
});
@@ -0,0 +1,55 @@
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, actor:string, action:Action) => applyAction(game,actor,action);
/** The quiet Nexus, wallet, Truthtrance and the later conflicting scalar
* answer are staged. Formation, the whole question and payment are real. */
function fixture(): Game {
let game = createGame('GROUPWHOLELEAVE',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');
game.players.find(player=>player.id==='a')!.spice=8;
game=act(game,'a',{type:'alliance',target:'b'});
game=act(game,'b',{type:'alliance',target:'a'});
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game=act(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=act(game,actor,{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);
game.players.find(player=>player.id==='e')!.hand.push(card);
game=act(game,'e',{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'});
}
game=act(game,'e',{type:'truthAsk',question:{kind:'turnActions',target:'a',turn:game.turn,
mixed:{kind:'and',terms:[{kind:'action',action:{kind:'payment',recipient:'d',minimum:2}},
{kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}]}}});
game=act(game,'a',{type:'truthAnswer',answer:'yes'});
game=act(game,'a',{type:'bribe',target:'d',amount:2});
assert.ok(game.turnActionPromises?.some(row=>!row.fulfilled && !row.released));
const source=JSON.stringify(['ordinaryNexus',game.turn,game.spiceDiscard.map(pile=>pile.length),
game.spiceSequence?.pile ?? null,game.spiceSequence?.skipped?.length ?? 0]);
game.alliancePromises=[{event:'staged-no',source,player:'a',asker:'e',turn:game.turn,
partner:'b',answer:false}];
return game;
}
void test('a live whole-turn promise does not expose a forbidden group-leave guide',()=>{
const game=fixture();
const guide=viewGame(game,'a').turnActionGuide;
assert.notEqual(guide?.action.type,'coalitionLeave');
if (guide) assert.doesNotThrow(()=>act(game,'a',guide.action));
});
+20 -2
View File
@@ -147,7 +147,7 @@ void test('native missing reciprocal consent never permits a definite combined f
});
void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => {
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 13));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
@@ -156,7 +156,7 @@ void test('native known unaffordable payment with existing real consent offers N
});
void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => {
const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 13));
const game = ask(declare(f).game, f.asker, f.target, mixed);
assert.equal(player(game, f.target).spice, 10);
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
@@ -166,6 +166,24 @@ void test('native missing consent cannot conceal a known unaffordable whole paym
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native final-partner answer can fund eleven through a real payment and later Charity', () => {
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
let game = answer(asked, f.target, 'yes');
game = act(game, f.target, { type:'bribe', target:f.recipient, amount:10 });
assert.equal(player(game, f.target).spice, 0);
game = act(game, f.target, f.allianceAction);
game = close(game);
game = advance(game, state => state.phase === 2 && clean(state));
game = act(game, f.target, { type:'charity' });
assert.equal(player(game, f.target).spice, 2);
game = act(game, f.target, { type:'bribe', target:f.recipient, amount:1 });
assert.equal(player(game, f.recipient).bribes, 11);
assert.equal(promise(game, f.target).fulfilled, true);
physical(game, f);
});
void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => {
const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient));
let game = bind(f, 'no', mixed).game;