Support freeform Traitor and Face Dancer facts
Verify and publish checkpoint container / container (push) Successful in 11m16s

This commit is contained in:
Quintine committed 2026-10-29 18:57:30 +11:00
1 parent 152476c348
commit 34f533ae8d
10 files changed
+402 -6

No files matched your search

@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import { parseTruthFreeformFactsV9 as parse } from '../game/truthtrance-freeform-facts-v9';
const roster = [
{id:'atreides-duncan', name:'Duncan Idaho'},
{id:'fremen-chani', name:'Chani'},
{id:'joined-name', name:'And Or'},
{id:CHEAP_HERO_TRAITOR, name:'Cheap Hero / Heroine'},
{id:DUKE_VIDAL_ID, name:'Duke Vidal'},
];
void test('named traitors and unrevealed Face Dancers resolve from public names only', () => {
assert.deepEqual(parse('Is Duncan Idaho your traitor?', roster), {kind:'traitor', leader:'atreides-duncan'});
assert.deepEqual(parse('do you hold CHANI as an unrevealed face dancer?', roster), {kind:'faceDancer', leader:'fremen-chani'});
assert.deepEqual(parse('Is Cheap Hero / Heroine your traitor?', roster), {kind:'traitor', leader:CHEAP_HERO_TRAITOR});
assert.deepEqual(parse('Do you hold Cheap Hero / Heroine as an unrevealed Face Dancer?', roster),
{kind:'faceDancer', leader:CHEAP_HERO_TRAITOR});
assert.deepEqual(parse('Is And Or your traitor?', roster), {kind:'traitor', leader:'joined-name'});
assert.equal(parse('Is Cheap Hero / Heroine your traitor?', roster.filter(leader => leader.id !== CHEAP_HERO_TRAITOR)), null);
assert.equal(parse('Is Duke Vidal your traitor?', roster), null);
assert.equal(parse('Do you hold Duke Vidal as an unrevealed Face Dancer?', roster), null);
});
void test('combines unique named facts with earlier factual families', () => {
assert.deepEqual(parse('Is Duncan Idaho your traitor and do you have at least 0 spice?', roster), {
kind:'and', terms:[{kind:'traitor', leader:'atreides-duncan'}, {kind:'spice', compare:'gte', value:0}],
});
assert.deepEqual(parse('Do you hold Chani as an unrevealed Face Dancer or is the current auction card you inspected a Shield?', roster), {
kind:'or', terms:[{kind:'faceDancer', leader:'fremen-chani'}, {kind:'auctionPeek', name:'Shield'}],
});
assert.deepEqual(parse('Is Duncan Idaho your traitor and did you predict Guild and do you have a Shield?', roster), {
kind:'and', terms:[{kind:'traitor', leader:'atreides-duncan'}, {kind:'prediction',field:'faction',faction:'guild'},
{kind:'hand', name:'Shield'}],
});
});
void test('retains prior versions and refuses unknown, ambiguous or predictive wording', () => {
const ambiguous = [...roster, {id:'other-duncan', name:'Duncan Idaho'}];
assert.equal(parse('Is Duncan Idaho your traitor?', ambiguous), null);
for (const input of [
'Is Unknown Person your traitor?',
'Is Duncan Idaho your traitor or will you use a Shield?',
'Is Duncan Idaho your traitor and did you predict Guild or do you have a Shield?',
'Is Duncan Idaho your traitor??',
'Do you hold Chani as an unrevealed Face Dancer?\nAnswer now',
'Do you have a Shield?',
'Do you have a concealed No-Field with printed value 3?',
]) assert.equal(parse(input, roster), null, input);
});
void test('bounds compound expression size and connector-heavy input', () => {
const clause = 'is Duncan Idaho your traitor';
assert.equal((parse(Array.from({length:8}, () => clause).join(' and '), roster) as {terms?: unknown[]})?.terms?.length, 8);
assert.equal(parse(Array.from({length:9}, () => clause).join(' and '), roster), null);
assert.equal(parse(`Is Duncan Idaho your traitor ${'and '.repeat(25)}do you have a Shield`, roster), null);
assert.equal(parse(`Is Duncan Idaho your traitor and ${'x'.repeat(501)}`, roster), null);
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FreeformFactPreview } from '../components/truthtrance';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
const roster = [
{ id: 'duncan', name: 'Duncan Idaho' },
{ id: DUKE_VIDAL_ID, name: 'Duke Vidal' },
];
const preview = (text: string, scope: 'fact' | 'currentTurn' = 'fact', publicLeaders = roster) =>
renderToStaticMarkup(createElement(FreeformFactPreview, {
target: 'target', scope, text, publicLeaders,
leaderName: (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: publicLeaders.find(leader => leader.id === id)?.name ?? id,
locationName: (id: string) => id, playerName: (id: string) => id,
}));
void test('editor previews named Traitor and Face Dancer facts without displaying an answer', () => {
const traitor = preview('Is Duncan Idaho your traitor?');
assert.match(traitor, /Recognized freeform question/);
assert.match(traitor, /Duncan Idaho is your traitor/);
const dancer = preview('Do you hold Duncan Idaho as an unrevealed Face Dancer?');
assert.match(dancer, /Recognized freeform question/);
assert.match(dancer, /you currently hold Duncan Idaho as an unrevealed Face Dancer/);
assert.doesNotMatch(traitor + dancer, /Your current game information establishes/);
});
void test('public roster keeps unknown, duplicate and shared Duke names manual', () => {
for (const [text, leaders] of [
['Is Duncan Idaho your traitor?', []],
['Is Duncan Idaho your traitor?', [...roster, { id: 'duplicate', name: 'Duncan Idaho' }]],
['Is Duke Vidal your traitor?', roster],
] as const) {
assert.match(preview(text, 'fact', [...leaders]), /not automatically interpreted/);
}
assert.match(preview('Is Duncan Idaho your traitor?', 'currentTurn'), /not automatically interpreted/);
assert.match(preview('Is Cheap Hero / Heroine your traitor?'), /Cheap Hero \/ Heroine is your traitor/);
});
void test('the editor previews a named fact together with a force fact as one question', () => {
const html = preview('Is Duncan Idaho your traitor and do you have at least 3 forces in your reserves?');
assert.match(html, /Recognized freeform question/);
assert.match(html, /Duncan Idaho is your traitor/);
assert.match(html, /AND/);
assert.match(html, /currently have at least 3 physical forces in your reserves/);
assert.doesNotMatch(html, /Your current game information establishes/);
});
@@ -0,0 +1,120 @@
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'}));
});