clone

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

main
miau-is-true / openspec / changes / archive / 2026-09-24-consolidate-level-schema-boundary / design.md
design.md 6.9K source

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

  1. Add the boundary module and fixtures without changing gameplay semantics.
  2. Route direct engine, CommonJS, and share paths through it, keeping their public error adapters and pack-only checks.
  3. Update canonical exports and shared payloads to emit explicit shapes.
  4. Run focused parity tests, full Node tests, the production build, world solver, and browser import/share flows.
  5. 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.