Files
Dune/tests/truthtrance-freeform-v9-runtime.test.ts
T
Quintine 34f533ae8d
Verify and publish checkpoint container / container (push) Successful in 11m16s
Support freeform Traitor and Face Dancer facts
2026-10-29 18:57:30 +11:00

121 lines
6.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
import { createTruthtranceFaceDancerFixture, truthFaceDancerInventory,
truthFaceDancerTraitors, truthFaceDancerPlayer } from './fixture-truthtrance-face-dancer';
const player = (g: Game, id: string) => {
const p = g.players.find(candidate => candidate.id === id); assert.ok(p); return p;
};
const publicLeaders = (g: Game) => g.players.flatMap(p => p.leaders);
function namedLeader(g: Game, predicate: (id: string) => boolean) {
const roster = publicLeaders(g);
const leader = roster.find(item => predicate(item.id) && roster.filter(other => other.name.toLowerCase() === item.name.toLowerCase()).length === 1);
assert.ok(leader, 'An eligible unique public leader name must exist');
return leader;
}
function traitorTarget(g: Game) {
const roster = publicLeaders(g);
const target = g.players.find(p => p.id !== 'a' && p.traitors.some(id =>
roster.some(leader => leader.id === id && roster.filter(other => other.name.toLowerCase() === leader.name.toLowerCase()).length === 1)));
assert.ok(target, 'A non-asking seat has a uniquely named ordinary Traitor');
return target;
}
function ask(g: Game, asker: string, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') {
const card = player(g, asker).hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
let next = applyAction(g, asker, {type:'card', card:card.id});
while (next.truthtrance?.stage === 'priority') {
const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
next = applyAction(next, actor.id, {type:'truthPass'});
}
return applyAction(next, asker, {type:'truthAsk', question:{kind:'freeform', target, scope, text}});
}
function question(g: Game) {
const q = g.truthtrance?.question; assert.equal(q?.kind, 'freeform');
if (q?.kind !== 'freeform') throw Error('Expected freeform question');
return q;
}
function verify(g: Game, target: string, answer: 'yes' | 'no' | 'unknown') {
assert.equal(question(g).interpreted?.version, 9);
assert.equal(viewGame(g, target).truthAnswer, answer);
for (const seat of g.players.filter(p => p.id !== target)) {
const view = viewGame(g, seat.id);
assert.equal(view.truthAnswer, null);
assert.equal(view.players.find(p => p.id === target)!.traitors, undefined);
assert.equal(view.players.find(p => p.id === target)!.faceDancers, undefined);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, target); view.players.find(p => p.id === target)!.bot = difficulty;
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer}]);
}
for (const wrong of (['yes','no','unknown'] as const).filter(value => value !== answer))
rejectKnowledge(g, target, {type:'truthAnswer', answer:wrong});
const restored = JSON.parse(JSON.stringify(g)) as Game;
const done = applyAction(restored, target, {type:'truthAnswer', answer});
if (answer === 'unknown') assert.equal(done.truthtrance?.stage, 'unknown');
else assert.equal(done.truthHistory!.at(-1)!.answer, answer);
return done;
}
void test('V9 checks the actual held and unheld Traitor privately', () => {
const initial = knowledgeGame(), target = traitorTarget(initial);
const held = namedLeader(initial, id => target.traitors.includes(id));
const unheld = namedLeader(initial, id => !target.traitors.includes(id));
const inventory = knowledgePhysical(initial);
for (const [name, answer] of [[held.name,'yes'], [unheld.name,'no']] as const) {
const pending = ask(structuredClone(initial), 'a', target.id, `Is ${name} your traitor?`);
const done = verify(pending, target.id, answer);
assert.deepEqual(knowledgePhysical(done), inventory);
}
const compound = ask(structuredClone(initial), 'a', target.id, `Is ${held.name} your traitor and do you have at least 0 spice?`);
const done = verify(compound, target.id, 'yes');
assert.deepEqual(knowledgePhysical(done), inventory);
});
void test('V9 preserves unknown for an unresolved physical Traitor choice', () => {
const initial = knowledgeGame(), target = traitorTarget(initial);
const held = namedLeader(initial, id => target.traitors.includes(id));
// Stage an unresolved legacy setup choice without creating a second card.
target.traitors = target.traitors.filter(id => id !== held.id);
target.traitorChoices = [held.id];
const pending = ask(initial, 'a', target.id, `Is ${held.name} your traitor?`);
const done = verify(pending, target.id, 'unknown');
assert.deepEqual(player(done,target.id).traitorChoices, [held.id]);
});
void test('V9 checks actual native Face Dancer custody separately from Traitors', () => {
const f = createTruthtranceFaceDancerFixture();
const held = namedLeader(f.game, id => f.heldIDs.includes(id));
const absent = namedLeader(f.game, id => !f.heldIDs.includes(id));
const original = structuredClone(truthFaceDancerPlayer(f.game, f.respondent).faceDancers);
for (const [leader, answer] of [[held,'yes'], [absent,'no']] as const) {
const pending = ask(structuredClone(f.game), f.asker, f.respondent, `Do you hold ${leader.name} as an unrevealed Face Dancer?`);
const done = verify(pending, f.respondent, answer);
assert.deepEqual(truthFaceDancerPlayer(done, f.respondent).faceDancers, original);
assert.deepEqual(truthFaceDancerInventory(done), f.originalInventory);
assert.deepEqual(truthFaceDancerTraitors(done), f.originalTraitors);
}
const different = ask(structuredClone(f.game), f.asker, f.respondent, `Is ${held.name} your traitor?`);
verify(different, f.respondent, 'no');
});
void test('V9 leaves unstamped and current-turn questions manual and rejects receipt tampering', () => {
const initial = knowledgeGame(), target = traitorTarget(initial), held = namedLeader(initial, id => target.traitors.includes(id));
const text = `Is ${held.name} your traitor?`;
const future = ask(structuredClone(initial), 'a', target.id, text, 'currentTurn');
assert.equal(question(future).interpreted, undefined);
const old = ask(structuredClone(initial), 'a', target.id, text);
delete question(old).interpreted;
assert.equal(viewGame(old,target.id).truthAnswer, null);
assert.equal(applyAction(old,target.id,{type:'truthAnswer',answer:'no'}).truthHistory!.at(-1)!.answer,'no');
const changed = ask(structuredClone(initial), 'a', target.id, text);
const q = question(changed); assert.equal(q.interpreted?.version, 9);
if (!q.interpreted) throw Error('Expected interpretation');
q.interpreted.fact = {kind:'faceDancer',leader:held.id};
assert.throws(() => viewGame(changed,target.id));
assert.throws(() => applyAction(changed,target.id,{type:'truthAnswer',answer:'yes'}));
});