Files
Dune/tests/coalition-truth-alliance.test.ts
T
Quintine 3e0fa2a8a2
Verify and publish checkpoint container / container (push) Successful in 11m22s
Honor group alliances in Truthtrance
2026-10-29 00:41:56 +11:00

165 lines
8.4 KiB
TypeScript

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