Support group departures in joint commitment plans
Verify and publish checkpoint container / container (push) Successful in 11m20s
Verify and publish checkpoint container / container (push) Successful in 11m20s
This commit is contained in:
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));
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user