141 lines
7.6 KiB
TypeScript
141 lines
7.6 KiB
TypeScript
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 send = (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, the six wallets and one physical Truthtrance card
|
|
* are staged. Pair/group formation, answers, consent and payments are actual
|
|
* game actions. No lobby exposes this group variant yet. */
|
|
function group(): Game {
|
|
let game = createGame('GROUPBRIBEADMIT', 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 = send(game,'a',{type:'alliance',target:'b'});
|
|
game = send(game,'b',{type:'alliance',target:'a'});
|
|
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
|
game = send(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c',
|
|
expectedRevision:game.coalitionState!.revision});
|
|
for (const actor of ['b','c']) game = answer(game,actor,true);
|
|
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
|
const [card] = game.deck.splice(index,1); seat(game,'f').hand.push(card);
|
|
return game;
|
|
}
|
|
function ask(game:Game, target:string, recipient:string, answerValue:'yes'|'no', expression=false):Game {
|
|
const card = seat(game,'f').hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
|
|
game = send(game,'f',{type:'card',card:card.id});
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
|
|
game = send(game,next.id,{type:'truthPass'});
|
|
}
|
|
const question = expression ? {kind:'bribeExpression',target,turn:game.turn,claim:{kind:'or',terms:[
|
|
{kind:'payment',recipient,minimum:2}, {kind:'payment',recipient:'e',minimum:1}]}} :
|
|
{kind:'bribe',target,turn:game.turn,recipient,minimum:2};
|
|
game = send(game,'f',{type:'truthAsk',question});
|
|
const view = viewGame(game,target);
|
|
const answers = expression ? view.truthBribeExpressionAnswers : view.truthBribeAnswers;
|
|
assert.ok(answers?.includes(answerValue));
|
|
return send(game,target,{type:'truthAnswer',answer:answerValue});
|
|
}
|
|
function propose(game:Game, actor:string):Game {
|
|
const original = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(original);
|
|
return send(game,actor,{type:'coalitionPropose',groupId:original.id,entrant:'d',
|
|
expectedRevision:game.coalitionState!.revision});
|
|
}
|
|
function answer(game:Game, actor:string, accept:boolean):Game {
|
|
const offer = game.coalitionState!.proposal; assert.ok(offer);
|
|
return send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
|
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
|
|
}
|
|
function rejected(game:Game, actor:string, action:Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(()=>send(game,actor,action));
|
|
assert.deepEqual(game,before,'Rejected consent must not modify the invitation or commitment.');
|
|
}
|
|
function rejectedProposal(game:Game, actor:string) {
|
|
const original = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(original);
|
|
rejected(game,actor,{type:'coalitionPropose',groupId:original.id,entrant:'d',
|
|
expectedRevision:game.coalitionState!.revision});
|
|
}
|
|
function rejectedAcceptance(game:Game, actor:string) {
|
|
const offer = game.coalitionState!.proposal; assert.ok(offer);
|
|
rejected(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
|
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
|
}
|
|
function admit(game:Game, proposer='a'):Game {
|
|
game = propose(game,proposer);
|
|
for (const actor of ['a','b','c','d'].filter(id=>id!==proposer)) game = answer(game,actor,true);
|
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c','d']);
|
|
return game;
|
|
}
|
|
|
|
for (const debtor of ['a','b','c','d']) void test(`${debtor} cannot consent to an admission that prevents their own Yes bribe`,()=>{
|
|
const recipient = debtor === 'd' ? 'a' : 'd';
|
|
let game = ask(group(),debtor,recipient,'yes');
|
|
// An initial proposal is the proposer's own consent, including when the
|
|
// entrant proposes to join the existing group.
|
|
rejectedProposal(game,debtor);
|
|
const proposer = debtor === 'a' ? 'b' : 'a';
|
|
game = propose(game,proposer);
|
|
rejectedAcceptance(game,debtor);
|
|
// A bot must decline rather than try to pay while the pending invitation
|
|
// locks all actions except the participants' admission answers.
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game,debtor);
|
|
view.players.find(player => player.id === debtor)!.bot = difficulty;
|
|
const action = botActions(view)[0]; assert.ok(action);
|
|
assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,false);
|
|
assert.doesNotThrow(()=>send(game,debtor,action));
|
|
}
|
|
game = answer(game,debtor,false);
|
|
const guide = viewGame(game,debtor).bribePromiseGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,recipient);
|
|
game = send(game,debtor,guide.action);
|
|
assert.equal(seat(game,recipient).bribes,2);
|
|
assert.ok(game.bribePromises!.some(row=>row.player===debtor && row.fulfilled));
|
|
game = admit(game);
|
|
assert.equal(game.coalitionState!.proposal,null);
|
|
});
|
|
|
|
void test('an outsider Yes to another outsider survives admission and remains payable',()=>{
|
|
let game = ask(group(),'e','f','yes');
|
|
const commitment = game.bribePromises!.find(row=>row.player==='e')!;
|
|
game = admit(game);
|
|
assert.ok(!game.bribePromises!.find(row=>row.event===commitment.event)!.released);
|
|
const guide = viewGame(game,'e').bribePromiseGuide; assert.ok(guide);
|
|
assert.equal(guide.action.target,'f');
|
|
game = send(game,'e',guide.action);
|
|
assert.equal(game.bribePromises!.find(row=>row.event===commitment.event)!.fulfilled,true);
|
|
assert.equal(seat(game,'f').bribes,2);
|
|
});
|
|
|
|
void test('a No about the entrant survives joining and still prevents that payment',()=>{
|
|
let game = ask(group(),'a','d','no');
|
|
const commitment = game.bribePromises!.find(row=>row.player==='a')!;
|
|
game = admit(game);
|
|
assert.ok(!game.bribePromises!.find(row=>row.event===commitment.event)!.released);
|
|
rejected(game,'a',{type:'bribe',target:'d',amount:2});
|
|
});
|
|
|
|
void test('a whole bribe Yes may admit one named recipient when another valid branch remains',()=>{
|
|
let game = ask(group(),'a','d','yes',true);
|
|
const commitment = game.bribeExpressionPromises!.find(row=>row.player==='a')!;
|
|
game = admit(game);
|
|
assert.ok(!game.bribeExpressionPromises!.find(row=>row.event===commitment.event)!.released);
|
|
const guide = viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'e');
|
|
game = send(game,'a',guide.action);
|
|
assert.equal(game.bribeExpressionPromises!.find(row=>row.event===commitment.event)!.fulfilled,true);
|
|
assert.equal(seat(game,'e').bribes,1);
|
|
});
|