Context
The engine is the authoritative simulator, but its direct level validator and
the CommonJS validator currently perform overlapping work with different
limits and error shapes. The engine accepts a level without a pack identity and
accepts U/D/L/R/W automaton steps. mods.js requires stable pack level
IDs, has bounded pack limits, and currently expects object-shaped automaton
steps. share.js repeats normalization before invoking the mod validator.
The project already has stable rules and sharing contracts. This change makes the data boundary explicit so gates, switches, FRAIL tiles, and future automata metadata have one place to enter the host without forcing the simulator to understand pack-service diagnostics.
Goals / Non-Goals
Goals:
- Establish a pure normalization result that does not mutate caller data, execute code, access the catalog, or depend on DOM state.
- Validate shared structural fields once, including coordinates, object kind, token casing, object IDs, automata IDs, pattern actions, and supported scalar metadata.
- Keep context policy explicit: standalone engine levels may omit pack-only identity fields, while pack validation still requires stable level IDs and applies pack count and size limits.
- Preserve actionable source paths and map one canonical issue code into the engine's thrown errors and loader/share diagnostics.
- Make canonical data deterministic for export, share encoding, catalog registration, solver input, and snapshot initialization.
Non-Goals:
- Adding gates, switches, FRAIL behavior, new rule vocabulary, or mechanic lifecycle state.
- Changing current movement, rule precedence, automaton ordering, or solver semantics.
- Sandboxing trusted local CommonJS or allowing remote package resolution.
- Removing the legacy shorthand in this change.
Decisions
One canonical normalization pass
Add a data-only level boundary with an explicit profile for standalone engine
levels and pack levels. It clones input first, validates the shared structural
contract, then returns canonical data. Canonical objects always have an
explicit kind; word text, object noun, automaton subject and property
tokens are uppercase; and automaton patterns contain { action: 'move', dx, dy } or { action: 'wait' } records. Optional metadata is preserved only
when it is part of the documented level schema.
Pack-only rules such as a required lowercase level ID, pack level count, and pack source limits remain profile checks around the shared boundary. They may reject a canonical level, but they may not normalize it differently.
Legacy shorthand is input-only
For the current compatibility window, the boundary accepts an omitted object
kind (inferred from text), case-insensitive word and noun tokens, and the
automaton strings U, D, L, R, and W. It materializes these into the
canonical explicit shape before any caller sees the result. Unknown strings,
mixed direction vectors, and unsupported action fields remain validation
errors. New authored content and CommonJS exports should use canonical object
steps; a later compatibility change may remove shorthand after a measured
warning period.
Adapters own presentation of errors
The boundary reports stable issue codes, paths, and values. createGame() maps
one issue into its existing TypeError or RangeError wording with the level
label. The loader maps issues to levels[i]... strings and ModValidationError
without executing or registering invalid data. Shared decoding maps issues to
its existing Invalid shared pack error. These adapters may add filename or
pack context but may not reimplement validation.
Round trips prove the boundary
Fixtures run the same authored level through direct engine creation, local CommonJS loading, CommonJS export, shared encoding/decoding, and engine creation from the decoded result. Tests compare canonical gameplay fields and issue codes, not incidental message punctuation. They also prove source data is not mutated and that malformed data fails before catalog installation.
Risks / Trade-offs
- A stricter shared boundary could reject a fixture that only passed through a permissive path. → Inventory current fixtures, preserve documented legacy shorthands, and add adapters before deleting old checks.
- Different pack and standalone limits could look like a validation split. → Keep shared structural checks in one module and make profile limits explicit in the public contract and tests.
- Future mechanic metadata could reintroduce ad hoc fields. → Require each new metadata family to register its schema with the same boundary before entering engine state or sharing.
Migration Plan
- Add the boundary module and fixtures without changing gameplay semantics.
- Route direct engine, CommonJS, and share paths through it, keeping their public error adapters and pack-only checks.
- Update canonical exports and shared payloads to emit explicit shapes.
- Run focused parity tests, full Node tests, the production build, world solver, and browser import/share flows.
- Record any remaining unowned metadata fields before adding a mechanic proposal that depends on them.
Open Questions
- Should future legacy shorthand produce a nonblocking authoring warning before it is removed?
- Which profile should own limits for mechanic metadata once gates and one-step tiles become authored content?
Boundary inventory and implementation evidence
| Input | Authored fields and policy | Adapter |
|---|---|---|
| Direct engine | Board size, objects, optional ID/title/hint/lesson metadata and automata; no pack ID or pack size cap | createGame() uses the standalone profile and preserves issue code/path on level-labeled errors. |
| CommonJS pack | The same level structure, plus required stable local level ID, 128×128 board, 4096 objects, 32 automata, 64-character title, and 1–64 levels per pack | loadMod() retains ModValidationError and source context; normalized levels contain explicit kinds/actions. |
| Data-only share/export | The pack profile plus the existing payload and schema revision limits | encodeSharedPack(), decodeSharedPack(), and exportCommonJS() revalidate canonical data without executing shared code. |
| Catalog and Gift views | Catalog identity, origin, palette, and preview fields are host metadata | playableLevelFromView() removes known view fields before createGame(); unknown authored fields still fail schema validation. |
| Solver | Level gameplay data and authoritative object/automaton state | checkLevel() uses the normalized engine path; no presentation or catalog metadata enters the state key. |
mechanics, object channel, and object leaves remain intentionally
unsupported until the declarative mechanic-state, gate, and FRAIL changes add
their schema and state contracts. The boundary reports them as
unsupported-field instead of losing them during normalization.