Files
Dune/tests/multi-ghola-leader-runtime.test.ts
T
Quintine 4224bb3e2a
Verify and publish checkpoint container / container (push) Canceled after 0s
Preserve Ghola promises during Box selection
2026-10-29 17:52:25 +11:00

120 lines
6.6 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovementGhola as act, advanceTruthMovementGhola as advance,
createTruthMovementBoxGholaFixture as fixture, truthMovementGholaClean as clean,
truthMovementGholaInventory as inventory, truthMovementGholaPlayer as player } from './fixture-truth-movement-ghola';
/** Original native Richese Box source, with controlled physical custody and
* two staged dead Atreides leaders. Each card is moved, never copied; this is
* a targeted composition fixture, not evidence of earned leader deaths. */
function source(withBox: boolean) {
const f = fixture(), game = structuredClone(f.game);
const actor = player(game, f.target), asker = player(game, f.asker);
const gholaIndex = game.discard.findIndex(card => card.id === f.ghola);
assert.ok(gholaIndex >= 0);
actor.hand.push(game.discard.splice(gholaIndex, 1)[0]);
if (!withBox) {
const index = actor.hand.findIndex(card => card.id === f.box); assert.ok(index >= 0);
game.richeseCache!.push(actor.hand.splice(index, 1)[0]);
}
const otherTruth = game.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(otherTruth >= 0, 'The second physical Truthtrance must remain in the undealt deck');
asker.hand.push(game.deck.splice(otherTruth, 1)[0]);
const truths = asker.hand.filter(card => card.effect === 'truthtrance');
assert.equal(truths.length, 2);
const leaders = actor.leaders.slice(0, 2);
for (const leader of leaders) { leader.dead = true; leader.deaths = 1; delete leader.usedAt; }
assert.deepEqual(inventory(game), f.originalCards);
return { ...f, game, leaders: leaders.map(leader => leader.id), truths: truths.map(card => card.id) };
}
function declared(f: ReturnType<typeof source>) {
let game = act(f.game, f.asker, { type:'card', card:f.truths[0], cards:[f.truths[1]] });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
game = act(game, seat.id, { type:'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
function settleDiscard(game: Game): Game {
for (let count = 0; game.pendingTreacheryDiscard && count < 20; count++) {
const pending = game.pendingTreacheryDiscard;
if (pending.reaction?.stage === 'offer') {
const passed = pending.reaction.passed;
const actor = game.players.find(p => !passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type:'semutaPass', event:pending.batch.event });
} else {
assert.ok(!pending.reaction, 'A neutral pass does not select a card');
game = act(game, game.host, { type:'advanceBots' });
}
}
assert.equal(game.pendingTreacheryDiscard, null);
return game;
}
function ask(game: Game, f: ReturnType<typeof source>, leader: string) {
return act(game, f.asker, { type:'truthAsk', question: {kind:'gholaLeader', target:f.target, turn:game.turn, leader} });
}
function answer(game: Game, actor: string, value: 'yes' | 'no') {
return act(game, actor, {type:'truthAnswer', answer:value});
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
void test('two distinct Ghola Yes answers require a real recovery route', () => {
const f = source(false);
let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes');
game = ask(settleDiscard(game), f, f.leaders[1]);
assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
reject(game, f.target, {type:'truthAnswer', answer:'yes'});
game = answer(game, f.target, 'no');
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two named Ghola Yes answers reuse one physical card through the paid Box', () => {
const f = source(true);
let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes');
game = ask(settleDiscard(game), f, f.leaders[1]);
assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && !row.fulfilled).length, 2);
game = advance(game, clean);
const firstGuide = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(firstGuide); assert.equal(firstGuide.action.type, 'card');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target);
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], firstGuide.action);
}
game = act(game, f.target, firstGuide.action);
game = advance(game, clean);
assert.ok(game.discard.some(card => card.id === f.ghola));
assert.equal(game.gholaLeaderPromises!.filter(row => row.fulfilled).length, 1);
const recovery = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(recovery, 'The owner receives the next Box preparation');
assert.equal(recovery.action.type, 'card'); assert.equal(recovery.action.card, f.box);
game = act(game, f.target, recovery.action);
assert.equal(game.decision?.kind, 'nullentropy');
const search = viewGame(game, f.target).nullentropy!.search!;
assert.ok(search);
const alternative = search.cards.find(card => card.id !== f.ghola);
assert.ok(alternative, 'The paid search must also contain an independently eligible non-Ghola card');
reject(game, f.target, {type:'decision', event:search.event, card:alternative.id});
assert.equal(viewGame(game, f.target).nullentropy!.search!.event, search.event,
'Rejecting an evasion keeps the already-paid search available');
game = act(game, f.target, {type:'decision', event:search.event, card:f.ghola});
game = advance(game, clean);
assert.ok(player(game, f.target).hand.some(card => card.id === f.ghola));
const secondGuide = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(secondGuide); assert.equal(secondGuide.action.card, f.ghola);
assert.notEqual(secondGuide.action.leader, firstGuide.action.leader);
game = advance(act(game, f.target, secondGuide.action), clean);
assert.ok(f.leaders.every(id => !player(game, f.target).leaders.find(leader => leader.id === id)!.dead));
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && row.fulfilled).length, 2);
assert.deepEqual(inventory(game), f.originalCards);
});