Files
Dune/tests/truthtrance.test.ts

549 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
RuleError,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
truthFactAnswer,
truthQuestionText,
validateTruthQuestionReceipt,
type TruthFact,
type TruthQuestion,
} from '../game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
void test('whole card questions name Amal, Thumper and the actual printed Karama role and shipment beneficiary', () => {
const question = (effect: 'amal' | 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect, source,
} } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id);
assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/);
assert.match(question('amal', 'phase-opening:2:8'), /Amal.*original phase opening.*before ordinary payments/);
assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/);
assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
});
void test('whole Box questions describe future printed activation and its search payment without choosing the recovered card', () => {
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } } };
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, /activate Nullentropy Box.*printed role.*pay its 2-spice search cost after this answer this turn/);
assert.doesNotMatch(text, /Ghola|selected|recover.*Shield/);
});
void test('Box raw question parsing needs no hidden possession and eagerly rejects extra selected-card data in folded clauses', () => {
const game = fixture();
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'nullentropyBox'));
const action = { kind: 'cardPlay' as const, effect: 'nullentropyBox' as const };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { cost: 2 }, { recipient: 'h' }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } };
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid as TruthQuestion));
}
});
void test('Distrans and Sapho questions name only actual printed use this turn without transfer or order choices', () => {
const game = fixture();
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === effect));
const action = { kind: 'cardPlay' as const, effect };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, effect === 'distrans' ? /complete a printed Distrans transfer and discard Distrans after this answer this turn/ :
/play Juice of Sapho in its printed role after this answer this turn/);
assert.doesNotMatch(text, /Ghola|first|last|aggressor|private-card|recipient/);
for (const extra of [{ recipient: 'h' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'first' }, { outcome: true }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
}
}
});
void test('Semuta future-use questions require completed physical exchange without naming a fresh target or inspection', () => {
const game = fixture();
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'semutaDrug'));
const action = { kind: 'cardPlay' as const, effect: 'semutaDrug' as const };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, /complete a printed Semuta Drug exchange and discard Semuta Drug after this answer this turn/);
assert.doesNotMatch(text, /inspect|commit|private-fresh-card|Ghola|Sapho/);
for (const extra of [{ selected: 'private-fresh-card' }, { committed: true }, { stage: 'select' }, { event: 'discard:2:4:1' }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
}
});
void test('Ornithopter and Residual Poison questions name actual native play and the proper deadline without chosen details', () => {
const game = fixture();
for (const effect of ['ornithopter', 'residualPoison'] as const) {
const action = { kind: 'cardPlay' as const, effect };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, effect === 'ornithopter' ? /play Ornithopter into a native flight after this answer before your Shipment and Movement turn ends/ :
/play Residual Poison in its printed role after this answer this turn/);
assert.doesNotMatch(text, /twoGroups|private-leader|killed|Semuta|Ghola/);
for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
}
}
});
function fixture() {
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
g.status = 'playing';
g.phase = 4;
g.turn = 2;
g.order = ['e', 'h', 'a'];
g.deck = baseDeck();
g.players.forEach((p) => {
p.hand = [];
p.traitorChoices = [];
p.traitors = [p.leaders[0].id];
});
hold(g, 'a', 'Truthtrance');
hold(g, 'h', 'Truthtrance');
hold(g, 'e', 'Shield');
return g;
}
function hold(g: Game, id: string, name: string) {
const index = g.deck.findIndex((c) => c.name === name);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function declare(g: Game, id = 'a') {
return applyAction(g, id, {
type: 'card',
card: g.players
.find((p) => p.id === id)!
.hand.find((c) => c.effect === 'truthtrance')!.id,
});
}
function priority(state: Game) {
let g = state;
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
return g;
}
const fact: TruthQuestion = {
kind: 'fact',
target: 'e',
fact: { kind: 'hand', name: 'Shield' },
};
const ask = (g: Game, question: TruthQuestion = fact, id = 'a') =>
applyAction(g, id, { type: 'truthAsk', question });
const answer = (g: Game, value = 'yes', id = 'e') =>
applyAction(g, id, { type: 'truthAnswer', answer: value });
const cards = (g: Game) =>
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
void test('simultaneous declarations resolve by storm order, independent of network arrival', () => {
const initial = fixture();
let g = declare(initial);
assert.equal(g.truthtrance!.stage, 'priority');
g = priority(declare(g, 'h'));
assert.deepEqual(
g.truthtrance!.queue.map((q) => q.player),
['h', 'a'],
);
assert.equal(g.discard.length, 0);
assert.throws(() => ask(g), /holder to ask/);
g = answer(ask(g, fact, 'h'));
assert.equal(g.truthtrance!.queue[0].player, 'a');
assert.equal(g.truthtrance!.stage, 'ask');
assert.equal(g.discard.length, 1);
g = answer(ask(g));
assert.equal(g.truthtrance, null);
assert.equal(g.discard.length, 2);
assert.deepEqual(cards(g), cards(initial));
assert.equal(initial.discard.length, 0);
assert.equal(initial.truthtrance, undefined);
});
void test('private fact answer is visible only to its target until publicly answered', () => {
const g = ask(priority(declare(fixture())));
for (const id of ['a', 'h']) {
const v = viewGame(g, id);
assert.equal(v.truthAnswer, null);
assert.equal(v.players.find((p) => p.id === 'e')!.hand, undefined);
assert.deepEqual(v.truthHistory, []);
assert.equal(v.truthtrance!.question!.kind, 'fact');
}
assert.equal(viewGame(g, 'e').truthAnswer, 'yes');
assert.throws(() => answer(g, 'no'), /truthfully/);
assert.throws(() => answer(g, 'unknown'), /truthfully/);
assert.throws(() => answer(g, 'yes', 'a'), /questioned player/);
const done = answer(JSON.parse(JSON.stringify(g)));
for (const p of done.players)
assert.equal(viewGame(done, p.id).truthHistory[0].answer, 'yes');
assert.equal(viewGame(done, 'a').players[1].hand, undefined);
});
void test('AND/OR answers reveal the combined fact rather than the matching private identity', () => {
const g = fixture();
g.players[1].traitors.push(CHEAP_HERO_TRAITOR);
const yes: TruthFact = { kind: 'traitor', leader: CHEAP_HERO_TRAITOR };
const no: TruthFact = { kind: 'hand', name: 'Lasgun' };
assert.equal(
truthFactAnswer(g.players[1], { kind: 'and', terms: [yes, no] }),
'no',
);
assert.equal(
truthFactAnswer(g.players[1], { kind: 'or', terms: [yes, no] }),
'yes',
);
const asked = ask(priority(declare(g)), {
kind: 'fact',
target: 'e',
fact: { kind: 'or', terms: [yes, no] },
});
const done = answer(asked);
assert.equal(done.truthHistory![0].answer, 'yes');
assert.deepEqual(Object.keys(done.truthHistory![0]).sort(), [
'answer',
'asker',
'phase',
'question',
'turn',
]);
assert.equal(viewGame(done, 'a').players[1].traitors, undefined);
});
void test('any-time question overlays and restores pending windows without reapplying effects', () => {
for (const mode of [
'opening',
'response',
'decision',
'battle',
'spice',
] as const) {
const initial = fixture();
if (mode === 'opening')
initial.phaseOpening = { passed: ['e'], initialize: true };
if (mode === 'response') {
hold(initial, 'a', 'Karama');
initial.response = { kind: 'emperorIncome', owner: 'e', passed: ['h'] };
initial.auction = {
cards: initial.deck.splice(0, 2),
index: 0,
bid: 3,
bidder: 'a',
active: 'h',
passed: [],
opener: 0,
};
}
if (mode === 'decision') {
initial.advanced = true;
initial.phase = 3;
initial.players.find(p => p.id === 'h')!.specialKaramaUsed = true;
hold(initial, 'h', 'Shield'); // Preserve a real return choice during the overlay.
initial.decision = {
kind: 'handExchange',
player: 'h',
target: 'a',
count: 1,
};
initial.pendingExchange = {
response: { kind: 'harkonnenBonus', owner: 'h', passed: [] },
decision: null,
};
}
if (mode === 'battle') {
initial.phase = 6;
initial.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {
e: {
dial: 1,
leader: initial.players[1].leaders[0].id,
weapon: null,
defense: initial.players[1].hand[0].id,
support: 0,
},
},
revealed: false,
traitorCalls: {},
preparation: { kind: 'prescience', owner: 'a', beneficiary: 'a' },
voice: { target: 'e', kind: 'poison', must: false },
};
}
if (mode === 'spice') {
initial.phase = 1;
initial.spiceWindow = {
territory: 'red_chasm',
sector: 7,
amount: 8,
harvested: false,
};
initial.ready = ['e'];
}
const before = structuredClone(initial);
let g = ask(priority(declare(initial, 'a')));
for (const action of [
{ type: 'ready' },
{ type: 'passResponse' },
{ type: 'decision' },
{ type: 'battlePlan', dial: 1 },
{ type: 'card', card: initial.players[2].hand[0].id },
{ type: 'card', mode: 'special', card: initial.players[2].hand[0].id },
])
assert.throws(() => applyAction(g, 'h', action), RuleError);
g = answer(JSON.parse(JSON.stringify(g)));
for (const key of [
'phaseOpening',
'response',
'decision',
'pendingExchange',
'auction',
'battle',
'spiceWindow',
'ready',
'phase',
'turn',
'active',
'spice',
] as const)
assert.deepEqual(g[key], before[key], `${mode}: ${key}`);
assert.equal(g.players[1].spice, before.players[1].spice);
assert.deepEqual(initial, before);
}
});
void test('unknown answers permit a different question or save, without discarding the card', () => {
const q: TruthQuestion = {
kind: 'freeform',
target: 'e',
text: 'Will you win two battles this turn?',
scope: 'fact',
};
let g = ask(priority(declare(fixture())), q);
g = answer(g, 'unknown');
assert.equal(g.truthtrance!.stage, 'unknown');
assert.equal(g.discard.length, 0);
assert.throws(
() => applyAction(g, 'e', { type: 'truthSave' }),
/can be saved/,
);
const saved = applyAction(JSON.parse(JSON.stringify(g)), 'a', {
type: 'truthSave',
});
assert.equal(saved.truthtrance, null);
assert.ok(saved.players[0].hand.some((c) => c.effect === 'truthtrance'));
const retried = answer(ask(g));
assert.equal(retried.truthHistory!.length, 2);
assert.equal(retried.discard.length, 1);
assert.throws(
() =>
applyAction(ask(priority(declare(fixture()))), 'a', {
type: 'truthSave',
}),
/can be saved/,
);
});
void test('freeform current-turn promises are public records with explicit turn scope', () => {
const question: TruthQuestion = {
kind: 'freeform',
scope: 'currentTurn',
target: 'e',
text: 'Will you ship six or more forces to Carthag this turn?',
};
const g = answer(ask(priority(declare(fixture())), question), 'no');
assert.equal(g.truthHistory![0].turn, 2);
assert.deepEqual(g.truthHistory![0].question, question);
for (const p of g.players)
assert.deepEqual(viewGame(g, p.id).truthHistory, g.truthHistory);
});
void test('malformed questions, targets and forged answers cannot mutate authoritative state', () => {
const g = priority(declare(fixture()));
const before = JSON.stringify(g);
const invalid: unknown[] = [
null,
{},
{ ...fact, target: 'a' },
{ ...fact, target: 'intruder' },
{ ...fact, fact: { kind: 'hand', name: '__proto__' } },
{ ...fact, fact: { kind: 'traitor', leader: 'missing' } },
{ ...fact, fact: { kind: 'and', terms: [] } },
{ ...fact, fact: { kind: 'or', terms: Array(9).fill(fact.fact) } },
{ kind: 'freeform', target: 'e', text: ' ', scope: 'fact' },
{ kind: 'freeform', target: 'e', text: 'x'.repeat(501), scope: 'fact' },
{ kind: 'freeform', target: 'e', text: 'Future?', scope: 'nextTurn' },
];
let nested = fact.fact;
for (let i = 0; i < 6; i++)
nested = { kind: 'and', terms: [nested, fact.fact] };
invalid.push({ ...fact, fact: nested });
for (const question of invalid)
assert.throws(
() => applyAction(g, 'a', { type: 'truthAsk', question }),
RuleError,
);
assert.equal(JSON.stringify(g), before);
assert.throws(
() => applyAction(ask(g), 'e', { type: 'truthAnswer', answer: true }),
/Answer Yes/,
);
assert.throws(
() => applyAction(fixture(), 'a', { type: 'truthAnswer', answer: 'yes' }),
/no pending/,
);
});
void test('unselected setup traitors are unknown and a Truthtrance does not finish setup prematurely', () => {
const g = fixture();
g.status = 'setup';
g.players[1].traitors = [];
g.players[1].traitorChoices = [g.players[1].leaders[1].id];
const q: TruthQuestion = {
kind: 'fact',
target: 'e',
fact: { kind: 'traitor', leader: g.players[1].leaders[0].id },
};
const pending = ask(priority(declare(g)), q);
assert.equal(pending.status, 'setup');
assert.equal(viewGame(pending, 'e').truthAnswer, 'unknown');
const unknown = answer(pending, 'unknown');
assert.equal(unknown.status, 'setup');
const saved = applyAction(unknown, 'a', { type: 'truthSave' });
assert.equal(saved.status, 'setup');
assert.deepEqual(
saved.players[1].traitorChoices,
g.players[1].traitorChoices,
);
});
void test('all AI levels pass priority and publish verified answers from their own private view', () => {
for (const level of DIFFICULTIES) {
const initial = fixture();
initial.players[1].bot = level;
initial.players[2].bot = level;
let g = runBots(declare(initial));
assert.equal(g.truthtrance!.stage, 'ask');
g = ask(g);
assert.deepEqual(botActions(viewGame(g, 'h')), []);
assert.deepEqual(botActions(viewGame(g, 'e')), [
{ type: 'truthAnswer', answer: 'yes' },
]);
const done = runBots(g, 1);
assert.equal(done.truthtrance, null);
assert.equal(done.truthHistory![0].answer, 'yes');
}
});
void test('AI handles unparseable freeform questions honestly and can complete a declared question', () => {
for (const level of DIFFICULTIES) {
const initial = fixture();
initial.players[1].bot = level;
const q: TruthQuestion = {
kind: 'freeform',
target: 'e',
scope: 'fact',
text: 'Will you win two battles?',
};
const pending = ask(priority(declare(initial)), q);
assert.throws(() => answer(pending, 'yes'), /cannot yet interpret/);
const unknown = runBots(pending);
assert.equal(unknown.truthtrance!.stage, 'unknown');
assert.equal(unknown.discard.length, 0);
const bot = fixture();
bot.players[0].bot = level;
let g = priority(declare(bot));
const actions: Action[] = botActions(viewGame(g, 'a'));
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'truthAsk');
g = applyAction(g, 'a', actions[0]);
const target = g.truthtrance!.question!.target;
const actual = viewGame(g, target).truthAnswer!;
g = answer(g, actual, target);
assert.equal(g.truthtrance, null);
}
});
void test('truth windows do not bypass lobby or finished-game boundaries and cannot be redeclared after passing', () => {
for (const status of ['lobby', 'finished'] as const) {
const g = fixture();
g.status = status;
assert.throws(() => declare(g), RuleError);
}
let g = declare(fixture());
assert.throws(() => declare(g), /already declared/);
g = applyAction(g, 'h', { type: 'truthPass' });
assert.throws(() => declare(g, 'h'), /already declared/);
assert.throws(
() => applyAction(g, 'h', { type: 'truthPass' }),
/priority window/,
);
});
void test('a holder can declare both physical Truthtrances with separate answers and disposal', () => {
const initial = fixture();
initial.players[0].hand.push(initial.players[2].hand.pop()!);
const ids = initial.players[0].hand.map((c) => c.id);
for (const extra of [
[ids[0]],
[initial.players[1].hand[0].id],
['forged'],
'not-an-array',
])
assert.throws(
() =>
applyAction(initial, 'a', { type: 'card', card: ids[0], cards: extra }),
RuleError,
);
let g = priority(
applyAction(initial, 'a', { type: 'card', card: ids[0], cards: [ids[1]] }),
);
assert.equal(g.truthtrance!.queue.length, 2);
g = answer(ask(g));
assert.equal(g.discard.length, 1);
assert.equal(g.truthtrance!.queue[0].card, ids[1]);
g = answer(
ask(g, {
kind: 'freeform',
target: 'e',
text: 'Will you win two battles?',
scope: 'fact',
}),
'unknown',
);
g = applyAction(g, 'a', { type: 'truthSave' });
assert.equal(g.truthtrance, null);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[ids[1]],
);
assert.deepEqual(cards(g), cards(initial));
});