Files
Dune/tests/truthtrance-face-dancer-engine.test.ts
Quintine 6ea691255a Add verified current Face Dancer questions to Truthtrance
Ask about a currently held unrevealed printed identity through shared facts
and AND/OR authoring. Keep ordinary Traitor meaning separate, candidate
identities public and only the aggregate answer published. Existing answer,
physical disposal and continuation flow need no new AI strategy.

Verified: 12 affected offline files86/86, types/lint and production build;
twelve actual field/question/gameplay/GameTable SSR journeys and sixteen legal
profile answers. Native Basic/Advanced sources and actual Mentat replacement
prove a current Yes creates no future reveal or retention promise.

Existing mixed-shipment admission stays unchanged. No saved-room, D1, reset,
restart or deployment operation; broader Truthtrance/mode assurance remains.
2026-10-24 19:15:46 +11:00

181 lines
9.1 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthFactAnswer, truthQuestionText, type TruthFact, type TruthQuestion } from '../game/truthtrance';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
createTruthtranceFaceDancerFixture as fixture,
actTruthFaceDancer as act,
truthFaceDancerPlayer as player,
truthFaceDancerInventory as inventory,
truthFaceDancerTraitors as traitors,
truthFaceDancerClean as clean,
advanceTruthFaceDancer as advance,
openTruthtranceFaceDancer as open,
askTruthFaceDancer as ask,
answerTruthFaceDancer as answer,
replaceTruthtranceFaceDancer as replace,
} from './fixture-truthtrance-face-dancer';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
const frame = (g: Game) => structuredClone({ phase: g.phase, turn: g.turn, active: g.active,
ready: g.ready, order: g.order, decision: g.decision, response: g.response,
phaseOpening: g.phaseOpening, movementRemaining: g.movementRemaining,
shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises });
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Face Dancer fact answers from the actual deal and resumes the same parent`, () => {
const f = fixture({ advanced });
const original = frame(f.game), cards = structuredClone(player(f.game, f.respondent).faceDancers);
assert.equal(cards?.length, 3);
assert.ok(cards?.some(card => card.leader === f.held && !card.revealed));
let g = ask(f.game, f);
assert.equal(g.truthtrance?.stage, 'answer');
assert.equal(viewGame(g, f.respondent).truthAnswer, 'yes');
for (const actor of [f.asker, f.observer]) {
const view = viewGame(g, actor);
assert.equal(view.truthAnswer, null);
assert.equal(view.players.find(p => p.id === f.respondent)!.faceDancers, undefined);
assert.deepEqual(view.truthHistory, []);
}
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'no' });
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'unknown' });
rejected(g, f.observer, { type: 'truthAnswer', answer: 'yes' });
g = answer(g, f, 'yes');
assert.deepEqual(frame(g), original);
assert.deepEqual(player(g, f.respondent).faceDancers, cards);
assert.equal(g.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(g, f.asker).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(g), f.originalInventory);
assert.deepEqual(traitors(g), f.originalTraitors);
for (const actor of [f.asker, f.respondent, f.observer]) {
const history = viewGame(g, actor).truthHistory;
assert.equal(history.length, 1);
assert.equal(history[0].answer, 'yes');
assert.deepEqual(history[0].question, f.action.question);
}
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'yes' });
});
}
void test('a current canonical unheld identity is a definite No, while an ordinary Traitor question stays distinct', () => {
const f = fixture();
const g = ask(f.game, f, f.unheld);
assert.equal(viewGame(g, f.respondent).truthAnswer, 'no');
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'yes' });
assert.equal(viewGame(answer(g, f, 'no'), f.observer).truthHistory[0].answer, 'no');
const ordinary: TruthQuestion = { kind: 'fact', target: f.respondent,
fact: { kind: 'traitor', leader: f.held } };
const different = act(open(f.game, f), f.asker, { type: 'truthAsk', question: ordinary });
assert.equal(viewGame(different, f.respondent).truthAnswer, 'no');
assert.equal(truthFactAnswer(player(f.game, f.respondent), { kind: 'faceDancer', leader: f.held }), 'yes');
const hero = ask(f.game, f, f.hero);
assert.equal(viewGame(hero, f.respondent).truthAnswer, f.cheapHeroHeld ? 'yes' : 'no');
});
void test('nested AND/OR publishes only its aggregate answer without exposing individual Face Dancer matches', () => {
const f = fixture({ advanced: true });
const fact: TruthFact = { kind: 'and', terms: [
{ kind: 'or', terms: [{ kind: 'faceDancer', leader: f.unheld }, { kind: 'faceDancer', leader: f.held }] },
{ kind: 'spice', compare: 'gte', value: 0 },
] };
const question: TruthQuestion = { kind: 'fact', target: f.respondent, fact };
const g = act(open(f.game, f), f.asker, { type: 'truthAsk', question });
assert.equal(viewGame(g, f.respondent).truthAnswer, 'yes');
const done = answer(g, f, 'yes');
for (const actor of [f.asker, f.observer]) {
const view = viewGame(done, actor);
assert.deepEqual(view.truthHistory[0].question, question);
assert.equal(view.truthHistory[0].answer, 'yes');
assert.equal(view.truthAnswer, null);
assert.equal(view.players.find(p => p.id === f.respondent)!.faceDancers, undefined);
assert.deepEqual(Object.keys(view.truthHistory[0]).sort(), ['answer', 'asker', 'phase', 'question', 'turn']);
}
const text = truthQuestionText(question, id => viewGame(g, f.asker).allLeaders.find(l => l.id === id)!.name, id => id);
assert.match(text, /unrevealed Face Dancer/);
assert.match(text, / OR /);
assert.match(text, / AND /);
});
void test('an actual Mentat replacement is allowed after Yes and leaves its historical fact unchanged', () => {
const f = fixture();
const answered = answer(ask(f.game, f), f, 'yes');
const history = structuredClone(viewGame(answered, f.observer).truthHistory);
const replacement = replace(answered, f);
assert.equal(replacement.old, f.held);
assert.notEqual(replacement.new, replacement.old);
assert.equal(truthFactAnswer(player(replacement.game, f.respondent), { kind: 'faceDancer', leader: f.held }), 'no');
assert.equal(truthFactAnswer(player(replacement.game, f.respondent), { kind: 'faceDancer', leader: replacement.new }), 'yes');
assert.deepEqual(viewGame(replacement.game, f.observer).truthHistory, history);
assert.equal(replacement.game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(traitors(replacement.game), f.originalTraitors);
});
void test('the fact does not bypass existing mixed-shipment mode limits and remains usable as a standalone question', () => {
const f = fixture();
const source = advance(f.game, g => g.phase === 5 && g.active === f.respondent && clean(g));
assert.equal(viewGame(source, f.asker).truthShipmentTargets.includes(f.respondent), false);
const question: TruthQuestion = { kind: 'mixedShipment', target: f.respondent, mixed: {
kind: 'or', terms: [
{ kind: 'fact', fact: { kind: 'faceDancer', leader: f.held } },
{ kind: 'shipment', territory: 'arrakeen', minimum: 1 },
],
} };
const ready = open(source, f);
rejected(ready, f.asker, { type: 'truthAsk', question });
const pending = ask(ready, f);
assert.equal(viewGame(pending, f.respondent).truthAnswer, 'yes');
const done = answer(pending, f, 'yes');
assert.equal(done.shipmentPromises, undefined);
assert.equal(done.active, source.active);
});
for (const leader of ['old', 'new'] as const) {
void test(`a genuine replaced-first ${leader} identity uses current custody at a fresh question`, () => {
const f = fixture({ advanced: true, replaced: true });
const identity = f[leader]; assert.ok(identity);
const g = ask(f.game, f, identity);
const expected = leader === 'old' ? 'no' : 'yes';
assert.equal(viewGame(g, f.respondent).truthAnswer, expected);
const done = answer(g, f, expected);
assert.equal(viewGame(done, f.asker).truthHistory[0].answer, expected);
assert.equal(done.phase, 8);
});
}
void test('invalid or shared Duke identities and extra status fields reject before changing the physical question', () => {
const f = fixture();
const ready = open(f.game, f);
for (const fact of [
{ kind: 'faceDancer', leader: DUKE_VIDAL_ID },
{ kind: 'faceDancer', leader: 'missing-disc' },
{ kind: 'faceDancer', leader: f.held, revealed: false },
]) rejected(ready, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.respondent, fact } });
assert.ok(player(ready, f.asker).hand.some(card => card.id === f.card));
assert.equal(ready.discard.some(card => card.id === f.card), false);
});
for (const profile of DIFFICULTIES) {
void test(`${profile} answers the same actual current Face Dancer facts without strategy or hidden rival access`, () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
for (const [identity, expected] of [[f.held, 'yes'], [f.unheld, 'no']] as const) {
const g = ask(f.game, f, identity);
const view = viewGame(g, f.respondent);
view.players.find(p => p.id === f.respondent)!.bot = profile;
const proposed = botActions(view);
assert.deepEqual(proposed, [{ type: 'truthAnswer', answer: expected }]);
const done = act(g, f.respondent, proposed[0]);
assert.equal(done.truthtrance, null);
assert.equal(viewGame(done, f.observer).truthHistory[0].answer, expected);
}
}
});
}