Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s

This commit is contained in:
Quintine committed 2026-10-29 00:41:56 +11:00
1 parent 9cb65a21f5
commit 3e0fa2a8a2
11 files changed
+592 -24

No files matched your search

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AlliancePromiseGuideButton } from '../components/alliance-promises';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, type GameView } from '../game/engine';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Real public pair/group formation with controlled promise and guide
* projections. These cases exercise client dispatch and stale guards. */
function projected(kind: 'yes' | 'no' | 'expression' | 'pair'): GameView {
let game = createGame('GROUPTRUTHCONTROLS', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'], ready:[]});
game.coalitionState = emptyCoalitionState('unlimited');
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
if (kind !== 'pair') {
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b','c']) {
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 view = viewGame(game, 'a');
view.alliancePromiseNexus = {turn:2};
view.alliancePromises = kind === 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
turn:2, partner:'b', answer:kind !== 'no'}];
view.allianceExpressionPromises = kind !== 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
turn:2, claim:{kind:'and', terms:[{kind:'partner',partner:'b'}, {kind:'partner',partner:'c'}]}, answer:true}];
view.alliancePromiseGuide = {action:kind === 'no'
? {type:'coalitionLeave', expectedRevision:view.coalitionState!.revision, alliancePromiseEvent:'controlled'}
: {type:'ready', alliancePromiseEvent:'controlled'}};
return view;
}
function button(game: GameView, sent: Action[], busy = false) {
const result = AlliancePromiseGuideButton({game, busy, send(action) { sent.push(action); }});
assert.ok(result); return result;
}
void test('group Yes and whole-AND guides render an enabled Ready action', () => {
for (const kind of ['yes', 'expression'] as const) {
const game = projected(kind), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
/Confirm Ready for this Nexus/);
control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
}
});
void test('group No guide renders and dispatches its exact event-bound leave action', () => {
const game = projected('no'), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
/Leave your group alliance while honoring your answers/);
control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
});
void test('group guide refuses stale revision, membership, event, and quiet-window changes', () => {
const mutations: ((game: GameView) => void)[] = [
game => { game.coalitionState!.revision += 1; },
game => { game.coalitionState!.groups[0].members.pop(); },
game => { game.players = game.players.filter(player => player.id !== 'b'); },
game => { game.alliancePromises[0].event = 'changed'; },
game => { game.alliancePromiseGuide!.action.expectedRevision = -1; },
game => { game.ready.push('a'); },
game => { game.nexus = false; },
game => { game.automaticContinuationPending = true; },
];
for (const mutate of mutations) {
const game = projected('no'), sent:Action[] = [], control = button(game, sent);
mutate(game); control.props.onClick(); assert.deepEqual(sent, []);
}
});
void test('reciprocal pair guidance remains usable with a staged coalition ledger', () => {
const game = projected('pair'), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false); control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
});
+164
View File
@@ -0,0 +1,164 @@
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'}}}});
});
@@ -0,0 +1,61 @@
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, type Action, type Game } from '../game/engine';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Stage the Nexus, spendable purse and physical card. The alliance and Truthtrance
* priority are real actions; b and c are a's group allies, d and e outsiders. */
function questionWindow(): Game {
let game = createGame('GROUPBRIBETRUTH', 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.players.find(player => player.id === 'a')!.spice = 5;
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
game = send(game, 'a', {type:'coalitionPropose', groupId:group.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b', 'c']) {
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);
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;
}
const fact = {kind:'fact', fact:{kind:'spice', compare:'gte', value:0}};
function question(family: 'bribe' | 'bribeExpression' | 'mixedBribe' | 'turnActions', recipient: string) {
const base = {kind:family, target:'a', turn:2};
if (family === 'bribe') return {...base, recipient, minimum:1};
if (family === 'bribeExpression') return {...base, claim:{kind:'and', terms:[
{kind:'payment', recipient, minimum:1}, {kind:'payment', recipient:'d', minimum:1},
]}};
if (family === 'mixedBribe') return {...base, mixed:{kind:'or', terms:[fact,
{kind:'action', action:{recipient, minimum:1}},
]}};
return {...base, mixed:{kind:'or', terms:[fact,
{kind:'action', action:{kind:'payment', recipient, minimum:1}},
]}};
}
for (const family of ['bribe', 'bribeExpression', 'mixedBribe', 'turnActions'] as const)
void test(`${family} question rejects an allied group payment and accepts an outsider`, () => {
const game = questionWindow(), before = structuredClone(game);
assert.throws(() => send(game, 'e', {type:'truthAsk', question:question(family, 'b')}));
assert.deepEqual(game, before, 'A rejected allied-payment question retains its Truthtrance window.');
const accepted = send(game, 'e', {type:'truthAsk', question:question(family, 'e')});
assert.equal(accepted.truthtrance?.question?.kind, family);
});
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, spiceDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const location = 'red_chasm:7';
/** The unlimited ledger, Nexus and later Spice Blow are staged. All pair and
* group admissions, the worm draw and its choices/responses are actual engine
* actions. Physical forces and the actual Karama cards are conserved. */
function source(entrantIds: readonly string[], fremenPresent = false) {
let game = createGame('UNLIMITEDWORM', newPlayer('f', 'Fremen', 'fremen'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['f','e','a','h','g','b'], ready:[], response:null, decision:null,
phaseOpening:null, storm:18, deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
game = act(game, 'f', {type:'alliance', target:'e'});
game = act(game, 'e', {type:'alliance', target:'f'});
for (const entrant of entrantIds) {
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
game = act(game, 'f', {type:'coalitionPropose', groupId:group.id, entrant,
expectedRevision:game.coalitionState!.revision});
for (const id of [...group.members.filter(member => member !== 'f'), entrant]) {
const proposal = game.coalitionState!.proposal!;
game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], response:null, decision:null,
phaseOpening:null, storm:18, active:null, stormPending:null});
for (const player of game.players) {
player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0;
player.traitors = []; player.traitorChoices = [];
}
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
const outsider = game.players.find(player => !group.members.includes(player.id)); assert.ok(outsider);
for (const id of [...group.members.filter(member => member !== 'f'), outsider.id,
...(fremenPresent ? ['f'] : [])]) {
seat(game,id).forces = {[location]:3}; seat(game,id).reserves = 17;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index,1); outsider.hand.push(karama);
// Keep a distinct physical counter available for the later survival
// response after the outsider spends the first Karama on one ally.
let survivalKarama: typeof karama | undefined;
if (fremenPresent) {
const counter = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counter >= 0);
[survivalKarama] = game.deck.splice(counter,1);
seat(game,'b').hand.push(survivalKarama);
}
const spice = spiceDeck();
const land = spice.find(card => 'territory' in card && card.territory === 'red_chasm');
const worm = spice.find(card => 'worm' in card); assert.ok(land && worm);
game.spiceDiscard = [[land], []];
game.spiceDeck = [worm, ...spice.filter(card => card !== land && card !== worm && 'territory' in card)];
game.spice = {[location]:8};
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.equal(game.decision?.kind,'wormProtection');
return {game, karama, survivalKarama, outsider:outsider.id};
}
function pass(game: Game, kind: string): Game {
for (let count = 0; game.response?.kind === kind && count < 32; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor); game = act(game, actor.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind, kind);
return game;
}
function choose(game: Game, accept: boolean): Game {
assert.equal(game.decision?.kind,'wormProtection'); assert.ok(game.decision.event);
return act(game,'f',{type:'decision',accept,event:game.decision.event,ally:game.decision.ally});
}
function conserved(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
}
void test('four-member group resolves three distinct worm protections before Fremen survival', () => {
const initial = source(['a','h'], true);
let {game} = initial;
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h']);
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'e');
game = pass(choose(game,true),'wormAllyProtection');
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'a');
game = choose(game,true);
assert.equal(game.response?.kind,'wormAllyProtection'); assert.equal(game.response.recipient,'a');
game = act(game, initial.outsider, {type:'card',mode:'cancel',card:initial.karama.id});
game = pass(game,'wormAllyProtection');
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'h');
game = choose(game,false);
assert.equal(game.response?.kind,'wormSurvival');
assert.deepEqual(game.coalitionWormProtection?.outcomes,[
{ally:'e',outcome:'allowed'}, {ally:'a',outcome:'canceled'}, {ally:'h',outcome:'declined'},
]);
game = pass(game,'wormSurvival');
for (const id of ['e','f']) assert.equal(seat(game,id).forces[location],3);
for (const id of ['a','h',initial.outsider]) {
assert.equal(seat(game,id).forces[location] ?? 0,0); assert.equal(seat(game,id).tanks,3);
}
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
assert.ok(initial.survivalKarama && seat(game,'b').hand.some(card => card.id === initial.survivalKarama!.id));
assert.equal(game.spice[location],undefined);
assert.deepEqual(game.wormRides,['red_chasm']);
assert.equal(game.coalitionWormProtection,undefined);
conserved(game);
});
void test('five-member group offers every threatened ally in order with legal bot choices', () => {
const initial = source(['a','h','g']);
let game = initial.game;
const {outsider} = initial;
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h','g']);
const seen:string[] = [];
let first:Action | undefined;
while (game.decision?.kind === 'wormProtection') {
const {ally,event} = game.decision; assert.ok(event); seen.push(ally);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'f');
view.players.find(player => player.id === 'f')!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'decision' && typeof candidate.accept === 'boolean');
assert.ok(action, difficulty); assert.equal(action.event,event); assert.equal(action.ally,ally);
assert.doesNotThrow(() => act(game,'f',action));
}
if (first) {
const before = structuredClone(game);
assert.throws(() => act(game,'f',first!)); assert.deepEqual(game,before);
} else first = {type:'decision',accept:false,event,ally};
game = choose(game,ally === 'g');
game = pass(game,'wormAllyProtection');
}
assert.deepEqual(seen,['e','a','h','g']);
assert.equal(seat(game,'g').forces[location],3);
for (const id of ['e','a','h',outsider]) assert.equal(seat(game,id).tanks,3);
assert.equal(game.spice[location],undefined);
assert.equal(game.nexus,true);
conserved(game);
});