120 lines
6.6 KiB
TypeScript
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);
|
|
});
|