import { TERRITORIES } from './board'; import { FACTIONS } from './catalog'; import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from './truthtrance-knowledge'; import type { ForceCountFact } from './truthtrance-force-count'; /** Anchored present facts only. This version is independent of earlier saved * interpretations and consults printed public registries, never a game state. */ export function parseFreeformFactV6(text: string): KnowledgeFact | ForceCountFact | null { if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null; const input = text.trim().toLowerCase().replace(/[ \t]+/g, ' '); const comparison = (value: string): 'eq' | 'gte' | 'lte' => value === 'at least' ? 'gte' : value === 'at most' ? 'lte' : 'eq'; const knowledge = (fact: KnowledgeFact): KnowledgeFact | null => knowledgeFactInputError(fact) ? null : fact; const faction = /^(?:did you predict (.+?)(?: to win)?|is your predicted faction (.+?))\??$/.exec(input); if (faction) { const name = faction[1] ?? faction[2]; const matches = FACTIONS.filter(candidate => candidate.name.toLowerCase() === name || candidate.id.toLowerCase() === name); return matches.length === 1 ? knowledge({kind:'prediction', field:'faction', faction:matches[0].id}) : null; } const numeric = /^is your (predicted turn|storm forecast|(?:submitted )?storm dial) (at least|exactly|at most) ([0-9]+)\??$/.exec(input); if (numeric) { const compare = comparison(numeric[2]), value = Number(numeric[3]); const fact: KnowledgeFact = numeric[1] === 'predicted turn' ? {kind:'prediction', field:'turn', compare, value} : {kind:numeric[1] === 'storm forecast' ? 'stormForecast' : 'stormDial', compare, value}; return knowledge(fact); } const spice = /^is the next spice blow (?:a |an )?(sandworm|worm|sandtrout)\??$/.exec(input); if (spice) return knowledge({kind:'spicePeek', value:spice[1] === 'sandworm' ? 'worm' : spice[1]}); const blow = /^is the next spice blow (?:in|at) (.+?)\??$/.exec(input); if (blow) { const name = blow[1]; const matches = TERRITORIES.filter(candidate => SPICE_PEEK_VALUES.includes(candidate.id) && (candidate.name.toLowerCase() === name || candidate.name.toLowerCase().replace(/^the /, '') === name)); return matches.length === 1 ? knowledge({kind:'spicePeek', value:matches[0].id}) : null; } const physical = /^do you have (at least|exactly|at most) ([0-9]+) (?:physical )?forces? (in (?:your )?reserves|in (?:the |your )?tanks|on the board)\??$/.exec(input); if (physical) { const value = Number(physical[2]); if (!Number.isSafeInteger(value) || value < 0) return null; const zone: ForceCountFact['zone'] = physical[3] === 'on the board' ? {kind:'board'} : physical[3].endsWith('reserves') ? {kind:'reserves'} : {kind:'tanks'}; return {kind:'forceCount', zone, counter:'total', compare:comparison(physical[1]), value}; } return null; }