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'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); /** The quiet Nexus and physical Truthtrance card are staged; every alliance, * invitation, question, answer and later group decision is a real action. */ function group(): Game { let game = createGame('GROUPTRUTH', 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 = send(game, 'a', {type:'alliance', target:'b'}); game = send(game, 'b', {type:'alliance', target:'a'}); for (const entrant of ['c','d']) { const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original); game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant, expectedRevision:game.coalitionState!.revision}); for (const actor of [...original.members.filter(id => id !== 'a'), entrant]) { const offer = game.coalitionState!.proposal!; game = send(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); return game; } function openTruth(game: Game): Game { const card = game.players.find(player => player.id === 'e')!.hand.find(row => row.effect === 'truthtrance')!; game = send(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 = send(game, actor.id, {type:'truthPass'}); } return game; } function ask(game: Game, partner: string): Game { game = openTruth(game); return send(game, 'e', {type:'truthAsk', question:{kind:'alliance', target:'a', turn:game.turn, partner}}); } function leave(game: Game, actor: string): Game { return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision}); } function rejected(game: Game, actor: string, action: Action) { const original = structuredClone(game); assert.throws(() => send(game, actor, action)); assert.deepEqual(game, original); } void test('a group member can truthfully answer Yes or No about an existing ally', () => { const game = ask(group(), 'b'); const answers = viewGame(game, 'a').truthAllianceAnswers; assert.ok(answers?.includes('yes'), 'The respondent is already allied to the named partner.'); assert.ok(answers?.includes('no'), 'The respondent can leave the alliance before the Nexus closes.'); }); void test('a Yes preserves the named alliance while allowing an unrelated member to leave', () => { let game = ask(group(), 'b'); game = send(game, 'a', {type:'truthAnswer', answer:'yes'}); rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision}); game = leave(game, 'c'); assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','d']); assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released ?? false, false); const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready'); game = send(game, 'a', guide.action); assert.ok(game.ready.includes('a')); }); void test('a named partner leaving externally releases the now-unfulfillable Yes', () => { let game = ask(group(), 'b'); game = send(game, 'a', {type:'truthAnswer', answer:'yes'}); game = leave(game, 'b'); assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c','d']); assert.equal(coalitionOf(game.players, game.coalitionState, 'b'), null); assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released, true); assert.equal(viewGame(game, 'a').alliancePromiseGuide, null); }); void test('a No requires the respondent to leave, with a legal bot guide', () => { let game = ask(group(), 'b'); game = send(game, 'a', {type:'truthAnswer', answer:'no'}); rejected(game, 'a', {type:'ready'}); const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'coalitionLeave'); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'a'); view.players.find(player => player.id === 'a')!.bot = difficulty; assert.deepEqual(botActions(view)[0], guide.action); assert.doesNotThrow(() => send(game, 'a', guide.action)); } 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','d']); game = send(game, 'a', {type:'ready'}); assert.ok(game.ready.includes('a')); }); void test('an outsider cannot be promised as an ally without their consent', () => { const game = ask(group(), 'e'); const answers = viewGame(game, 'a').truthAllianceAnswers; assert.ok(answers?.includes('no')); assert.ok(answers?.includes('unknown')); assert.ok(!answers?.includes('yes')); rejected(game, 'a', {type:'truthAnswer', answer:'yes'}); }); const bothAllies = {kind:'and', terms:[{kind:'partner', partner:'b'}, {kind:'partner', partner:'c'}]}; const factualAndAllies = {kind:'and', terms:[{kind:'fact', fact:{kind:'spice', compare:'gte', value:0}}, {kind:'action', action:{partner:'b'}}, {kind:'action', action:{partner:'c'}}]}; for (const answer of ['yes', 'no'] as const) void test(`group alliance AND can answer ${answer} while evaluating both named allies`, () => { let game = openTruth(group()); game = send(game, 'e', {type:'truthAsk', question:{kind:'allianceExpression', target:'a', turn:game.turn, claim:bothAllies}}); const answers = viewGame(game, 'a').truthAllianceExpressionAnswers; assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no')); game = send(game, 'a', {type:'truthAnswer', answer}); const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide); if (answer === 'yes') { assert.equal(guide.action.type, 'ready'); rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision}); game = send(game, 'a', guide.action); } else { rejected(game, 'a', {type:'ready'}); assert.equal(guide.action.type, 'coalitionLeave'); game = send(game, 'a', guide.action); assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null); game = send(game, 'a', {type:'ready'}); } assert.ok(game.ready.includes('a')); }); for (const answer of ['yes', 'no'] as const) void test(`mixed factual and group-alliance question retains the ${answer} answer`, () => { let game = openTruth(group()); game = send(game, 'e', {type:'truthAsk', question:{kind:'mixedAlliance', target:'a', turn:game.turn, mixed:factualAndAllies}}); const answers = viewGame(game, 'a').truthMixedAllianceAnswers; assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no')); game = send(game, 'a', {type:'truthAnswer', answer}); const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, answer === 'yes' ? 'ready' : 'coalitionLeave'); game = send(game, 'a', guide.action); if (answer === 'no') { assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null); game = send(game, 'a', {type:'ready'}); } assert.ok(game.ready.includes('a')); }); void test('whole-turn alliance leaves remain fenced while a group is active', () => { const game = openTruth(group()); rejected(game, 'e', {type:'truthAsk', question:{kind:'turnActions', target:'a', turn:game.turn, mixed:{kind:'action', action:{kind:'alliance', partner:'b'}}}}); });