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(); 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(); 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; }