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 { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression'; import type { TruthFact } from './truthtrance'; type Compound = Extract; type Separator = { start: number; end: number; kind: 'and' | 'or' }; /** Recognize only a uniquely partitioned, flat current-fact expression. * Existing whole questions retain their original parser version. Each atom * uses the established public grammar; this parser never inspects game state. */ export function parseTruthFreeformFactsV7(text: string): Compound | null { if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null; const input = text.trim(); if (parseFreeformFactV2(input) || parseFreeformFactV3(input) || parseFreeformFactV4(input) || parseTruthFreeformFactsV5(input) || parseFreeformFactV6(input)) return null; const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input; if (!body || body.includes('?')) return null; 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', })); // Keep the search bounded even if a malicious input contains many words // that resemble connectors. A separator may still be part of a card name. if (!separators.length || 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): TruthFact | 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|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`; const fact = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified); const result = fact && fact.kind !== 'and' && fact.kind !== 'or' ? fact : null; atomCache.set(key, result); return result; }; // Retain at most two paths per suffix; two prove ambiguity. Memoization // bounds repeated exploration even when many separators form card names. const memo = new Map(); const suffix = (offset: number, kind: 'and' | 'or', count: number): TruthFact[][] => { const key = `${offset}:${kind}:${count}`; const cached = memo.get(key); if (cached) return cached; const results: TruthFact[][] = []; const final = atom(body.slice(offset), false); if (final && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([final]); if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const sep of separators) { if (sep.start <= offset || sep.kind !== kind) continue; const head = atom(body.slice(offset, sep.start), false); if (!head) continue; for (const tail of suffix(sep.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: Compound[] = []; for (const sep of separators) { const first = atom(body.slice(0, sep.start), true); if (!first) continue; for (const tail of suffix(sep.end, sep.kind, 1)) { matches.push({kind: sep.kind, terms: [first, ...tail]}); if (matches.length >= 2) return null; } } return matches.length === 1 ? matches[0] : null; }