Support freeform Traitor and Face Dancer facts
Verify and publish checkpoint container / container (push) Successful in 11m16s
Verify and publish checkpoint container / container (push) Successful in 11m16s
This commit is contained in:
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'}));
|
||||
});
|
||||
Reference in new issue
Block a user