// The parser: words on the grid become rules. // // Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`, // read left to right in a row and top to bottom in a column. The parser starts at // every `IS`, collects the subjects before it and the complements after it, and // emits one rule per pair: AND distributes over both sides. A tile holding several // words contributes each of them, so stacked text yields every possible rule. // // `NOT` before a complement negates that one complement. `NOT` before a subject is // not in the grammar: the run of subjects stops there and that subject is dropped. // // The parser only reads; which rules win is the resolution pass (`resolve`). import type { Board, ObjectId } from "./grid"; import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word"; /** * One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`. * `text` is the text objects that spell it (subject, `IS`, `NOT` if any, * complement), or empty for a base rule. */ export interface Rule { subject: string; complement: string; complementRole: Role; negated: boolean; text: ObjectId[]; } /** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`. */ export const ruleText = (r: Rule): string => `${r.subject} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`; /** `X IS Y` where `Y` is a noun: every X is to become a Y. */ export const isConversion = (r: Rule): boolean => r.complementRole === "noun"; /** `X IS X` (or `X IS NOT X`). */ export const isIdentity = (r: Rule): boolean => r.subject === r.complement; /** Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. */ export const negates = (n: Rule, r: Rule): boolean => n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement; /** Rules in force with no text: the game's base rules (`[subject, property]`). */ export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]]; type Tiles = Map; type Pos = readonly [number, number]; /** A word in a run, with the `NOT` in front of it if there is one. */ interface Word { not?: Token; token: Token; } /** * Every rule the board spells, base rules first, negated rules included. A rule * spelled by two different sets of text objects appears twice. */ export function parse(board: Board, lexicon: Lexicon): Rule[] { const tiles = lex(board, lexicon); const rules: Rule[] = BASE_RULES.map(([subject, property]) => ({ subject, complement: property, complementRole: "property", negated: false, text: [], })); for (const o of board.objects()) { if (o.kind.type !== "text" || o.kind.word !== IS) continue; for (const [dx, dy] of [ [1, 0], [0, 1], ] as const) { const ss = subjects(tiles, [o.x - dx, o.y - dy], [-dx, -dy]); const cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]); for (const s of ss) { for (const c of cs) { rules.push({ subject: s.word, complement: c.token.word, complementRole: c.token.role, negated: c.not !== undefined, text: c.not ? [s.id, o.id, c.not.id, c.token.id] : [s.id, o.id, c.token.id], }); } } } } return rules; } const wordsAt = (tiles: Tiles, [x, y]: Pos): Token[] => tiles.get(tileKey(x, y)) ?? []; const holds = (tiles: Tiles, p: Pos, word: string): boolean => wordsAt(tiles, p).some((t) => t.word === word); const ahead = ([x, y]: Pos, [dx, dy]: Pos): Pos => [x + dx, y + dy]; /** * The run of subjects before an `IS`, walking backwards from `at`: the nouns at * `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT` * before a noun ends the run without it (a negated subject is not in the grammar). */ function subjects(tiles: Tiles, at: Pos, step: Pos): Token[] { const before = ahead(at, step); if (holds(tiles, before, NOT)) return []; const found = wordsAt(tiles, at).filter((t) => t.role === "noun"); if (found.length > 0 && holds(tiles, before, AND)) found.push(...subjects(tiles, ahead(before, step), step)); return found; } /** * The run of complements after an `IS`, walking forwards from `at`: the nouns * and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it, * negated; then, if the next tile holds an AND, the run beyond it. */ function complements(tiles: Tiles, at: Pos, step: Pos): Word[] { const fits = (t: Token) => t.role === "noun" || t.role === "property"; const found: Word[] = []; // Plain words here, and negated words one tile on: a stack may hold both. const branches: [Token | undefined, Pos][] = [[undefined, at]]; for (const not of wordsAt(tiles, at).filter((t) => t.word === NOT)) branches.push([not, ahead(at, step)]); for (const [not, p] of branches) { const words = wordsAt(tiles, p) .filter(fits) .map((token) => ({ not, token })); if (words.length === 0) continue; found.push(...words); const andAt = ahead(p, step); if (holds(tiles, andAt, AND)) found.push(...complements(tiles, ahead(andAt, step), step)); } return found; }