Files
Dune/tests/truth-mixed-revival-runtime.test.ts

316 lines
23 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedRevival as act, advanceTruthMixedRevival as advance,
answerTruthMixedRevival as answer, askTruthMixedRevival as ask,
bindTruthMixedRevival as bind, completeTruthMixedRevival as complete,
createTruthMixedRevivalFixture as fixture, declareTruthMixedRevival as declare,
finishTruthMixedRevival as finish, settleTruthMixedRevival as settle,
truthMixedRevivalClean as clean, truthMixedRevivalFact as fact,
truthMixedRevivalForce as force, truthMixedRevivalGroup as group,
truthMixedRevivalInventory as inventory, truthMixedRevivalLeader as leader,
truthMixedRevivalPlayer as player, truthMixedRevivalPromise as promise,
} from './fixture-truth-mixed-revival';
const progress0 = { total: 0, normal: 0, elite: 0, leaders: [] };
const forceAction: Action = { type: 'revive', amount: 1, elite: 0 };
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed Revival freezes original T4 before real force income and named leader debit`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.expansions, ['ix']); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, advanced ? 108 : 102); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.equal(f.revivalSource.question.kind, 'revivalExpression'); assert.equal(f.question.kind, 'mixedRevival');
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1);
assert.equal(player(f.afterBattle, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
assert.equal(player(f.game, f.target).leaders.filter(disc => !disc.dead).length, 4);
assert.equal(player(f.game, f.target).revivalCycle, 0); assert.equal(f.quote.early, true); assert.equal(f.quote.cost, 2);
assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
assert.deepEqual(promise(bound.game).mixed, f.mixed); assert.deepEqual(promise(bound.game).progress, progress0);
rejected(bound.game, f.target, { type: 'ready' });
let game = act(bound.game, f.target, forceAction);
if (game.pendingRevival) assert.deepEqual(promise(game).progress, progress0);
game = settle(game);
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [] });
assert.equal(promise(game).fulfilled, false); assert.equal(player(game, f.target).spice, 5);
assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).tanks, 0);
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, 4); assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
game = settle(act(game, f.target, guide.action));
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).spice, 3); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(player(game, f.target).leaderRevived, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
if (advanced) assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true current fact OR return answers Yes immediately with no native Revival binding', () => {
const f = fixture(), mixed = group('or', fact(f.spiceFact), leader(f.leader));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
assert.equal(game.revivalExpressionPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
assert.deepEqual(promise(game).mixed, mixed); assert.deepEqual(promise(game).progress, progress0);
game = finish(game); assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).revived, 0);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false current fact AND return answers No immediately while the real return remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.falseSpiceFact), force());
assert.equal(truthFactAnswer(player(f.game, f.target), f.falseSpiceFact), 'no');
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
assert.equal(game.revivalExpressionPromises?.length ?? 0, 0);
game = settle(act(game, f.target, forceAction)); assert.equal(player(game, f.target).spice, 5);
assert.equal(truthFactAnswer(player(game, f.target), f.falseSpiceFact), 'yes');
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native false current fact OR return Yes requires the actual ordinary return', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.falseSpiceFact), leader(f.leader))).game;
assert.equal(promise(game).fulfilled, false); assert.deepEqual(promise(game).progress, progress0);
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.revivalAction));
assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
assert.equal(player(game, f.target).spice, 2); truthOnce(game, f.truth);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true current fact AND return No forbids that exact ordinary event until the real deadline', () => {
const f = fixture(); let game = bind(f, 'no', group('and', fact(f.spiceFact), force())).game;
rejected(game, f.target, forceAction); assert.equal(promise(game).fulfilled, false);
game = settle(act(game, f.target, f.revivalAction)); assert.equal(player(game, f.target).spice, 2);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, forceAction); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).tanks, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} ordinary-return constraint`, () => {
const f = fixture(), p = player(f.game, f.target);
assert.equal(p.faction, 'tleilaxu'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), leader(f.leader));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
if (answerValue === 'yes') {
game = settle(act(game, f.target, f.revivalAction)); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress.leaders, [f.leader]);
} else {
rejected(game, f.target, f.revivalAction); game = settle(act(game, f.target, forceAction));
assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
}
game = finish(game); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed OR preserves the manual force or leader alternative instead of pinning its guide', () => {
const f = fixture(), mixed = group('or', group('and', fact(f.spiceFact), force()), leader(f.leader));
let game = bind(f, 'yes', mixed).game;
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.ok(guide.action.type === 'revive' || guide.action.type === 'reviveLeader');
const manual = guide.action.type === 'revive' ? f.revivalAction : forceAction;
assert.notEqual(manual.type, guide.action.type); game = settle(act(game, f.target, manual));
assert.equal(promise(game).fulfilled, true);
assert.equal(promise(game).progress.total, manual.type === 'revive' ? 1 : 0);
assert.deepEqual(promise(game).progress.leaders, manual.type === 'reviveLeader' ? [f.leader] : []);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native mixed partial normal returns retain a larger force OR until the real alternate leader return', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), group('or', force(2, 'normal'), leader(f.leader)));
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, forceAction));
assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); game = settle(act(game, f.target, guide.action));
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).progress.total, 1);
assert.deepEqual(promise(game).progress.leaders, [f.leader]); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
for (const first of ['forces', 'leader'] as const) void test(`native mixed whole AND No permits ${first} first and rejects only whole completion`, () => {
const f = fixture(); let game = bind(f, 'no').game;
game = settle(act(game, f.target, first === 'forces' ? forceAction : f.revivalAction));
assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).progress.total, first === 'forces' ? 1 : 0);
assert.deepEqual(promise(game).progress.leaders, first === 'leader' ? [f.leader] : []);
rejected(game, f.target, first === 'forces' ? f.revivalAction : forceAction);
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed whole OR No forbids both qualifying ordinary returns through the native deadline', () => {
const f = fixture(), mixed = group('or', fact(f.falseSpiceFact), force(), leader(f.leader));
let game = bind(f, 'no', mixed).game;
rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
assert.deepEqual(promise(game).progress, progress0); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior ordinary returns start outside a newly answered mixed interval', () => {
const f = fixture(); let game = settle(act(f.game, f.target, forceAction));
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).spice, 5);
const mixed = group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)));
game = bind(f, 'yes', mixed, game).game;
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, false);
game = settle(act(game, f.target, f.revivalAction));
assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native original Ghola stays outside the mixed ordinary-return progress and No restriction', () => {
const f = fixture({ ghola: true }); assert.ok(f.ghola);
let game = bind(f, 'no', group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)))).game;
game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(player(game, f.target).leaderRevived, false); assert.equal(player(game, f.target).spice, 5);
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, forceAction); game = finish(game); assert.equal(promise(game).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed projection exposes only owned raw question aggregate and filters its pure residual', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.ok(!('mixedRevivalQuestions' in view));
assert.deepEqual(view.revivalExpressionPromises, []); assert.equal(view.truthMixedRevivalAnswers, null);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
if (actor !== f.target) {
assert.deepEqual(view.mixedRevivalPromises, []); assert.equal(view.revivalExpressionPromiseGuide, null);
} else {
const row = promise(game);
assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'mixed', 'player', 'progress', 'released', 'turn']);
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, progress0);
assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, game.turn);
assert.equal(typeof row.answer, 'boolean'); assert.equal(typeof row.fulfilled, 'boolean'); assert.equal(typeof row.released, 'boolean');
assert.ok(view.revivalExpressionPromiseGuide?.action.revivalExpressionPromiseEvent);
assert.ok(!JSON.stringify(row).includes('"claim"'));
}
}
assert.equal(game.revivalExpressionPromises?.length, 1);
assert.equal(game.revivalExpressionPromises![0].event, promise(game).event);
});
void test('native mixed question and guide preserve exact owner turn event and JSON packets', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedRevivalTargets.includes(f.target));
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question,
mixed: group('and', fact(f.spiceFact), leader('atreides-0')) } });
let game = ask(declared.game, f.asker, f.target, f.mixed);
assert.equal(viewGame(game, f.asker).truthMixedRevivalAnswers, null);
assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['yes', 'no']);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, revivalExpressionPromiseEvent: 'stale-mixed-revival-event' });
rejected(game, f.target, { ...guide.action, amount: 20, elite: 0 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
rejected(game, f.target, wire); game = complete(game, f.target);
rejected(game, f.target, wire); rejected(finish(game), f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed post-Storm Yes crosses real Spice Charity Bidding and original ordered Revival', () => {
const f = fixture({ advanced: true, window: 'waiting' });
assert.equal(f.postStorm.turn, 2); assert.equal(f.postStorm.phase, 1);
assert.equal(f.sourceCounts.postStorm, 83); assert.deepEqual(f.postStorm, f.storm.after);
assert.equal(f.postStormSourceActions.length, f.postStormActionCount);
assert.deepEqual(f.sourceActions.slice(f.postStormActionCount, f.phase3ActionCount), f.bidding.steps);
let game = bind(f, 'yes', f.mixed, f.postStorm).game;
assert.deepEqual(promise(game).progress, progress0); assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
const actions: { actor: string; action: Action }[] = [];
game = advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === f.target, actions);
assert.ok(actions.some(step => step.action.type === 'passBid')); assert.ok(actions.some(step => step.action.type === 'ready'));
assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
assert.deepEqual(promise(game).progress, progress0); assert.equal(player(game, f.target).spice, 4);
game = complete(game, f.target); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native waiting mixed Yes exposes the common guide only at its original owned opportunity', () => {
const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target);
let game = bind(f).game; assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
game = complete(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
});
for (const permitted of [false, true]) void test(`native mixed personally priced early leader ${permitted ? 'Yes pays the real quote' : 'No rejects the real acceptance'}`, () => {
const f = fixture({ advanced: true, window: 'waiting', bothDead: true });
assert.equal(player(f.afterBattle, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
assert.equal(player(f.game, f.opponent).leaders.filter(disc => !disc.dead).length, 4);
assert.equal(f.game.revivalOrder!.remaining[0], f.opponent);
let game = act(f.game, f.opponent, { type: 'requestLeaderRevival', leader: f.opponentLeader });
game = act(game, f.target, { type: 'quoteLeaderRevival', target: f.opponent, amount: 3 });
const before = player(game, f.opponent).spice, payee = player(game, f.target).spice;
game = declare(f, game).game;
game = ask(game, f.asker, f.opponent, group('and', fact({ kind: 'spice', compare: 'gte', value: 3 }), leader(f.opponentLeader)));
game = answer(game, f.opponent, permitted ? 'yes' : 'no');
if (permitted) {
const guide = viewGame(game, f.opponent).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'acceptLeaderRevival'); assert.equal(guide.action.leaderRevivalPrice, 3);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game, f.opponent).event);
rejected(game, f.opponent, { ...guide.action, leaderRevivalPrice: 2 });
game = settle(act(game, f.opponent, guide.action));
assert.equal(player(game, f.opponent).spice, before - 3); assert.equal(player(game, f.target).spice, payee + 3);
assert.equal(player(game, f.opponent).leaderRevived, true); assert.equal(promise(game, f.opponent).fulfilled, true);
assert.deepEqual(promise(game, f.opponent).progress.leaders, [f.opponentLeader]);
} else {
rejected(game, f.opponent, { type: 'acceptLeaderRevival', leaderRevivalPrice: 3 });
assert.equal(player(game, f.opponent).spice, before);
assert.equal(player(game, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
}
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies actually answer mixed Yes and perform exact native ordinary return guides', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked);
const answering = viewGame(game, f.target); answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); assert.ok(answering.truthMixedRevivalAnswers?.includes('yes'));
game = act(game, f.target, answerAction);
for (let count = 0; count < 4 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.revivalExpressionPromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.revivalExpressionPromiseEvent === guide.action.revivalExpressionPromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = settle(act(game, f.target, action));
}
assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).spice, 3);
assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});