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')); });