Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:54:36 +11:00
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'));
});
+140
View File
@@ -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);
});
+3 -3
View File
@@ -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);
+4 -4
View File
@@ -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});
});