Files
Dune/game/truthtrance-freeform-facts-v8.ts
T
Quintine 152476c348
Verify and publish checkpoint container / container (push) Canceled after 0s
Add freeform No-Field and auction facts
2026-10-29 18:53:48 +11:00

113 lines
5.8 KiB
TypeScript

import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { knowledgeFactInputError, type KnowledgeFact } from './truthtrance-knowledge';
import { parseNoFieldValueFact } from './truthtrance-no-field-value';
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 { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
import type { TruthFact } from './truthtrance';
type Atom = { fact: TruthFact; fresh: boolean };
type Separator = { start: number; end: number; kind: 'and' | 'or' };
/** New questions refer only to an actual current observation or concealed
* marker. Resolve printed names from the public catalog, never a game state. */
function newAtom(input: string): TruthFact | null {
const text = input.trim().replace(/[ \t]+/g, ' ');
const field = /^do you have a concealed no-field with printed value (0|3|5)$/i.exec(text);
if (field) return parseNoFieldValueFact({kind:'noFieldValue', value:Number(field[1])});
const descriptions = [
{ kind: 'auctionPeek' as const, pattern: /^is the current auction card you inspected (.+)$/i },
{ kind: 'cacheLot' as const, pattern: /^is the current public cache card (.+)$/i },
{ kind: 'ixPoolPeek' as const, pattern: /^does your recorded ixian auction group contain (.+)$/i },
];
for (const {kind, pattern} of descriptions) {
const match = pattern.exec(text);
if (!match) continue;
const raw = match[1].trim().toLowerCase();
// Consider the exact name as well as a grammatical article; never guess
// if a future printed catalog creates a collision between those forms.
const withoutArticle = raw.replace(/^(?:a|an) /, '');
const names = TRUTH_CARD_NAMES.filter(name => {
const folded = name.toLowerCase();
return folded === raw || folded === withoutArticle;
});
if (names.length !== 1) return null;
const fact: KnowledgeFact = {kind, name:names[0]};
return knowledgeFactInputError(fact) ? null : fact;
}
return null;
}
/** Accept one new atom, or a unique homogeneous AND/OR of two to eight
* established and new atoms. Earlier whole interpretations always win. */
export function parseTruthFreeformFactsV8(text: string): TruthFact | 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) || parseTruthFreeformFactsV7(input)) return null;
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
if (!body || body.includes('?')) return null;
const single = newAtom(body);
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 = newAtom(qualified);
const old = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified);
// Refuse any future collision between previously distinct grammars.
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;
}