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

@@ -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;