clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / docs / worlds / clockwork-api.md
clockwork-api.md 23.8K rendered
1# Clockwork: custom property contract23Owner: Clockwork world-builder. Audience: the engine team, then design.4Grounded in `src/engine.js` and `src/mods.js` as of commit `78137b1` plus the5uncommitted automata work on 09-23.67Clockwork proves a mod can add custom rules. It uses two properties:89| Property | Kind | Status |10| --- | --- | --- |11| `TICK` | Autonomous: things repeat a step pattern, one beat per turn | **Shipped** as declarative `level.automata`. All 6 Clockwork levels use it and pass the real solver. |12| `OPEN` / `SHUT` | Contact: an `OPEN` and a `SHUT` thing in one cell destroy each other | **Proposed** as declarative `level.pairs` (below). Prototype only, 3 draft levels. |1314Both are **data, not code**. An earlier JS-handler draft of this API was15dropped on the engine team's direction. Its findings are recorded in16[Rejected: JS handlers](#rejected-js-handlers), because they explain why data17is the right call.1819## Ask for the engine team2021Most of the first round landed: `subject` is optional (the rule word carries22the pattern), `wait()` is exported, the solver restores phase and tries `W`,23and a thing the player pushed still takes its beat. The remaining asks, in24priority order:25261. **Stop the silent noun trap.** A word the parser doesn't know is a noun. If a27 level says `DOOR IS SHUT` without declaring `SHUT`, every door silently28 turns into an unglyphed `SHUT` thing and stops being `STOP`. That's what29 happens on the shipped engine today to the draft level `key-and-door`,30 which becomes free to walk through. Ask: at load time, warn about any31 predicate word that isn't a declared property and isn't a noun that32 appears in the pack's objects, palette, or vocabulary. Also reject an33 object whose noun equals a declared property word. `validateLevel` accepts34 `{ noun: 'TICK' }` today.352. **Draw declared properties as property tiles.** `src/render/board.js`36 hard-codes `TICK` into its `PROPERTIES` set. A pack that names its37 property `WIND` or `SPIN` gets a noun-style tile. Derive the set from38 `level.automata[].property` (and `level.pairs[].words` later).393. **Let the player learn the word.** Accept an optional pack-level glossary,40 `properties: { TICK: { accent: 'push', describe: 'Repeats its steps, one for each of your turns.' } }`.41 `accent` is a property colour token. `describe` is one sentence of at most42 80 characters, shown the first time the tile appears and in the ledger. The43 shipped loader already passes this field through untouched. Clockwork44 declares it.454. **Keep the loader and engine in step.** The engine accepts `'U'`/`'W'`46 shorthand steps, but `mods.js` rejects them. Accept both in both places,47 or neither.485. **Reconcile the OpenSpec change.**49 `openspec/changes/add-rule-driven-automata/specs/.../spec.md` still50 describes a different model (one automaton, step IDs, property51 additions and removals) from the code (many automata, move and wait52 steps). Clockwork follows the code.536. **Make the solver usable on automata levels.** At the default `--max 300000`,54 `mouse-hole` and `escapement` return `LIMIT`. They need 3 to 4 million55 expansions and about 3 minutes each, mostly in the BYPASS pass. A compact56 state key (only moved or changed objects, plus phase) and a per-level57 `solverMax` hint would help.587. **Ship `level.pairs`** (the `OPEN`/`SHUT` contract below), or tell us it's59 out of scope so Clockwork can retire it.608. **Feature flags instead of silent ignore.** `automata` shipped without a61 schema bump. An older build ignores the field, so the level loads with its62 property word as a noun. Proposal: `manifest.features: ['automata']`63 (later `'pairs'`). A loader that doesn't know a listed feature rejects the64 pack with the player line "This pack needs a newer Miau." `manifest.engine`65 stays `1`.6667**Complete proof example.** This is Clockwork level 1 exactly as the pack68builds it. The shipped `tools/solve.mjs` solves it in 11 steps69(`DRRRRRRRRRR`).7071```js72const L = { action: 'move', dx: -1, dy: 0 };73const R = { action: 'move', dx: 1, dy: 0 };74const w = (text, x, y) => ({ kind: 'word', text, x, y });75const o = (noun, x, y) => ({ kind: 'object', noun, x, y });76const row = (noun, y, xs) => xs.map(x => o(noun, x, y));77const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];7879module.exports = {80 manifest: { id: 'clockwork-proof', name: 'Clockwork proof', version: '1.0.0', engine: 1 },81 palette: { MIAU: { glyph: '🐈', faces: 'left' }, MOUSE: { glyph: '🐁', faces: 'left' }, WALL: '🧱', BELL: '🔔' },82 levels: [{83 id: 'mouse-keeps-time',84 title: 'The mouse keeps time',85 hint: 'The mouse takes one step for every step you take.',86 width: 11,87 height: 7,88 automata: [{ id: 'mouse-shuttle', subject: 'MOUSE', property: 'TICK', phase: 0, pattern: [L, L, L, L, L, R, R, R, R, R] }],89 objects: [90 w('MIAU', 0, 0), w('IS', 1, 0), w('YOU', 2, 0),91 w('WALL', 4, 0), w('IS', 5, 0), w('STOP', 6, 0),92 w('MOUSE', 8, 0), w('IS', 9, 0), w('TICK', 10, 0),93 ...row('WALL', 2, all),94 o('WALL', 0, 3), w('BELL', 1, 3), w('IS', 2, 3), w('WIN', 5, 3), o('MOUSE', 9, 3), o('WALL', 10, 3),95 ...row('WALL', 4, all),96 o('MIAU', 0, 5),97 o('BELL', 10, 6),98 ],99 }],100};101```102103Expected result: the mouse walks left and pushes `WIN` into `BELL IS _` on104beat 5. On its next left run it waits against the full sentence. The cat105reaches the bell in 11 steps. If `TICK` weren't declared, `MOUSE IS TICK`106would turn the mouse into a `TICK` thing at reset.107108## TICK: the automata contract Clockwork relies on109110This section documents the shipped behaviour Clockwork depends on. A change to111any line here would break a level.112113**Declaration** (per level, data only):114115```js116automata: [{117 id: 'mouse-shuttle', // stable, unique in the level; phase is keyed by it118 subject: 'MOUSE', // optional; omit it and every noun that IS TICK follows the pattern119 property: 'TICK', // custom property word, never a core word120 phase: 0, // optional starting index121 pattern: [ // 1 to 32 steps122 { action: 'move', dx: -1, dy: 0 },123 { action: 'wait' },124 ],125}]126```127128**Semantics:**129130- **Parsing.** Every declared `property` joins the level's property set, so131 `MOUSE IS TICK` is a property rule and never a transformation.132- **Activation.** A record is active when some rule `X IS <property>` holds,133 where `X` is its `subject` if it has one. Its actors are all things that134 have the property and match the subject.135- **Beat.** Each accepted player turn is one beat, whether it's a move or a136 `wait()`. A blocked move isn't a turn, so no beat happens.137- **Phase.** Each record has its own phase. It advances by one only on a beat138 where the record is active and has at least one actor, so a paused clock139 resumes where it stopped. Records with different subjects but the same140 word keep separate phases.141- **Action.** A move uses the player's push planner: it pushes `PUSH` things142 and word tiles, `STOP` blocks it, and it walks onto anything else (including143 `YOU`). A blocked actor waits in place and the phase still advances. Actors144 move front-first along the step direction, then by id.145- **Turn order.** Player move, then parse and transform, then all automata146 (records by id), then parse and transform, then one contact pass (`SINK`,147 then `DEFEAT`, then `WIN`).148- **Undo, replay, and solver.** Phase is in `state.automata`, so snapshots,149 `restore()`, and the solver's state key include it.150151**Consequences for level design** (all found while building Clockwork):152153- **Contacts run once, at the end of the turn.** A `YOU` that is also `TICK`154 moves twice per turn and never stops on the cell in between, so it can155 step over a `DEFEAT` line (`double-time`). A transform caused by an156 automaton's push lands before contacts (`escapement`: the cat steps onto a157 cog on the very turn the cogs become bells).158- **A moving `DEFEAT` or `STOP` can't guard a corridor.** The player can swap159 cells with it head-on, or let it walk onto them. A timing puzzle built on a160 patrolling hazard always has a timing bypass. Clockwork uses static hazards161 and moving helpers instead.162- **Rules move with the word.** With no `subject`, pushing `TICK` from163 `MOUSE IS TICK` to `BELL IS TICK` hands over the pattern and its phase164 (`ring-for-service`).165166## OPEN and SHUT: proposed `level.pairs`167168This is the next extension in the same declarative style. It adds no code169hooks, no new state, and no new turn phase.170171```js172pairs: [{ words: ['OPEN', 'SHUT'], effect: 'destroy-both', enter: true }]173```174175| Field | Rule |176| --- | --- |177| `words` | Two distinct custom property words. They join the level's property set. Neither word can be a core word, an automaton property, or a noun used by the level's objects. |178| `effect` | Only `'destroy-both'` in this slice. |179| `enter` | Optional, default `false`. When `true`, a mover with one word may enter a cell held by a thing with the other word even if that thing is `STOP` or `PUSH`. The occupant is neither pushed nor blocking. This is what lets a key go into a locked door. |180| Limit | At most 4 pairs per level. |181182**Resolution.** At the start of every contact pass, before `SINK`, each cell183is checked for each pair in declaration order. Things with the first word and184things with the second word are matched one for one, in entity id order. A185thing never pairs with itself. Every matched thing is destroyed, and the186settled event's `destroyed` entries carry `cause: 'open'` (the first word,187lowercased). Unmatched extras survive.188189**Ordering.** Pairs resolve after automata, because they're part of the single190contact pass. An `OPEN` mouse that walks into a `SHUT` door on its beat191opens it that same turn (`let-the-mouse-in`).192193**Undo, replay, and solver.** Pairs are stateless. Destruction is part of the194object list, so snapshots, `restore()`, and the solver work unchanged. The195prototype check replays `key-and-door` twice with identical states and events,196and Undo of the opening turn restores both the key and the door.197198**Validation and sharing.** Pairs are plain data, so a shared link carries199them the same way it carries automata. `mods.js` validates the fields above200with player-readable messages.201202**Presentation.** Design needs a destruction cue for `cause: 'open'`. It's203different from sink and defeat: a click, then both sprites drop away. The204glossary (ask 3) explains both tiles.205206## Security and determinism207208- Automata and pairs are data. They run inside the host's own turn code, so209 they add no execution surface, and a shared link stays data-only.210- Everything is ordered by position projection and entity id. There's no211 randomness, no wall clock, and no reliance on object key order.212- Limits: 32 automata of 32 steps, 4 pairs, and one beat per turn, so there's213 no cascade to cap.214215## Rejected: JS handlers216217The first draft let a pack register `properties: { MOVE: { phase, run(view, self) } }`218with pure handlers that returned queued commands (`move`, `turn`, `destroy`)219and an `admits(view, mover, blocker)` predicate. It worked in a prototype220(kept at `/tmp/clockwork-proto/attic/engine-handlers.js`, about 270 changed221lines), including rollback of a throwing handler. It was dropped for four222reasons:2232241. `loadMod` clones the export with `structuredClone`, which throws225 `run(){} could not be cloned.` on any function.2262. The pack library, URL links, and exports store data only, so a pack with227 handlers can't be persisted or shared without sending code.2283. Handlers needed engine-owned facing state (`dir`) that doesn't exist today.229 `reset()` silently drops unknown object fields such as `dir`.2304. Error isolation needs whole-turn rollback and a fault channel through every231 caller. Data can't throw.232233## Appendix A: prototype234235Location: `/tmp/clockwork-proto/`.236237- `base/`: snapshot of `src/engine.js` and `src/mods.js` (09-23, with the238 automata slice).239- `src/`: the snapshot plus the `pairs` patch. Apply it with240 `python3 patch-engine.py && python3 patch-mods.py`, which re-applies onto a241 fresh `base/`.242- `tools/solve.mjs`: a copy of the repo solver that imports `../src`.243- `verify.mjs`: the same BFS with an extra **HANDS-OFF** flag (won without the244 player pushing a word), usable against the shipped engine (`--engine real`)245 or the prototype (`--engine proto`).246- `clockwork-pairs.cjs`: the three draft `OPEN`/`SHUT` levels.247- `checks.mjs`: the replay, Undo, and noun-trap evidence quoted above.248- `replay.mjs`: prints the board after a move string.249250Engine diff: +55 and -2 lines. Loader diff: +40 lines.251252### Engine diff (relative to `src/engine.js`)253254```diff255--- a/src/engine.js256+++ b/src/engine.js257@@ -6,6 +6,9 @@258 const key = (x, y) => `${x},${y}`;259 const directions = [[1, 0], [0, 1]];260 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;261+/** Clockwork proposal: declarative paired contact effects. */262+export const PAIR_EFFECTS = Object.freeze(['destroy-both']);263+export const MAX_LEVEL_PAIRS = 4;264 265 function validateLevel(level) {266 if (!level || typeof level !== 'object') throw new TypeError('level must be an object');267@@ -50,6 +53,22 @@268 if (system.phase !== undefined && (!Number.isInteger(system.phase) || system.phase < 0)) throw new TypeError(`${context}: automata ${system.id} phase must be a non-negative integer`);269 });270 }271+ if (level.pairs !== undefined) {272+ if (!Array.isArray(level.pairs) || level.pairs.length > MAX_LEVEL_PAIRS) throw new TypeError(`${context}: pairs must be an array of at most ${MAX_LEVEL_PAIRS}`);273+ const pairWords = new Set();274+ level.pairs.forEach((pair, i) => {275+ if (!pair || typeof pair !== 'object' || !Array.isArray(pair.words) || pair.words.length !== 2) throw new TypeError(`${context}: pairs[${i}] needs two property words`);276+ for (const word of pair.words) {277+ const upper = typeof word === 'string' ? word.toUpperCase() : '';278+ if (!TOKEN_RE.test(upper) || ['IS', 'AND', ...CORE_PROPERTIES].includes(upper) || pairWords.has(upper)) throw new TypeError(`${context}: pairs[${i}] words must be distinct custom properties`);279+ if ((level.automata || []).some(system => String(system.property).toUpperCase() === upper)) throw new TypeError(`${context}: pairs[${i}] word ${upper} is already an automaton property`);280+ if (level.objects.some(o => (o.kind || (o.text != null ? 'word' : 'object')) === 'object' && String(o.noun).toUpperCase() === upper)) throw new TypeError(`${context}: pairs[${i}] word ${upper} is also used as a noun`);281+ pairWords.add(upper);282+ }283+ if (!PAIR_EFFECTS.includes(pair.effect)) throw new TypeError(`${context}: pairs[${i}] effect must be ${PAIR_EFFECTS.join(' or ')}`);284+ if (pair.enter !== undefined && typeof pair.enter !== 'boolean') throw new TypeError(`${context}: pairs[${i}] enter must be a boolean`);285+ });286+ }287 return clone(level);288 }289 290@@ -71,7 +90,8 @@291 return { ...action };292 }),293 }));294- const dynamicProperties = new Set(automataDefs.map(system => system.property));295+ const pairDefs = (original.pairs || []).map(pair => ({ words: pair.words.map(word => word.toUpperCase()), effect: pair.effect, enter: pair.enter === true }));296+ const dynamicProperties = new Set([...automataDefs.map(system => system.property), ...pairDefs.flatMap(pair => pair.words)]);297 const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);298 let state;299 let history = [];300@@ -179,11 +199,43 @@301 for (const r of state.rules) if (!semanticProperties.has(r.predicate) && r.subject !== r.predicate && !changes.has(r.subject)) changes.set(r.subject, r.predicate);302 for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);303 }304+ // Pairs resolve before SINK, DEFEAT, and WIN: in each cell, things with the305+ // first word and things with the second word destroy each other one for one,306+ // matched in entity id order. A thing never pairs with itself.307+ function resolvePairs() {308+ const causes = new Map();309+ if (!pairDefs.length) return causes;310+ const byId = (a, b) => String(a.id).localeCompare(String(b.id));311+ for (const pair of pairDefs) {312+ const cells = new Map();313+ for (const o of state.objects) { const k = key(o.x, o.y); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(o); }314+ const removed = new Set();315+ for (const occupants of cells.values()) {316+ if (occupants.length < 2) continue;317+ const firsts = occupants.filter(o => properties(o).has(pair.words[0])).sort(byId);318+ const seconds = occupants.filter(o => properties(o).has(pair.words[1])).sort(byId);319+ for (const a of firsts) {320+ if (removed.has(a.id)) continue;321+ const b = seconds.find(candidate => candidate.id !== a.id && !removed.has(candidate.id));322+ if (!b) continue;323+ for (const o of [a, b]) { removed.add(o.id); causes.set(o.id, { cause: pair.words[0].toLowerCase(), at: { x: o.x, y: o.y }, noun: o.noun, text: o.text }); }324+ }325+ }326+ if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));327+ }328+ return causes;329+ }330+ // A mover may enter a cell held by its pair partner even if that partner is STOP or PUSH.331+ function enters(mover, blocker) {332+ if (!pairDefs.length) return false;333+ const a = properties(mover), b = properties(blocker);334+ return pairDefs.some(pair => pair.enter && ((a.has(pair.words[0]) && b.has(pair.words[1])) || (a.has(pair.words[1]) && b.has(pair.words[0]))));335+ }336 function resolveContacts(evaluateWin = true) {337+ const causes = resolvePairs();338 const cells = new Map();339 for (const o of state.objects) { const k = key(o.x, o.y); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(o); }340 const removed = new Set();341- const causes = new Map();342 for (const occupants of cells.values()) {343 // A single entity can carry both YOU and WIN after a rule changes. It344 // wins without needing a second occupant in its cell.345@@ -213,6 +265,7 @@346 const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));347 for (const other of collisions) {348 const traits = properties(other);349+ if (enters(o, other)) continue;350 if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };351 if (traits.has('PUSH')) {352 const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);353```354355### Loader diff (relative to `src/mods.js`)356357```diff358--- a/src/mods.js359+++ b/src/mods.js360@@ -11,6 +11,8 @@361 export const MAX_MOD_LEVELS = 64;362 export const MAX_LEVEL_OBJECTS = 4096;363 export const MAX_LEVEL_AUTOMATA = 32;364+export const MAX_LEVEL_PAIRS = 4;365+const PROPERTY_ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat']);366 export const MAX_AUTOMATON_PATTERN = 32;367 368 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;369@@ -160,6 +162,43 @@370 }371 });372 }373+ }374+}375+376+// Clockwork proposal: level.pairs and the pack-level properties glossary.377+function validatePairs(level, path, errors) {378+ if (level.pairs === undefined) return;379+ if (!Array.isArray(level.pairs) || level.pairs.length > MAX_LEVEL_PAIRS) { errors.push(issue(`${path}.pairs`, `must be an array of at most ${MAX_LEVEL_PAIRS} pairs`)); return; }380+ const automatonWords = new Set((Array.isArray(level.automata) ? level.automata : []).map(a => String(a?.property).toUpperCase()));381+ const nouns = new Set((Array.isArray(level.objects) ? level.objects : []).filter(o => o?.kind !== 'word' && o?.text === undefined).map(o => String(o?.noun).toUpperCase()));382+ const seen = new Set();383+ level.pairs.forEach((pair, i) => {384+ const pairPath = `${path}.pairs[${i}]`;385+ if (!isRecord(pair)) { errors.push(issue(pairPath, 'must be an object')); return; }386+ for (const key of Object.keys(pair)) if (!['words', 'effect', 'enter'].includes(key)) errors.push(issue(`${pairPath}.${key}`, 'unsupported field'));387+ if (!Array.isArray(pair.words) || pair.words.length !== 2) errors.push(issue(`${pairPath}.words`, 'must name exactly two property words'));388+ else pair.words.forEach((word, j) => {389+ const upper = typeof word === 'string' ? word.toUpperCase() : word;390+ if (!token(upper) || RESERVED.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, 'must be a new uppercase property word'));391+ else if (seen.has(upper) || automatonWords.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is already declared in this level`));392+ else if (nouns.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is also used as a noun in this level`));393+ seen.add(upper);394+ });395+ if (pair.effect !== 'destroy-both') errors.push(issue(`${pairPath}.effect`, "must be 'destroy-both'"));396+ if (pair.enter !== undefined && typeof pair.enter !== 'boolean') errors.push(issue(`${pairPath}.enter`, 'must be true or false'));397+ });398+}399+400+function validateGlossary(mod, errors) {401+ if (mod.properties === undefined) return;402+ if (!isRecord(mod.properties)) { errors.push(issue('properties', 'must map property words to { accent, describe }')); return; }403+ for (const [word, entry] of Object.entries(mod.properties)) {404+ const path = `properties.${word}`;405+ if (!token(word) || RESERVED.has(word)) errors.push(issue(path, 'key must be a new uppercase property word'));406+ if (!isRecord(entry)) { errors.push(issue(path, 'must be { accent, describe }')); continue; }407+ for (const key of Object.keys(entry)) if (!['accent', 'describe'].includes(key)) errors.push(issue(`${path}.${key}`, 'unsupported field'));408+ if (!PROPERTY_ACCENTS.has(entry.accent)) errors.push(issue(`${path}.accent`, `must be one of ${[...PROPERTY_ACCENTS].join(', ')}`));409+ if (typeof entry.describe !== 'string' || entry.describe.trim() === '' || entry.describe.length > 80) errors.push(issue(`${path}.describe`, 'must be one sentence of at most 80 characters'));410 }411 }412 413@@ -182,6 +221,7 @@414 const levelIds = new Set();415 mod.levels.forEach((level, index) => {416 validateLevel(level, index, errors);417+ if (isRecord(level)) validatePairs(level, `levels[${index}]`, errors);418 if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {419 if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));420 else levelIds.add(level.id);421@@ -189,6 +229,7 @@422 });423 }424 validatePalette(mod.palette, errors);425+ validateGlossary(mod, errors);426 if (mod.vocabulary !== undefined) {427 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));428 else mod.vocabulary.forEach((word, index) => {429@@ -208,6 +249,7 @@430 ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),431 ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),432 })) } : {}),433+ ...(Array.isArray(level.pairs) ? { pairs: level.pairs.map(pair => ({ ...pair, words: pair.words.map(word => word.toUpperCase()) })) } : {}),434 objects: level.objects.map(object => ({435 ...object,436 kind: object.kind || (object.text !== undefined ? 'word' : 'object'),437```438439## Appendix B: draft pair levels (prototype solver)440441| id | title | steps | flags | idea |442| --- | --- | --- | --- | --- |443| `key-and-door` | Every door has a key | 17 | none | Build `KEY IS PUSH AND OPEN`, then push the key into the locked door. |444| `any-wall-will-do` | Any wall will do | 32 | none | No door exists. `WALL IS STOP AND SHUT` turns every wall into a lock. Aim the one key at the single-thickness side of the bell's box. |445| `let-the-mouse-in` | Let the mouse in | 13 | none | The cat is sealed in. `MOUSE IS OPEN` lets the pacing mouse open the pen door from outside. |446447Command: `node /tmp/clockwork-proto/verify.mjs /tmp/clockwork-proto/clockwork-pairs.cjs --engine proto --max 4000000`.448On the shipped engine the same file loads without an error, and every level is449broken. `SHUT` and `OPEN` become nouns, and doors turn into `SHUT` things.