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
@@ -66,6 +66,7 @@ import { useId, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { DUKE_VIDAL_ID } from '@/game/duke-vidal';
|
||||
import { parseFreeformFactV2 } from '@/game/truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from '@/game/truthtrance-freeform-facts-v3';
|
||||
import { parseFreeformFactV4 } from '@/game/truthtrance-freeform-facts-v4';
|
||||
@@ -73,6 +74,7 @@ import { parseTruthFreeformFactsV5 } from '@/game/truthtrance-freeform-facts-v5'
|
||||
import { parseFreeformFactV6 } from '@/game/truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from '@/game/truthtrance-freeform-facts-v7';
|
||||
import { parseTruthFreeformFactsV8 } from '@/game/truthtrance-freeform-facts-v8';
|
||||
import { parseTruthFreeformFactsV9 } from '@/game/truthtrance-freeform-facts-v9';
|
||||
import {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -108,13 +110,16 @@ export function EarlyRicheseBidFields({ minimum, onMinimum, disabled }: {
|
||||
|
||||
/** Show only a recognized current-fact interpretation; unsupported text and
|
||||
* current-turn promises keep their existing manual-answer guidance. */
|
||||
export function FreeformFactPreview({ scope, text, target, leaderName, locationName, playerName }: {
|
||||
export function FreeformFactPreview({ scope, text, target, leaderName, locationName, playerName, publicLeaders = [] }: {
|
||||
scope: 'fact' | 'currentTurn'; text: string; target: string;
|
||||
leaderName: (id: string) => string; locationName: (id: string) => string; playerName: (id: string) => string;
|
||||
publicLeaders?: readonly { id: string; name: string }[];
|
||||
}) {
|
||||
const leaders = [...publicLeaders.filter(leader => leader.id !== DUKE_VIDAL_ID),
|
||||
{ id: CHEAP_HERO_TRAITOR, name: 'Cheap Hero / Heroine' }];
|
||||
const fact = scope === 'fact' ? parseFreeformFactV2(text) || parseFreeformFactV3(text) ||
|
||||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) || parseFreeformFactV6(text) ||
|
||||
parseTruthFreeformFactsV7(text) || parseTruthFreeformFactsV8(text) : null;
|
||||
parseTruthFreeformFactsV7(text) || parseTruthFreeformFactsV8(text) || parseTruthFreeformFactsV9(text, leaders) : null;
|
||||
return fact ? <p className="notice" aria-label="Recognized freeform question">
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact }, leaderName, locationName, playerName)}
|
||||
</p> : <p className="notice">
|
||||
@@ -1002,7 +1007,7 @@ export function Truthtrance({
|
||||
/>
|
||||
</label>
|
||||
<FreeformFactPreview scope={scope} text={text} target={target}
|
||||
leaderName={leaderName} locationName={locationName} playerName={name} />
|
||||
leaderName={leaderName} locationName={locationName} playerName={name} publicLeaders={g.allLeaders} />
|
||||
</>
|
||||
)}
|
||||
{kind === 'pledgeAid' && <>
|
||||
|
||||
@@ -17,6 +17,15 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Freeform Traitor and Face Dancer facts — 29 October. Prototyped / Partial.**
|
||||
New current-fact questions recognize unique public leader names for held
|
||||
Traitors and unrevealed Face Dancers, including supported compound facts.
|
||||
The editor previews the interpretation and the target receives the checked
|
||||
answer. Twenty-three related freeform files pass 103 cases; an unresolved
|
||||
Traitor choice is staged in the runtime fixture. Arbitrary wording, broader
|
||||
commitments and deployed verification remain open.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-traitor-and-face-dancer-facts--29-october-2026).
|
||||
|
||||
**Freeform No-Field and auction knowledge — 29 October. Prototyped / Partial.**
|
||||
New current-fact questions can name a concealed No-Field value or a current
|
||||
auction, public cache or recorded Ixian group card; they can combine these
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Freeform Traitor and Face Dancer facts — 29 October 2026
|
||||
|
||||
Bounded current-fact wording resolves exact unique public leader names to the
|
||||
existing Traitor and unrevealed Face Dancer predicates. This is a provisional
|
||||
online interpretation of game-related yes/no questions.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-traitor-and-face-dancer-facts--29-october-2026).
|
||||
|
||||
## Freeform No-Field and auction knowledge — 29 October 2026
|
||||
|
||||
Bounded questions about a concealed marker and actual current auction knowledge
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Freeform Traitor and Face Dancer facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions recognize an exact
|
||||
unique public leader name in a held-Traitor or unrevealed-Face-Dancer question,
|
||||
including supported single-operator combinations with other current facts.
|
||||
The editor previews the checked interpretation. The server uses the existing
|
||||
distinct predicates, and only the target sees the verified answer before
|
||||
replying; an unresolved Traitor choice yields I don't know. Absent or ambiguous
|
||||
names stay player-managed. The shared Cheap Hero / Heroine identity is
|
||||
supported; Duke Vidal is not an eligible named identity for this grammar.
|
||||
|
||||
New interpretations carry version 9; earlier versions and unstamped questions
|
||||
retain their existing meaning. The wording is a provisional online
|
||||
interpretation, not general language understanding. Runtime evidence exercises
|
||||
dealt Traitors and Face Dancers and stages one unresolved Traitor choice;
|
||||
fixtures also stage conserved Truthtrance custody. Twenty-three related
|
||||
freeform files pass 103 cases. Broader questions, full rules and deployed
|
||||
verification remain open.
|
||||
|
||||
## Freeform No-Field and auction knowledge — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions recognize bounded
|
||||
|
||||
+1
-1
@@ -4036,7 +4036,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Supported negative hand questions check that the named face or category count is zero. Category questions use the displayed printed primary role. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, physical force pools, concealed No-Field values and current auction knowledge also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous AND or OR; only their aggregate answer is published. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
|
||||
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Supported negative hand questions check that the named face or category count is zero. Category questions use the displayed printed primary role. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, physical force pools, concealed No-Field values, current auction knowledge, and uniquely named Traitors or unrevealed Face Dancers also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous AND or OR; only their aggregate answer is published. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
|
||||
'In supported quiet Basic Bidding or Shipment and Movement, ask whether a player will update funding for their current mutual ally to at least a chosen amount this phase. Yes requires a future accepted update while possible; No forbids reaching the amount. An existing pledge alone does not count, a repeated same total does, and the ally chooses whether to spend it. Only one definite funding question is supported per phase; other future-action questions in that phase and optional modules remain separate.',
|
||||
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
|
||||
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { parseFreeformFactV2 } from './truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
|
||||
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
|
||||
import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
|
||||
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
|
||||
import { parseTruthFreeformFactsV8 } from './truthtrance-freeform-facts-v8';
|
||||
import { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
export type FreeformPublicLeader = { id: string; name: string };
|
||||
type Atom = { fact: TruthFact; fresh: boolean };
|
||||
type Separator = { start: number; end: number; kind: 'and' | 'or' };
|
||||
|
||||
/** Only exact public roster names resolve. A caller can supply the special
|
||||
* Cheap Hero identity, but must never supply held cards or other private data. */
|
||||
function namedAtom(input: string, leaders: readonly FreeformPublicLeader[]): TruthFact | null {
|
||||
const text = input.trim().replace(/[ \t]+/g, ' ');
|
||||
const traitor = /^is (.+?) your traitor$/i.exec(text);
|
||||
const dancer = /^do you hold (.+?) as an unrevealed face dancer$/i.exec(text);
|
||||
const name = (traitor?.[1] ?? dancer?.[1])?.toLowerCase();
|
||||
if (!name) return null;
|
||||
const candidates = new Set(leaders.filter(leader =>
|
||||
typeof leader?.id === 'string' && leader.id.length > 0 && leader.id !== DUKE_VIDAL_ID &&
|
||||
typeof leader.name === 'string' && leader.name.trim().replace(/[ \t]+/g, ' ').toLowerCase() === name
|
||||
).map(leader => leader.id));
|
||||
if (candidates.size !== 1) return null;
|
||||
const leader = [...candidates][0];
|
||||
return { kind: traitor ? 'traitor' : 'faceDancer', leader };
|
||||
}
|
||||
|
||||
/** Resolve a named current fact, or a uniquely parsed flat homogeneous
|
||||
* AND/OR with other already-supported atoms. Prior whole questions retain
|
||||
* their old versions; ambiguous, mixed and unrecognized forms stay manual. */
|
||||
export function parseTruthFreeformFactsV9(text: string, leaders: readonly FreeformPublicLeader[]): TruthFact | null {
|
||||
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text) || !Array.isArray(leaders)) return null;
|
||||
const input = text.trim();
|
||||
if (parseFreeformFactV2(input) || parseFreeformFactV3(input) || parseFreeformFactV4(input) ||
|
||||
parseTruthFreeformFactsV5(input) || parseFreeformFactV6(input) || parseTruthFreeformFactsV7(input) ||
|
||||
parseTruthFreeformFactsV8(input)) return null;
|
||||
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
|
||||
if (!body || body.includes('?')) return null;
|
||||
const single = namedAtom(body, leaders);
|
||||
const separators: Separator[] = [...body.matchAll(/[ \t]+(and|or)[ \t]+/gi)].map(match => ({
|
||||
start: match.index!, end: match.index! + match[0].length,
|
||||
kind: match[1].toLowerCase() as 'and' | 'or',
|
||||
}));
|
||||
if (separators.length > 24) return null;
|
||||
const shorthand = /^do[ \t]+you[ \t]+have[ \t]+/i.test(body);
|
||||
const atomCache = new Map<string, Atom | null>();
|
||||
const atom = (raw: string, first: boolean): Atom | null => {
|
||||
const segment = raw.trim();
|
||||
if (!segment) return null;
|
||||
const key = `${first ? 'first' : 'later'}:${segment}`;
|
||||
if (atomCache.has(key)) return atomCache.get(key)!;
|
||||
let qualified = segment;
|
||||
if (!first && /^not[ \t]+have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && /^have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && shorthand && !/^(?:do|does|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`;
|
||||
const fresh = namedAtom(qualified, leaders);
|
||||
const old = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified) ??
|
||||
(/^(?:do[ \t]+you[ \t]+have[ \t]+a[ \t]+concealed|is[ \t]+the[ \t]+current|does[ \t]+your[ \t]+recorded)/i.test(qualified)
|
||||
? parseTruthFreeformFactsV8(qualified) : null);
|
||||
const result = fresh && old ? null : fresh ? {fact:fresh, fresh:true}
|
||||
: old && old.kind !== 'and' && old.kind !== 'or' ? {fact:old, fresh:false} : null;
|
||||
atomCache.set(key, result);
|
||||
return result;
|
||||
};
|
||||
const memo = new Map<string, Atom[][]>();
|
||||
const suffix = (offset: number, kind: 'and' | 'or', count: number): Atom[][] => {
|
||||
const key = `${offset}:${kind}:${count}`;
|
||||
const cached = memo.get(key); if (cached) return cached;
|
||||
const results: Atom[][] = [];
|
||||
const last = atom(body.slice(offset), false);
|
||||
if (last && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([last]);
|
||||
if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const separator of separators) {
|
||||
if (separator.start <= offset || separator.kind !== kind) continue;
|
||||
const head = atom(body.slice(offset, separator.start), false);
|
||||
if (!head) continue;
|
||||
for (const tail of suffix(separator.end, kind, count + 1)) {
|
||||
results.push([head, ...tail]);
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
memo.set(key, results);
|
||||
return results;
|
||||
};
|
||||
const matches: Array<{kind:'and'|'or'; atoms:Atom[]}> = [];
|
||||
for (const separator of separators) {
|
||||
const head = atom(body.slice(0, separator.start), true);
|
||||
if (!head) continue;
|
||||
for (const tail of suffix(separator.end, separator.kind, 1)) {
|
||||
matches.push({kind:separator.kind, atoms:[head, ...tail]});
|
||||
if (matches.length >= 2) return null;
|
||||
}
|
||||
}
|
||||
if (single) return matches.length ? null : single;
|
||||
const match = matches[0];
|
||||
return match?.atoms.some(item => item.fresh)
|
||||
? {kind:match.kind, terms:match.atoms.map(item => item.fact)} : null;
|
||||
}
|
||||
+25
-2
@@ -75,6 +75,7 @@ import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
|
||||
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
|
||||
import { parseTruthFreeformFactsV8 } from './truthtrance-freeform-facts-v8';
|
||||
import { parseTruthFreeformFactsV9 } from './truthtrance-freeform-facts-v9';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -140,7 +141,7 @@ export type TruthQuestion =
|
||||
text: string;
|
||||
scope: 'fact' | 'currentTurn';
|
||||
/** Only newly asked, explicitly recognized facts carry this stable interpretation. */
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7 | 8; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -174,6 +175,16 @@ const record = (value: unknown): Record<string, unknown> => {
|
||||
);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
/** Names available to every player, independent of private Traitor custody. */
|
||||
function freeformLeaderRoster(g: Game): { id: string; name: string }[] {
|
||||
return [
|
||||
...g.players.flatMap(player => player.leaders)
|
||||
.filter(leader => leader.id !== DUKE_VIDAL_ID)
|
||||
.map(leader => ({ id: leader.id, name: leader.name })),
|
||||
{ id: CHEAP_HERO_TRAITOR, name: 'Cheap Hero / Heroine' },
|
||||
];
|
||||
}
|
||||
|
||||
function parseFact(
|
||||
g: Game,
|
||||
value: unknown,
|
||||
@@ -740,6 +751,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
|
||||
!parseFreeformFactV6(question.text) && !parseTruthFreeformFactsV7(question.text)
|
||||
? parseTruthFreeformFactsV8(question.text) : null;
|
||||
const leaderFact = question.scope === 'fact' && marker.version === 9 &&
|
||||
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
|
||||
!parseFreeformFactV6(question.text) && !parseTruthFreeformFactsV7(question.text) &&
|
||||
!parseTruthFreeformFactsV8(question.text)
|
||||
? parseTruthFreeformFactsV9(question.text, freeformLeaderRoster(g)) : null;
|
||||
const expected = oldFact ? { version: 1 as const, fact: oldFact }
|
||||
: newFact ? { version: 2 as const, fact: newFact }
|
||||
: categoryFact ? { version: 3 as const, fact: categoryFact }
|
||||
@@ -747,7 +764,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
: compoundFact ? { version: 5 as const, fact: compoundFact }
|
||||
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) }
|
||||
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) }
|
||||
: inspectedFact ? { version: 8 as const, fact: parseFact(g, inspectedFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
: inspectedFact ? { version: 8 as const, fact: parseFact(g, inspectedFact, g.players.find(player => player.id === question.target)!) }
|
||||
: leaderFact ? { version: 9 as const, fact: parseFact(g, leaderFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1246,6 +1264,11 @@ export function resolveTruthAction(
|
||||
const inspectedFact = parseTruthFreeformFactsV8(w.question.text);
|
||||
if (inspectedFact) w.question.interpreted = { version: 8,
|
||||
fact: parseFact(g, inspectedFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
else {
|
||||
const leaderFact = parseTruthFreeformFactsV9(w.question.text, freeformLeaderRoster(g));
|
||||
if (leaderFact) w.question.interpreted = { version: 9,
|
||||
fact: parseFact(g, leaderFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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