Module 9 lesson, scenarios, tests (8, both tracks), reference engines (TURN phase list, conversion, content-registered hazards, win/lost), WEAK Expansion Drop, harness.md M9 semantics, research notes (WEAK page, baba-is-auto Game::MovePlayer, X IS NOT X placement reading). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.1 KiB
TypeScript
131 lines
5.1 KiB
TypeScript
// 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<string, Token[]>;
|
|
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;
|
|
}
|