Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
0faa7923b0
commit
6524ed3bf2
14 files changed
+679
-53
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
|
||||
import type { CoalitionGroup } from '../game/coalition-alliance';
|
||||
|
||||
const players = ['a', 'b', 'c', 'd', 'e'].map(id => ({id, name: id.toUpperCase()}));
|
||||
const group: CoalitionGroup = {id: 'group', members: ['a', 'b', 'c'], formedTurn: 1, edges: [
|
||||
{members: ['a', 'b'], formedTurn: 1}, {members: ['a', 'c'], formedTurn: 1},
|
||||
{members: ['b', 'c'], formedTurn: 1},
|
||||
]};
|
||||
const view = (me: string): CoalitionNexusView => ({
|
||||
me, players, groups: [group], proposal: null, revision: 3, variant: 'unlimited',
|
||||
});
|
||||
const render = (value: CoalitionNexusView) => renderToStaticMarkup(createElement(CoalitionNexus, {
|
||||
view: value, onIntent() {},
|
||||
}));
|
||||
|
||||
void test('only the specified group and entrant invitation is blocked', () => {
|
||||
const member = view('a');
|
||||
member.blockedInvitations = [{groupId: 'group', entrant: 'd'}, {groupId: 'other', entrant: 'e'}];
|
||||
const choices = coalitionNexusOptions(member);
|
||||
assert.deepEqual(choices.invitations.map(row => row.entrant), ['e']);
|
||||
assert.equal(choices.mayLeave, true);
|
||||
const html = render(member);
|
||||
assert.doesNotMatch(html, /Invite D to/);
|
||||
assert.match(html, /Invite E to A, B, C/);
|
||||
assert.match(html, /conflict with your commitments/);
|
||||
assert.match(html, /Leave A, B, C/);
|
||||
|
||||
const entrant = view('d');
|
||||
entrant.blockedInvitations = [{groupId: 'group', entrant: 'e'}];
|
||||
assert.match(render(entrant), /Ask to join A, B, C/);
|
||||
entrant.blockedInvitations = [{groupId: 'group', entrant: 'd'}];
|
||||
assert.doesNotMatch(render(entrant), /Ask to join/);
|
||||
});
|
||||
|
||||
void test('a blocked pending acceptance can still be declined or withdrawn', () => {
|
||||
const pending = view('d');
|
||||
pending.proposal = {event: 'event', nexus: 'nexus', turn: 2, groupId: group.id,
|
||||
original: group, entrant: 'd', proposer: 'a', approvals: ['a', 'b']};
|
||||
pending.acceptBlocked = true;
|
||||
assert.equal(coalitionNexusOptions(pending).mayAccept, false);
|
||||
assert.equal(coalitionNexusOptions(pending).mayDecline, true);
|
||||
assert.doesNotMatch(render(pending), /Accept invitation/);
|
||||
assert.match(render(pending), /conflict with your commitments/);
|
||||
assert.match(render(pending), /Decline and cancel invitation/);
|
||||
|
||||
pending.me = 'b';
|
||||
assert.match(render(pending), /Withdraw approval and cancel invitation/);
|
||||
pending.me = 'd';
|
||||
pending.acceptBlocked = false;
|
||||
assert.equal(coalitionNexusOptions(pending).mayAccept, true);
|
||||
assert.match(render(pending), /Accept invitation/);
|
||||
});
|
||||
|
||||
void test('legacy admissionBlocked still blocks all invitations and acceptance', () => {
|
||||
const member = view('a');
|
||||
member.admissionBlocked = true;
|
||||
assert.deepEqual(coalitionNexusOptions(member).invitations, []);
|
||||
assert.equal(coalitionNexusOptions(member).mayLeave, true);
|
||||
const pending = view('d');
|
||||
pending.admissionBlocked = true;
|
||||
pending.proposal = {event: 'event', nexus: 'nexus', turn: 2, groupId: group.id,
|
||||
original: group, entrant: 'd', proposer: 'a', approvals: ['a']};
|
||||
assert.equal(coalitionNexusOptions(pending).mayAccept, false);
|
||||
assert.equal(coalitionNexusOptions(pending).mayDecline, true);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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 unfinished group mode is staged in a quiet Basic Nexus, along with
|
||||
* wallets and one physical Truthtrance card. Group formation, deal consent,
|
||||
* the question, admission answers and both payments are real actions. */
|
||||
function fixture(): Game {
|
||||
let game = createGame('GROUPDEALTRUTH', 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 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 answer(game: Game, actor: string, accept: boolean): Game {
|
||||
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
|
||||
return act(game,actor,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
|
||||
turn:proposal.turn,accept,expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function propose(game: Game, actor: string): Game {
|
||||
const group = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
return act(game,actor,{type:'coalitionPropose',groupId:group.id,entrant:'d',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(()=>act(game,actor,action));
|
||||
assert.deepEqual(game,before,'A rejected action must leave the source and obligations intact.');
|
||||
}
|
||||
function acceptAction(game: Game): Action {
|
||||
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
|
||||
return {type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
|
||||
turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision};
|
||||
}
|
||||
function offerDeal(game: Game): {game: Game; id: string} {
|
||||
game = act(game,'e',{type:'dealOffer',target:'a',terms:'Pay for information this turn.',
|
||||
payment:{kind:'deferredBribe',payer:'a',recipient:'e',amount:2,dueTurn:game.turn}});
|
||||
const id = game.deals!.at(-1)!.id;
|
||||
game = act(game,'a',{type:'dealRespond',deal:id,accept:true});
|
||||
assert.equal(game.deals!.find(deal=>deal.id===id)!.paymentStatus,'pending');
|
||||
return {game,id};
|
||||
}
|
||||
function promise(game: Game, recipient: string): Game {
|
||||
const card = seat(game,'f').hand.find(candidate=>candidate.effect==='truthtrance'); assert.ok(card);
|
||||
game = act(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 = act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game = act(game,'f',{type:'truthAsk',question:{kind:'bribe',target:'a',turn:game.turn,recipient,minimum:2}});
|
||||
assert.ok(viewGame(game,'a').truthBribeAnswers?.includes('yes'));
|
||||
return act(game,'a',{type:'truthAnswer',answer:'yes'});
|
||||
}
|
||||
function admit(game: Game): Game {
|
||||
game = propose(game,'b');
|
||||
for (const actor of ['a','c','d']) game = answer(game,actor,true);
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c','d']);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('an outsider deal does not hide a separate Yes that admission would make unpayable',()=>{
|
||||
const offered = offerDeal(fixture());
|
||||
let game = promise(offered.game,'d');
|
||||
const group = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
reject(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'d',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
game = propose(game,'b');
|
||||
reject(game,'a',acceptAction(game));
|
||||
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'pending');
|
||||
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
|
||||
game = answer(game,'a',false);
|
||||
game = act(game,'a',{type:'dealPay',deal:offered.id});
|
||||
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'paid');
|
||||
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
|
||||
game = act(game,'a',{type:'bribe',target:'d',amount:2});
|
||||
assert.ok(game.bribePromises!.some(row=>row.player==='a' && row.fulfilled));
|
||||
game = admit(game);
|
||||
assert.equal(seat(game,'e').bribes,2);
|
||||
assert.equal(seat(game,'d').bribes,2);
|
||||
});
|
||||
|
||||
void test('admission preserves an outsider deal that also fulfills the matching Yes',()=>{
|
||||
const offered = offerDeal(fixture());
|
||||
let game = promise(offered.game,'e');
|
||||
game = admit(game);
|
||||
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'pending');
|
||||
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
|
||||
assert.equal(viewGame(game,'a').deferredDealPaymentOptions[offered.id]?.blocked,null);
|
||||
game = act(game,'a',{type:'dealPay',deal:offered.id});
|
||||
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'paid');
|
||||
assert.ok(game.bribePromises!.some(row=>row.player==='a' && row.fulfilled));
|
||||
assert.equal(seat(game,'a').spice,6);
|
||||
assert.equal(seat(game,'e').bribes,2);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
|
||||
/** A player's retained partner and unrelated bribe remain jointly feasible.
|
||||
* The empty joint ledger is significant: it delegates accepted-order
|
||||
* reconciliation to the scalar alliance planner. */
|
||||
function fixture(): Game {
|
||||
let game = createGame('ADMISSIONJOINT', 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:[]});
|
||||
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 source = JSON.stringify(['ordinaryNexus', game.turn, game.spiceDiscard.map(pile => pile.length),
|
||||
game.spiceSequence?.pile ?? null, game.spiceSequence?.skipped?.length ?? 0]);
|
||||
game.alliancePromises = [{event:'alliance', source, player:'a', asker:'e', turn:game.turn,
|
||||
partner:'b', answer:true}];
|
||||
game.bribePromises = [{event:'bribe', player:'a', asker:'e', turn:game.turn,
|
||||
recipient:'e', minimum:1, answer:true, progress:0}];
|
||||
game.turnActionPromises = [];
|
||||
return game;
|
||||
}
|
||||
|
||||
function proposal(game: Game) {
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
|
||||
return {type:'coalitionPropose', groupId:group.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision};
|
||||
}
|
||||
function answer(game: Game, accept: boolean) {
|
||||
const offer = game.coalitionState!.proposal; assert.ok(offer);
|
||||
return {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn,
|
||||
accept, expectedRevision:game.coalitionState!.revision};
|
||||
}
|
||||
function commitmentsLive(game: Game) {
|
||||
assert.equal(game.bribePromises![0].released ?? false, false);
|
||||
assert.equal(game.alliancePromises![0].released ?? false, false);
|
||||
}
|
||||
|
||||
void test('joint accepted-order promises cannot be released by group proposal or approval', () => {
|
||||
let game = fixture();
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, 'a', proposal(game)));
|
||||
assert.deepEqual(game, before);
|
||||
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));
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
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 the quiet Basic Nexus and two physical Truthtrance cards are staged.
|
||||
* Pair/group formation, each question, consent and departure use real actions. */
|
||||
function group(): {game:Game; cards:string[]} {
|
||||
let game = createGame('GROUPALLIANCEADMIT',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');
|
||||
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 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, partner:string, choice:'yes'|'no'):Game {
|
||||
game = send(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 = send(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game = send(game,'f',{type:'truthAsk',question:{kind:'alliance',target:'a',turn:game.turn,partner}});
|
||||
assert.ok(viewGame(game,'a').truthAllianceAnswers?.includes(choice));
|
||||
return send(game,'a',{type:'truthAnswer',answer:choice});
|
||||
}
|
||||
function propose(game:Game, proposer='c'):Game {
|
||||
const current = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(current);
|
||||
return send(game,proposer,{type:'coalitionPropose',groupId:current.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 rejectedAcceptance(game:Game, actor:string) {
|
||||
const offer = game.coalitionState!.proposal; assert.ok(offer);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(()=>send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision}));
|
||||
assert.deepEqual(game,before,'Rejected consent must preserve the offer and every answer.');
|
||||
}
|
||||
function botDeclines(game:Game, actor:string) {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,actor);
|
||||
view.players.find(player=>player.id===actor)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'coalitionAnswer',difficulty);
|
||||
assert.equal(action.accept,false,difficulty);
|
||||
assert.doesNotThrow(()=>send(game,actor,action),difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
void test('an unmet group No survives a pending invitation the respondent can decline',()=>{
|
||||
const fixture=group();
|
||||
let game=ask(fixture.game,fixture.cards[0],'b','no');
|
||||
const event=game.alliancePromises!.find(row=>row.player==='a')!.event;
|
||||
assert.equal(viewGame(game,'a').alliancePromiseGuide?.action.type,'coalitionLeave');
|
||||
game=propose(game,'c');
|
||||
assert.ok(game.coalitionState!.proposal);
|
||||
assert.ok(!game.alliancePromises!.find(row=>row.event===event)!.released,
|
||||
'The respondent can decline the invitation and then leave.');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'a');
|
||||
view.players.find(player=>player.id==='a')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'coalitionAnswer',difficulty);
|
||||
assert.equal(action.accept,true,difficulty);
|
||||
assert.doesNotThrow(()=>send(game,'a',action),difficulty);
|
||||
}
|
||||
game=answer(game,'a',false);
|
||||
assert.equal(game.coalitionState!.proposal,null);
|
||||
assert.ok(!game.alliancePromises!.find(row=>row.event===event)!.released);
|
||||
const guide=viewGame(game,'a').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'coalitionLeave');
|
||||
game=send(game,'a',guide.action);
|
||||
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
|
||||
assert.ok(!game.alliancePromises!.find(row=>row.event===event)!.released);
|
||||
game=send(game,'a',{type:'ready'});
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
|
||||
void test('Yes to an existing ally and No to the entrant constrain only their respondent’s consent',()=>{
|
||||
const fixture=group();
|
||||
let game=ask(fixture.game,fixture.cards[0],'b','yes');
|
||||
game=ask(game,fixture.cards[1],'d','no');
|
||||
const events=game.alliancePromises!.filter(row=>row.player==='a').map(row=>row.event);
|
||||
assert.equal(events.length,2);
|
||||
game=propose(game,'c');
|
||||
rejectedAcceptance(game,'a');
|
||||
// Other participants may approve: the promise is A's, and those approvals
|
||||
// neither constitute A's consent nor complete the larger alliance.
|
||||
game=answer(game,'b',true);
|
||||
game=answer(game,'d',true);
|
||||
assert.ok(game.coalitionState!.proposal);
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
|
||||
rejectedAcceptance(game,'a');
|
||||
assert.ok(events.every(event=>!game.alliancePromises!.find(row=>row.event===event)!.released));
|
||||
botDeclines(game,'a');
|
||||
game=answer(game,'a',false);
|
||||
assert.equal(game.coalitionState!.proposal,null);
|
||||
assert.ok(events.every(event=>!game.alliancePromises!.find(row=>row.event===event)!.released));
|
||||
game=send(game,'a',{type:'ready'});
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
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);
|
||||
});
|
||||
@@ -67,9 +67,10 @@ void test('a positive bribe answer allows owned group departure and remains paya
|
||||
let game = ask('yes');
|
||||
const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
|
||||
const options = coalitionNexusOptions(projected);
|
||||
assert.equal(options.mayLeave, true); assert.equal(options.invitations.length, 0);
|
||||
assert.equal(options.mayLeave, true);
|
||||
assert.ok(options.invitations.every(invitation => invitation.entrant !== 'd'));
|
||||
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
|
||||
assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
|
||||
assert.match(markup, /Leave/);
|
||||
blockedProposal(game);
|
||||
game = leave(game, 'a');
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
@@ -85,7 +86,6 @@ void test('a positive bribe answer allows owned group departure and remains paya
|
||||
|
||||
void test('a negative bribe answer survives another member departing and retains its cap', () => {
|
||||
let game = ask('no');
|
||||
blockedProposal(game);
|
||||
game = leave(game, 'b');
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
|
||||
const before = structuredClone(game);
|
||||
|
||||
@@ -86,20 +86,20 @@ void test('fulfilled and released Truthtrance records do not prevent group Nexus
|
||||
|
||||
void test('an unresolved prior-turn Truthtrance record still blocks group Nexus', () => {
|
||||
const game = originalPair();
|
||||
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
|
||||
game.bribePromises = [{event:'unreconciled-bribe', player:'a', asker:'d', turn:game.turn - 1,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
||||
assert.ok(viewGame(game, 'a').coalitionNexus?.blockedInvitations?.some(row => row.groupId === pair.id && row.entrant === 'c'));
|
||||
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
});
|
||||
|
||||
void test('a current unanswered group-incompatible Truthtrance commitment still blocks group Nexus', () => {
|
||||
const game = originalPair();
|
||||
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
|
||||
game.bribePromises = [{event:'live-bribe', player:'a', asker:'d', turn:game.turn,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
||||
assert.ok(viewGame(game, 'a').coalitionNexus?.blockedInvitations?.some(row => row.groupId === pair.id && row.entrant === 'c'));
|
||||
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user