diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4701c66b..c66b8d88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,18 +14,55 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 - uses: actions/setup-node@v4.2.0 with: node-version: 20 cache: npm cache-dependency-path: package-lock.json - run: npm ci + - run: npm audit --audit-level=moderate + - run: npx playwright install --with-deps chromium - run: npm run format + - name: Check changed-line whitespace + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git diff --check "origin/${{ github.base_ref }}...HEAD" + else + git diff --check "${{ github.event.before }}...HEAD" + fi - run: git diff --exit-code - run: npm run check-undefined - run: npm run lint + - run: npm run typecheck:critical + - run: npm run release-readiness - run: npm run depcheck + - run: npm run check-mcp-clients + - run: npm run test-bench-unit - run: npm test + - name: Create localhost HTTPS cert + run: | + mkdir -p certs + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout certs/localhost-key.pem \ + -out certs/localhost.pem \ + -days 1 \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + - name: Start HTTPS server + run: | + npm run start-https > /tmp/lemmings-http.log 2>&1 & + echo $! > /tmp/lemmings-http.pid + node -e "const https=require('node:https'); const started=Date.now(); const tick=()=>{const req=https.get('https://localhost:8080/?e2e=1',{rejectUnauthorized:false},res=>{res.resume(); process.exit(0);}); req.on('error',()=>{if(Date.now()-started>30000) process.exit(1); setTimeout(tick,500);});}; tick();" + - run: npm run test-mcp-smoke + - run: npm run bench-smoke + - name: Stop HTTPS server + if: always() + run: | + if [ -f /tmp/lemmings-http.pid ]; then + kill "$(cat /tmp/lemmings-http.pid)" || true + fi - run: npm run coverage - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 93724ef1..a6482f35 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ test/processhtmlfile.test.js test/processhtmlfile.test.js playwright-report/ test-results/ +temp/ diff --git a/MCP_COMPAT_PUBLISHING/manifest.example.json b/MCP_COMPAT_PUBLISHING/manifest.example.json index 937c912f..ad80a4a4 100644 --- a/MCP_COMPAT_PUBLISHING/manifest.example.json +++ b/MCP_COMPAT_PUBLISHING/manifest.example.json @@ -16,7 +16,8 @@ "args": ["${__dirname}/server/index.js"], "env": { "LOG_LEVEL": "info", - "API_KEY": "${user_config.api_key}" + "API_KEY": "${user_config.api_key}", + "LEMMINGS_MCP_SURFACES": "game,editor,interact" } } }, diff --git a/README.md b/README.md index 3fabae57..d6cfea6c 100644 --- a/README.md +++ b/README.md @@ -151,23 +151,33 @@ Keybindings are configurable in `keybindings.json`. The in-game defaults map to ## MIDI -- Enable MIDI from the left control panel (toggle persists). - - When disabled, WebMIDI is not enabled and the MIDI router is detached. -- Use the I/O section for Input/Output, Input channel, and `MIDI reset`. - - Input channel defaults to `Omni` and can be set to a specific 1-16 channel. -- Use `reset all` to clear stored configuration and UI state. -- Base BPM is the sequencing anchor; current BPM shows `speed x base`, plus ticks per second/beat/measure. -- Global FX tab: - - Intensity and Accent adjust default velocity and density scaling. - - Positional Modifiers add X/Y mappings (with optional operators) and per-target min/max ranges. - - Global Repeat applies a beat window, max count, target, and amount to scale parameters on rapid repeats. -- Events/Triggers tabs: - - Configure each SFX event or trigger with mode (note/degree/chord), key+octave, or scale degree + octave. - - Chords support triad, seventh, sixth, ninth, power, sus2, sus4, and octave. - - Arps support up/down/updown; triggers can optionally run independent arps per source. -- ADSR tab lets you target Global, a specific SFX, or a trigger to override envelope values. -- UI state is stored in localStorage. Defaults come from `midi-mapping.json` and apply only on first run or when a value is missing. -- Full defaults and customization notes live in `midi-mapping.json` and `docs/midi-mapping.md`. +- The in-game MIDI sequencer workspace is layered over `/` with transport + setup, source browser, track routing, inspector, clip editing, and output + status regions. +- Enable MIDI from the transport strip, then choose WebMIDI input/output + devices and an input channel. Panic sends all-notes-off and clears queued + notes. +- Editable state is stored only as `lemmings.midi.project.v1`. Fresh projects + are created from the checked-in `midi-mapping.json` factory template, and + legacy MIDI storage keys are cleared on load. +- Use the source browser to search and filter SFX events, trigger types, MIDI + flags, system events, changed sources, sources available in the current + level, assigned/unassigned sources, and conflict status. +- Tracks route sources to channels and output devices with mute, solo, arm, + velocity scale, priority, and voice budget controls. +- The inspector edits direct note/degree/chord/velocity/duration/envelope + mappings, assigns clips, audits conflicts, and auditions the selected source + or clip through the selected track. +- Clips support step, chord, and arp types. Step clips expose note, velocity, + probability, hold, and tie controls, plus keyboard navigation across the grid. +- Save Template, Export, and Import move sanitized project/template JSON through + the project validator. +- Learn captures a pending MIDI note assignment for a selected direct source. + Record captures a short mocked or live MIDI phrase into consecutive clip + steps. +- Full defaults and runtime mapping notes live in `midi-mapping.json` and + `docs/midi-mapping.md`. Current UI behavior is documented in + `docs/midi-ui.md`. ### MIDI input mapping @@ -229,6 +239,7 @@ Keybindings are configurable in `keybindings.json`. The in-game defaults map to ## Docs +- Index: [docs/README.md](docs/README.md) - Usage: [docs/usage.md](docs/usage.md) - Offline tools: [docs/offline-tools.md](docs/offline-tools.md) - Exporting sprites: [docs/exporting-sprites.md](docs/exporting-sprites.md) @@ -249,7 +260,8 @@ mode. Touch input still needs polish, so please file bugs for any issues you hit ## Roadmap -See [docs/roadmap.md](docs/roadmap.md) for the consolidated roadmap and phases. +See [docs/roadmap.md](docs/roadmap.md) for the active roadmap. Completed +phases live in git history. ## Credits diff --git a/css/editor.css b/css/editor.css index e652dc19..14cb83da 100644 --- a/css/editor.css +++ b/css/editor.css @@ -111,13 +111,31 @@ body { color: #fff; } +.editor-controls button:focus-visible, +.editor-controls select:focus-visible, +.editor-controls input:focus-visible, +.tool-grid button:focus-visible, +.palette-tabs button:focus-visible, +.palette-recent button:focus-visible, +.palette-view-toggle button:focus-visible, +.palette-list button:focus-visible, +.editor-button:focus-visible, +.inspector-grid input:focus-visible, +.inspector-grid select:focus-visible, +.control-row input:focus-visible, +.control-row select:focus-visible, +.shortcut-overlay__close:focus-visible { + outline: 3px solid rgba(210, 106, 60, 0.55); + outline-offset: 2px; +} + .editor-main { display: grid; grid-template-columns: minmax(200px, 280px) 1fr minmax(220px, 320px); gap: 12px; padding: 8px 12px 12px; min-height: 0; - height: 100%; + overflow: hidden; } .editor-panel { @@ -130,6 +148,8 @@ body { flex-direction: column; gap: 10px; min-height: 0; + overflow-x: hidden; + overflow-y: auto; } .panel-title { @@ -245,6 +265,14 @@ body { image-rendering: pixelated; } +.palette-preview.pending { + background: + linear-gradient(90deg, rgba(210, 106, 60, 0.08), rgba(210, 106, 60, 0.18), rgba(210, 106, 60, 0.08)), + #fffdf9; + background-size: 180% 100%; + animation: palette-preview-pulse 900ms linear infinite; +} + .palette-preview.empty { background: repeating-linear-gradient( @@ -257,6 +285,15 @@ body { #fffdf9; } +@keyframes palette-preview-pulse { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} + .palette-label { display: block; line-height: 1.25; @@ -268,6 +305,34 @@ body { gap: 8px; } +.palette-recent { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px; + border: 1px solid var(--panel-border); + background: #fffdf9; +} + +.palette-recent[hidden] { + display: none; +} + +.palette-recent button { + border: 1px solid var(--panel-border); + background: #f6efe5; + color: var(--ink); + cursor: pointer; + font-size: 11px; + min-height: 26px; + padding: 3px 6px; +} + +.palette-recent button.active { + border-color: var(--accent); + background: #f7d6c5; +} + #editorPaletteSearch { width: 150px; min-width: 120px; @@ -377,6 +442,40 @@ body { gap: 8px; } +.compact-row { + align-items: flex-start; + gap: 6px; +} + +.inline-controls { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.inline-controls input[type="number"], +.inline-controls input[type="text"] { + min-width: 64px; + max-width: 120px; + padding: 4px 6px; + border: 1px solid var(--panel-border); + background: #fffdf9; +} + +.inline-controls .editor-button { + text-align: center; + white-space: nowrap; +} + +.inline-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--muted); +} + .selection-flags { border: 1px solid var(--panel-border); border-radius: 8px; @@ -410,6 +509,39 @@ body { gap: 8px; } +.validation-toolbar { + display: grid; + gap: 6px; +} + +.validation-toolbar .editor-button { + text-align: center; +} + +.validation-status { + border: 1px solid var(--panel-border); + background: #fffdf9; + color: var(--muted); + font-size: 12px; + line-height: 1.35; + padding: 6px 8px; +} + +.validation-status[data-status="ok"] { + border-left: 4px solid #2f7a48; + color: var(--ink); +} + +.validation-status[data-status="warnings"] { + border-left: 4px solid var(--warn); + color: var(--ink); +} + +.validation-status[data-status="unavailable"] { + border-left: 4px solid var(--error); + color: var(--ink); +} + .issue-item { padding: 8px; border: 1px solid var(--panel-border); @@ -445,7 +577,42 @@ body { border-left: 4px solid var(--warn); } -.issue-item button { +.issue-item.has-destructive-fix { + background: #fff8ee; +} + +.issue-severity { + align-self: flex-start; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + background: #efe6d9; + color: var(--muted); +} + +.issue-item.error .issue-severity { + background: rgba(179, 54, 45, 0.12); + color: var(--error); +} + +.issue-item.warning .issue-severity { + background: rgba(199, 124, 47, 0.14); + color: var(--warn); +} + +.issue-message { + line-height: 1.35; +} + +.issue-note { + color: var(--warn); + font-size: 11px; + line-height: 1.3; +} + +.issue-action { align-self: flex-start; background: var(--accent); color: #fff; @@ -455,9 +622,53 @@ body { font-size: 11px; } +.issue-action.destructive { + background: var(--warn); + border: 1px solid #9b6326; +} + @media (max-width: 1100px) { .editor-main { grid-template-columns: 1fr; + align-content: start; + grid-auto-rows: max-content; + overflow-y: auto; + } + + .editor-canvas-panel { + min-height: min(70vh, 640px); + } +} + +@media (max-width: 640px) { + .editor-header { + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px; + } + + .editor-title { + font-size: 16px; + } + + .editor-controls { + width: 100%; + gap: 8px; + } + + .editor-controls label { + flex: 1 1 140px; + } + + .editor-controls select, + .editor-controls input, + .editor-controls button { + max-width: 100%; + } + + .editor-main { + padding: 6px; } } diff --git a/css/game.css b/css/game.css index 09266fe0..5ffd3c83 100644 --- a/css/game.css +++ b/css/game.css @@ -90,7 +90,11 @@ canvas { } .game_container.small > .arrow_l { - zoom: 0.6 + zoom: 0.6; + padding-left: 10px; + padding-right: 10px; + margin-left: -10px; + margin-right: -10px; } .arrow_r { @@ -102,7 +106,11 @@ canvas { } .game_container.small > .arrow_r { - zoom: 0.6 + zoom: 0.6; + padding-left: 10px; + padding-right: 10px; + margin-left: -10px; + margin-right: -10px; } /* style level selection dropdowns */ @@ -147,414 +155,700 @@ canvas { font-size: 10px; } -.control-panel { - position: absolute; - top: 8px; - width: 240px; - max-height: calc(100vh - 16px); - padding: 10px 12px; - color: #e7e7e7; - background: rgba(10, 12, 18, 0.85); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); - overflow-x: hidden; - overflow-y: auto; - z-index: 6; - box-sizing: border-box; +#levelSelects, +#levelName, +#levelPrevButton, +#levelNextButton { + position: relative; + z-index: 8; } -.control-panel.left { - left: 8px; +#levelPrevButton:focus-visible, +#levelNextButton:focus-visible { + outline: 2px solid #0f0; + outline-offset: 3px; } -.control-panel.right { - right: 8px; +.midi-sequencer { + position: fixed; + inset: 8px; + z-index: 9; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 8px; + color: #e9eef2; + font-family: Arial, Helvetica, sans-serif; + pointer-events: none; } -.control-panel * { - overflow: visible; +.midi-sequencer *, +.midi-sequencer *::before, +.midi-sequencer *::after { box-sizing: border-box; - max-width: 100%; } -.control-panel summary { - list-style: none; -} - -.control-panel summary::-webkit-details-marker { - display: none; +.midi-transport, +.midi-region { + pointer-events: auto; + background: rgba(9, 12, 16, 0.88); + border: 1px solid rgba(185, 207, 213, 0.22); + border-radius: 8px; + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(10px); } -body.midi-disabled #controlRight { - display: none; +.midi-transport { + display: grid; + grid-template-columns: auto auto minmax(120px, 1fr) minmax(120px, 1fr) 86px 72px minmax(120px, 1fr) repeat(5, auto) minmax(140px, 1.2fr); + align-items: end; + gap: 8px; + min-height: 58px; + padding: 8px; } -body.midi-disabled #controlLeft .panel-section:not(.panel-header) { +.midi-hidden-file-input { display: none; } -body.midi-disabled #controlLeft .panel-header > :not(.midi-enable-row) { - display: none; +.midi-transport__title { + align-self: center; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #d7fff5; } -body.midi-disabled #controlLeft .panel-header { - border-bottom: 0; - margin-bottom: 0; - padding-bottom: 0; +.midi-workspace-grid { + display: grid; + grid-template-columns: minmax(220px, 0.85fr) minmax(280px, 1.15fr) minmax(260px, 0.95fr); + grid-template-rows: minmax(0, 1fr) auto; + gap: 8px; + min-height: 0; } -body.midi-disabled #controlLeft .tab-panel { - display: none; +.midi-region { + min-width: 0; + min-height: 0; + padding: 10px; + overflow: hidden; } -body.midi-disabled #controlLeft { - width: auto; - height: auto; - max-height: none; - padding: 10px 12px; - min-width: 0; +.midi-region__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + margin-bottom: 8px; } -body.midi-disabled #controlLeft.control-panel.collapsed { - width: auto; - padding: 10px 12px; +.midi-region__header--compact { + min-height: 24px; + margin-bottom: 6px; } -body.portrait-small .control-panel { - max-height: 40vh; +.midi-region__header h2, +.midi-inspector-group h3 { + margin: 0; + color: #d7fff5; + letter-spacing: 0.04em; + text-transform: uppercase; } -.panel-section { - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); +.midi-region__header h2 { + font-size: 12px; } -.panel-section:last-child { - margin-bottom: 0; - padding-bottom: 0; - border-bottom: 0; +.midi-inspector-group h3 { + font-size: 11px; + color: #9fc7d1; } -.panel-title { +.midi-field { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; + gap: 6px; min-width: 0; - flex-wrap: nowrap; - font-size: 12px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #9fd6ff; - margin-bottom: 6px; + font-size: 11px; + color: rgba(233, 238, 242, 0.8); } -.panel-toggle { - cursor: pointer; +.midi-field--stacked { + flex-direction: column; + align-items: stretch; + gap: 4px; + margin-bottom: 8px; } -.panel-title-row { - display: flex; +.midi-field--toggle { align-items: center; - justify-content: flex-start; - gap: 8px; - flex: 1; - width: 100%; +} + +.midi-field--compact { min-width: 0; - flex-wrap: nowrap; } -.panel-title-text { - flex: 1 1 auto; - min-width: 6ch; +.midi-field span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - color: inherit; } -.panel-title-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 10px; - text-transform: none; - letter-spacing: 0.04em; - color: rgba(255, 255, 255, 0.7); - flex: 0 0 auto; - margin-left: auto; - white-space: nowrap; - width: auto; +.midi-sequencer input, +.midi-sequencer select, +.midi-sequencer button { + min-width: 0; + min-height: 30px; + border: 1px solid rgba(210, 228, 232, 0.26); + border-radius: 6px; + background: rgba(19, 24, 30, 0.94); + color: #f7fbfc; + font-size: 12px; + letter-spacing: 0; +} + +.midi-sequencer input, +.midi-sequencer select { + width: 100%; + padding: 4px 6px; + user-select: text; } -.control-panel .panel-title-toggle { - width: auto; +.midi-sequencer input[type="checkbox"] { + width: 16px; + min-width: 16px; + height: 16px; + min-height: 16px; + padding: 0; } -.panel-row-wide { - font-size: 9px; - line-height: 1.1; - justify-content: flex-start; - white-space: nowrap; - max-width: 100%; - overflow: hidden; +.midi-sequencer button { + padding: 4px 10px; + cursor: pointer; + color: #d7fff5; } -.panel-row-wide > span { - display: block; - width: 100%; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; +.midi-sequencer button:hover, +.midi-source-row:hover, +.midi-track-row:hover, +.midi-clip-row:hover { + border-color: rgba(134, 245, 216, 0.62); + background: rgba(28, 42, 44, 0.96); +} + +.midi-sequencer button:focus-visible, +.midi-sequencer input:focus-visible, +.midi-sequencer select:focus-visible, +.midi-source-list:focus-visible, +.midi-track-list:focus-visible, +.midi-clip-list:focus-visible, +.midi-source-row:focus-visible, +.midi-track-row:focus-visible, +.midi-clip-row:focus-visible { + outline: 2px solid rgba(134, 245, 216, 0.84); + outline-offset: 2px; } -.control-panel.collapsed { - width: 64px; - padding: 10px 6px; +.midi-status, +.midi-error { + align-self: center; + min-width: 0; + font-size: 11px; + line-height: 1.25; +} + +.midi-status { + color: rgba(233, 238, 242, 0.82); overflow: hidden; - height: auto; - max-height: none; + text-overflow: ellipsis; + white-space: nowrap; } -.control-panel.collapsed .panel-section:not(.panel-header) { +.midi-error { + grid-column: 1 / -1; display: none; + color: #ffb4a8; + line-height: 1.3; + overflow: visible; + overflow-wrap: anywhere; + white-space: normal; } -.control-panel.collapsed .panel-header { - border-bottom: 0; - margin-bottom: 0; - padding-bottom: 0; - text-align: center; +.midi-error:not(:empty) { + display: block; } -.panel-row { +.midi-filter-row, +.midi-assignment-row, +.midi-inline-fields, +.midi-button-row, +.midi-toggle-row { display: flex; align-items: center; - justify-content: space-between; gap: 8px; - font-size: 12px; - margin-bottom: 6px; min-width: 0; - flex-wrap: nowrap; } -.panel-row > span { - flex: 1; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.midi-filter-row { + margin-bottom: 8px; } -.panel-row > .range-input, -.panel-row > .input-pair { - flex: 1; - min-width: 0; +.midi-inline-fields { + margin-bottom: 8px; } -.panel-row > input, -.panel-row > select, -.panel-row > button, -.panel-row > .axis-toggle { - flex-shrink: 0; +.midi-inline-fields > .midi-field { + flex: 1 1 0; } -#midiIoSection .panel-row > span { +.midi-inline-fields > button { flex: 0 0 auto; - max-width: 45%; + align-self: end; } -#midiIoSection .panel-row > select { - flex: 1 1 0; - min-width: 0; -} - -details.panel-section > summary.panel-title { - cursor: pointer; - width: 100%; - min-width: 0; +.midi-button-row { + flex-wrap: wrap; } -.panel-tabs { - padding-bottom: 6px; +.midi-toggle-row { + flex-wrap: wrap; + font-size: 11px; } -.range-row { +.midi-toggle-row label { + display: inline-flex; align-items: center; + gap: 5px; } -.range-input { +.midi-source-list, +.midi-track-list, +.midi-clip-list { display: flex; - align-items: center; + flex-direction: column; gap: 6px; - flex: 1; - min-width: 120px; + overflow-x: hidden; + overflow-y: auto; } -.range-input input[type="range"] { - flex: 1; - min-width: 90px; +.midi-source-list { + max-height: calc(100% - 102px); } -.range-label { - min-width: 28px; - font-size: 10px; - color: rgba(255, 255, 255, 0.6); - text-align: center; +.midi-track-list { + max-height: 32%; + margin-bottom: 8px; } -.axis-toggle { +.midi-track-workspace { display: flex; - align-items: center; - gap: 6px; + flex-direction: column; + min-height: 0; + overflow: hidden; } -.axis-checkbox { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; +.midi-clip-library { + display: flex; + flex-direction: column; + min-height: 0; + margin-bottom: 8px; } -.axis-toggle select { - min-width: 48px; +.midi-clip-list { + min-height: 0; + max-height: 180px; } -.input-pair { - display: flex; - align-items: center; - gap: 6px; +.midi-source-row, +.midi-track-row, +.midi-clip-row { + display: grid; + width: 100%; + min-height: 44px; + padding: 7px 8px; + border: 1px solid rgba(210, 228, 232, 0.14); + border-radius: 6px; + background: rgba(18, 23, 28, 0.86); + color: inherit; + text-align: left; + cursor: pointer; } -.input-compact { - width: 56px; - min-width: 56px; +.midi-source-row { + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 4px 8px; } -.input-mini { - width: 44px; - min-width: 44px; +.midi-clip-row { + min-height: 40px; } -.input-align-right { - text-align: right; +.midi-source-row.is-selected, +.midi-track-row.is-selected, +.midi-clip-row.is-selected { + border-color: rgba(134, 245, 216, 0.72); + background: rgba(26, 49, 49, 0.95); } -.control-panel label { - width: 100%; +.midi-source-row.is-disabled { + opacity: 0.58; +} + +.midi-source-row.has-conflict { + border-color: rgba(255, 197, 112, 0.56); } -.control-panel input:not([type="checkbox"]):not([type="radio"]), -.control-panel select, -.control-panel button { +.midi-source-row.is-changed { + background: rgba(22, 31, 34, 0.9); +} + +.midi-source-row__label, +.midi-track-row__label, +.midi-clip-row__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 12px; - background: rgba(20, 22, 30, 0.9); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - padding: 2px 6px; - min-width: 80px; - user-select: text; + color: #f7fbfc; } -.control-panel input[type="checkbox"], -.control-panel input[type="radio"] { +.midi-source-row__meta, +.midi-track-row__meta, +.midi-clip-row__meta { + grid-column: 1 / -1; min-width: 0; - width: 14px; - height: 14px; - padding: 0; - margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: rgba(233, 238, 242, 0.62); } -.control-panel input[type="range"] { - padding: 0; - min-width: 120px; +.midi-conflict-badge { + align-self: start; + min-width: 20px; + height: 20px; + padding: 2px 5px; + border-radius: 999px; + background: rgba(255, 190, 92, 0.18); + color: #ffd79a; + font-size: 10px; + line-height: 16px; + text-align: center; } -.control-panel .input-compact { - width: 56px; - min-width: 56px; +.midi-pill { + align-self: start; + min-width: 48px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(134, 245, 216, 0.12); + color: #bffced; + font-size: 10px; + text-align: center; + text-transform: uppercase; } -.control-panel .input-mini { - width: 44px; - min-width: 44px; +.midi-pill--changed { + background: rgba(138, 171, 255, 0.16); + color: #cad6ff; } -.control-panel button { - width: 100%; - margin-top: 6px; - cursor: pointer; +.midi-selection-summary { + min-height: 54px; + margin: 8px 0; + padding: 8px; + border: 1px solid rgba(210, 228, 232, 0.14); + border-radius: 6px; + color: rgba(233, 238, 242, 0.74); + font-size: 12px; + line-height: 1.35; + overflow-y: auto; } -.control-panel .button-danger { - background: rgba(70, 20, 20, 0.9); - border-color: rgba(255, 120, 120, 0.6); - color: #ffd6d6; +.midi-conflict-summary { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 34px; + margin: 0 0 8px; + padding: 7px 8px; + border: 1px solid rgba(210, 228, 232, 0.14); + border-radius: 6px; + color: rgba(233, 238, 242, 0.68); + font-size: 11px; + line-height: 1.3; +} + +.midi-conflict-summary.has-conflict { + border-color: rgba(255, 197, 112, 0.5); + color: #ffd79a; +} + +.midi-conflict-summary__item { + min-width: 0; + overflow-wrap: anywhere; +} + +.midi-conflict-summary__item[data-severity="error"] { + color: #ffb4a8; +} + +.midi-learn-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 38px; + margin: 0 0 8px; + padding: 7px 8px; + border: 1px solid rgba(134, 245, 216, 0.18); + border-radius: 6px; + background: rgba(14, 21, 25, 0.78); } -.control-panel .button-compact { - width: 25%; - min-width: 70px; - margin-right: auto; +.midi-learn-panel.is-active { + border-color: rgba(134, 245, 216, 0.62); + background: rgba(22, 44, 43, 0.86); } -.panel-error { - color: #ff7b7b; +.midi-learn-status { + min-width: 0; + overflow-wrap: anywhere; + color: rgba(233, 238, 242, 0.72); font-size: 11px; - margin-top: 6px; + line-height: 1.25; } -.tabs { +.midi-automation-list { display: flex; + flex-direction: column; gap: 6px; - margin-bottom: 8px; + min-width: 0; } -.tab-button { - flex: 1; - padding: 4px 6px; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(28, 30, 42, 0.9); - color: #cfd8e3; - cursor: pointer; +.midi-automation-row { + display: grid; + grid-template-columns: auto minmax(72px, 1fr) minmax(48px, 0.7fr) minmax(52px, 0.7fr) minmax(52px, 0.7fr) minmax(52px, 0.7fr) minmax(52px, 0.7fr) minmax(52px, 0.7fr) auto; + gap: 6px; + align-items: end; + min-width: 0; + padding: 6px; + border: 1px solid rgba(210, 228, 232, 0.14); + border-radius: 6px; + background: rgba(18, 23, 28, 0.72); } -.tab-button.active { - background: rgba(70, 120, 180, 0.85); - color: #fff; +.midi-automation-row .midi-field { + min-width: 0; } -.tab-panel { - display: none; +.midi-inspector { + overflow-y: auto; } -.tab-panel.active { - display: block; +.midi-inspector-group { + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid rgba(210, 228, 232, 0.12); } -@media (max-width: 960px) { - .control-panel { - width: 200px; - font-size: 11px; +.midi-inspector-group:first-of-type { + padding-top: 0; + margin-top: 0; + border-top: 0; +} + +.midi-step-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + min-width: 0; +} + +.midi-step-cell { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 4px; + min-width: 0; + padding: 6px; + border: 1px solid rgba(210, 228, 232, 0.14); + border-radius: 6px; + background: rgba(18, 23, 28, 0.76); +} + +.midi-step-cell__index { + grid-column: 1 / -1; + color: rgba(233, 238, 242, 0.68); + font-size: 10px; +} + +.midi-step-cell label { + display: grid; + min-width: 0; + gap: 2px; + color: rgba(233, 238, 242, 0.68); + font-size: 10px; +} + +.midi-step-cell input[type="checkbox"] { + justify-self: start; +} + +.midi-step-cell input, +.midi-step-cell select, +.midi-step-cell button { + min-height: 26px; + padding: 2px 4px; + font-size: 11px; +} + +.midi-output-status { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr); + gap: 10px; + min-height: 54px; + max-height: 88px; + font-size: 11px; + color: rgba(233, 238, 242, 0.78); +} + +.midi-output-log { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body.midi-disabled .midi-region:not(.midi-output-status) { + opacity: 0.74; +} + +body.midi-disabled .midi-region button, +body.midi-disabled .midi-region input, +body.midi-disabled .midi-region select { + border-color: rgba(210, 228, 232, 0.16); +} + +@media (max-width: 1080px) { + .midi-transport { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .midi-transport__title, + .midi-status, + .midi-error { + grid-column: 1 / -1; + } + + .midi-workspace-grid { + grid-template-columns: minmax(190px, 0.8fr) minmax(240px, 1fr) minmax(230px, 0.9fr); + } +} + +@media (max-width: 820px) { + .midi-sequencer { + position: absolute; + inset: 6px; + bottom: auto; + height: auto; + min-height: calc(100vh - 12px); + grid-template-rows: auto auto; + align-content: start; + overflow-y: visible; + pointer-events: auto; + } + + .midi-transport, + .midi-workspace-grid { + grid-template-columns: 1fr 1fr; + } + + .midi-transport__title, + .midi-status, + .midi-error { + grid-column: 1 / -1; + } + + .midi-workspace-grid { + grid-template-rows: auto; + min-height: auto; + } + + .midi-region, + .midi-output-status { + max-height: none; + } + + .midi-track-workspace, + .midi-inspector { + overflow: visible; + } + + .midi-source-browser, + .midi-track-workspace, + .midi-inspector, + .midi-output-status { + grid-column: 1 / -1; + } + + .midi-source-list, + .midi-track-list, + .midi-clip-list { + max-height: 220px; + } + + .midi-output-log { + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + } + + .midi-step-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .midi-automation-row { + grid-template-columns: auto minmax(80px, 1fr) minmax(56px, 0.8fr) minmax(56px, 0.8fr) minmax(56px, 0.8fr) minmax(56px, 0.8fr) minmax(56px, 0.8fr) minmax(56px, 0.8fr) auto; + } + + .midi-learn-panel { + grid-template-columns: 1fr; } } -@media (max-width: 720px) { - .control-panel.left { - left: 4px; - top: auto; - bottom: 4px; - max-height: 45vh; +@media (max-width: 520px) { + .midi-transport, + .midi-workspace-grid, + .midi-output-status, + .midi-inline-fields, + .midi-assignment-row, + .midi-filter-row, + .midi-automation-row, + .midi-button-row { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .midi-step-grid { + grid-template-columns: 1fr; + } + + .midi-automation-row { + grid-template-columns: 1fr 1fr; + align-items: stretch; } - .control-panel.right { - right: 4px; - top: 4px; - max-height: 45vh; + + .midi-sequencer button, + .midi-sequencer input, + .midi-sequencer select { + min-height: 34px; } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..ecf9146a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,100 @@ +# Documentation Index + +This directory is the source of truth for project documentation. Completed +roadmap phases live in git history; active roadmap work is in +[`roadmap.md`](roadmap.md). + +## Canonical User And Developer Docs + +These files describe current behavior and should be kept in sync with code: + +- [`usage.md`](usage.md): local browser startup, editor entry point, E2E harness + entry point. +- [`TESTING.md`](TESTING.md): unit/static/benchmark/Playwright test workflow. +- [`playwright-tests.md`](playwright-tests.md): Playwright server setup, + disposable visual capture tooling, and probe usage. +- [`e2e-state.md`](e2e-state.md): `window.__E2E__` game/runtime harness API. +- [`e2e-editor-state.md`](e2e-editor-state.md): editor-specific E2E state. +- [`ci.md`](ci.md): current GitHub Actions gate order. +- [`release-readiness.md`](release-readiness.md): validated release checklist. +- [`config.md`](config.md): `config.json`, runtime profiles, and bench profiles. +- [`analytics.md`](analytics.md): local opt-in analytics controls. +- [`keybindings-design.md`](keybindings-design.md): keyboard binding format. +- [`gamepad-bindings.md`](gamepad-bindings.md): gamepad binding format. +- [`midi-ui.md`](midi-ui.md): current MIDI UI controls and persistence. +- [`midi-mapping.md`](midi-mapping.md): default MIDI mapping file reference. +- [`procgen.md`](procgen.md): current procgen runtime behavior and validation. +- [`performance-benchmarks.md`](performance-benchmarks.md): runtime benchmark + modes and scripts. +- [`offline-tools.md`](offline-tools.md): Node asset and pack tooling. +- [`exporting-sprites.md`](exporting-sprites.md): quick export/patch workflow. +- [`levelpacks.md`](levelpacks.md): level pack folder layout. +- [`replays.md`](replays.md): replay command format. +- [`architecture-internals.md`](architecture-internals.md): renderer, history, + MCP, and profile internals. + +## Level Editor Docs + +The level editor docs are canonical for the current classic-subset editor unless +the file explicitly says it is backlog: + +- [`level-editor/workflows.md`](level-editor/workflows.md) +- [`level-editor/audit.md`](level-editor/audit.md) +- [`level-editor/classic-subset-contract.md`](level-editor/classic-subset-contract.md) +- [`level-editor/design-overview.md`](level-editor/design-overview.md) +- [`level-editor/data-model.md`](level-editor/data-model.md) +- [`level-editor/ui-and-tools.md`](level-editor/ui-and-tools.md) +- [`level-editor/ui-spec.md`](level-editor/ui-spec.md) +- [`level-editor/runtime-preview.md`](level-editor/runtime-preview.md) +- [`level-editor/history.md`](level-editor/history.md) +- [`level-editor/remaining.md`](level-editor/remaining.md): out-of-scope backlog. +- [`level-editor/neolemmix-expansion.md`](level-editor/neolemmix-expansion.md): + NeoLemmix expansion backlog. + +## MCP Docs + +The current MCP entry points are: + +- [`mcp/README.md`](mcp/README.md): server usage, surfaces, tool naming, smoke + checklist. +- [`mcp/protocol-v2.md`](mcp/protocol-v2.md): current compact protocol defaults. +- [`mcp/editor-apply.md`](mcp/editor-apply.md): shipped `editor_apply` contract. +- [`mcp/call-examples.md`](mcp/call-examples.md): short-name call examples. +- [`mcp/publishing.md`](mcp/publishing.md): MCPB packaging and registry notes. +- [`mcp/protocol-mappings.json`](mcp/protocol-mappings.json): protocol mapping + metadata consumed by code/tests. +- [`mcp/client-compatibility.json`](mcp/client-compatibility.json): host + compatibility matrix. +- `mcp/config-examples/*`: checked client config snippets. + +The `mcp/lemmings-mcp-*-memresources.*` files are retained as historical design +and schema reference notes. Prefer the current README/protocol docs for shipped +behavior. + +## File Format And Asset References + +These are reference material, not product roadmap docs: + +- [`compression-format.md`](compression-format.md) +- [`level-file-format.md`](level-file-format.md) +- [`nl-file-format.md`](nl-file-format.md) +- [`nl-objects.md`](nl-objects.md) +- [`nl-skills.md`](nl-skills.md) +- [`nl-pack-toolkit.md`](nl-pack-toolkit.md) +- Every file under [`camanis/`](camanis/) +- [`reading-list/particle-handling.md`](reading-list/particle-handling.md) + +## Historical Source Notes + +These are useful implementation references, but they are not authoritative for +current JavaScript behavior: + +- Every file under [`port-info/`](port-info/) +- [`webmidi-evaluation.md`](webmidi-evaluation.md) + +## Test Status Reports + +The repo no longer keeps stale broken/fixable/incoherent test reports. Current +test status is the command output from `npm test`, Playwright commands, and CI. +[`excluded-tests.md`](excluded-tests.md) is retained only to document deliberate +manual exclusions; it currently states that none are excluded. diff --git a/docs/TESTING.md b/docs/TESTING.md index cc0d9a63..e8aa4d91 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,33 +6,219 @@ common subsets of the suite: ```bash npm test # runs all tests npm run test-core # core game logic -npm run test-bench # performance benchmarks -npm run bench-performance # standalone bench (Playwright + E2E harness) -npm run bench-history # history stress test (Playwright + E2E harness) +npm run test-bench # bench-related unit/integration tests (Mocha only) +npm run test-bench-smoke # browser benchmark smoke gate (Playwright + E2E harness) +npm run bench-smoke # fast benchmark smoke gate (short dev-loop default) +npm run bench-performance # standalone perf bench (smoke profile by default) +npm run bench-performance-smoke # explicit perf smoke profile +npm run bench-history # history stress bench (smoke profile by default) +npm run bench-history-smoke # explicit history smoke profile +npm run bench-long-session # long-session benchmark gate (smoke profile by default) +npm run bench-long-session-smoke # explicit long-session smoke profile +npm run bench-performance-soak # long perf soak run (explicit opt-in) +npm run bench-history-soak # long history soak run (explicit opt-in) +npm run bench-long-session-soak # long replay/memory/event-queue soak run npm run test-workflow # GitHub workflow helpers npm run test-tools # command line tools npm run test-offline-tools # offline asset tooling npm run test-editor # editor-related tests +npm run test:changed # infer the smallest safe Mocha subset from git changes npm run coverage-editor # 100% coverage for editor modules npm run test-mcp-smoke # MCP stdio smoke test (requires start-https) +npm run typecheck:critical # targeted checkJs guard for runtime-critical modules +npm run release-readiness # release checklist gate (strict by default) ``` Categories map to the glob patterns defined in `scripts/runTests.js`. +`npm run test:changed` resolves its comparison base in this order: explicit +`--base=`, current branch upstream, `origin/HEAD`, then known default +branch names. Add `--print-selection` (or `--dry-run`) to print the resolved +base ref, changed files, inferred categories, and Mocha args without running +guards or tests. +The maintained subset scripts (`test-core`, `test-bench-unit`, +`test-workflow`, `test-tools`, `test-offline-tools`, and `test-editor`) all go +through `scripts/runTests.js`, so they share the same runtime-global guard, +critical typecheck guard, and runtime budget reporting as `npm test`. -Tests that require significant manual setup or large downloads are documented in -[`excluded-tests.md`](excluded-tests.md). They are skipped in continuous -integration. +Tests that require significant manual setup or large downloads belong in +[`excluded-tests.md`](excluded-tests.md). No tests are currently excluded. The tests require no special environment variables. A minimal `lemmings` object is created and temporary files are written under your operating system's temp directory. +## Benchmark profiles + +Benchmark scripts default to short smoke settings so local perf checks stay +within a quick dev-loop budget. Use explicit soak mode for long runs: + +```bash +npm run bench-performance -- --soak +npm run bench-history -- --soak +npm run bench-smoke -- --soak +``` + +`test-bench` and `bench-*` intentionally have different semantics: + +- `test-bench`: runs deterministic Mocha tests under `test/*bench*.test.js`. +- `bench-*`: runs live browser benchmarks (requires local HTTPS server and + browser automation support). + +`bench-history` is the replay-invariant guardrail for compression/rewind work: + +- It runs random seek/replay probes and fails on replay-hash divergence + (`HISTORY_REQUIRE_REPLAY_PARITY`, defaults to `true`). +- It fails when bounded history retention is not enabled + (`HISTORY_REQUIRE_BOUNDED_RETENTION`, defaults to `true`). +- Non-smoke profiles also require cold compaction activity + (`HISTORY_REQUIRE_COLD_COMPACTION`, defaults to `true` for `default`/`soak`). + +`bench-hotpaths` now reports percentile and allocation diagnostics per section: + +- `avgMs`, `p50Ms`, `p95Ms`, `p99Ms`, `worstMs` +- `allocBytesAvg`, `allocBytesP95`, `allocBytesWorst` + +For render experiments, use canonical query flags in non-default runs and keep +rollback ready: + +- `offscreenPresent=true`: requests the Canvas2D staging plus `drawImage` + present-path experiment when supported. +- `workerOffscreen=true`: requests worker/offscreen path; runtime falls back + automatically when unsupported. + +Runtime diagnostics now expose capability matrix and rollout-flag snapshots +through `window.__E2E__.getDiagnostics()` / `window.__E2E__.getState()`: + +- `capabilities.webMidi`, `capabilities.offscreenCanvas`, + `capabilities.imageBitmap`, `capabilities.worker`. +- `capabilities.renderPaths` for deterministic fallback selection. Diagnostics + use `presentPathSupported`/`drawimage_present`. +- `rolloutFlags` for staged rollout / emergency rollback state. + +Rollout and rollback query toggles: + +- `rollbackAll=1`: disables all high-risk rollout flags. +- `rollbackRenderPresent=1`: disables offscreen/worker present-path experiments. +- `rollbackHistoryCodec=1`: disables cold history compression/dedupe. + +`bench-long-session` enforces thresholds for: + +- replay-hash integrity +- heap growth and heap churn proxies +- sound-event queue ratio and queue-growth bounds +- history span growth and trigger-count drift + +Release gates are defined in [`release-readiness.md`](release-readiness.md) and +validated by `npm run release-readiness`. Override strictness via +`LEMMINGS_RELEASE_READINESS_STRICT=false` when validating checklist structure +without requiring all items checked. + +## Runtime profiles + +Runtime boot/query presets use these profile IDs: + +- `classic` +- `midi` +- `editor` +- `e2e` +- `perf` + +Legacy `profile=gameplay` links are normalized to `classic`. + +## Analytics controls + +Privacy-first analytics is opt-in and local-only by default. See +[`analytics.md`](analytics.md) for consent defaults, event schema constraints, +local buffer export/import, optional managed beacon settings, and hard/runtime +kill switches. + ## npm test workflow -Run `npm run check-undefined` manually before `npm test` to verify no uninitialized references remain in the build. GitHub Actions performs the same checks on **Node 20** during the CI job after running `npm run lint`. +Run `npm run check-undefined` before `npm test` to catch low-cost JS hygiene +regressions early. GitHub Actions also runs `git diff --check` against the +changed lines after `npm run format` to catch trailing-whitespace and EOF +blank-line issues without a custom baseline. + +`npm test` now reports total runtime and supports optional guardrails for local +suite budgets: -To mirror the CI environment locally: +- `LEMMINGS_TEST_ENFORCE_BUDGET=true`: fail when runtime budget is exceeded. +- `LEMMINGS_TEST_BUDGET_MS=`: override the default 180000ms budget. +- `npm run test:budget`: convenience wrapper with enforcement enabled. + +To cover the main CI static/test gates locally: ```bash npm run lint +git diff --check origin/master...HEAD +npm run check-undefined npm test ``` + +## Playwright base URL overrides + +Playwright defaults to `https://localhost:8080`. Override the origin with +`LEMMINGS_E2E_BASE_URL` when validating another same-machine or LAN URL: + +```powershell +npm run test-e2e -- e2e/service-worker.spec.js +$env:LEMMINGS_E2E_BASE_URL = "https://127.0.0.1:8080" +npm run test-e2e -- e2e/service-worker.spec.js +Remove-Item Env:\LEMMINGS_E2E_BASE_URL +``` + +The service-worker smoke asserts same-origin scope instead of literal localhost, +so the same spec should pass for localhost and non-localhost origins served by +the configured HTTPS server. + +## Visual capture smoke + +Disposable local screenshots are documented in +[`playwright-tests.md`](playwright-tests.md). Start `npm run start-https` first +when invoking the capture CLI directly: + +```bash +npm run capture:e2e:midi +npm run capture:e2e:editor +npm run capture:e2e:procgen +npm run capture:e2e:game-hud +``` + +Output stays under ignored `temp/e2e-captures/`. + +## Milestone checkpoint evidence + +Milestone work should leave a short, reproducible evidence note before issue +closeout. Keep working artifacts under ignored `temp/` and commit only the code, +tests, and durable docs needed by the feature. + +Use this compact format for each lane or issue group: + +```text +Issues: +Commands: +Temp artifacts: +GitHub closeout: +Skipped checks: +Unrelated failures: +Follow-up risks: +``` + +The Capture matrix below is the standard milestone map for the current +editor, procgen, solver, MIDI, and validation work. Run the narrow lane checks +while a lane is in progress, then run the standard gate before final closeout. + +| Area | Issues | Capture matrix | Required checkpoint commands | Disposable evidence | +| --- | --- | --- | --- | --- | +| MIDI polish | #924-#928 | `midi-transport`, `midi-source-browser`, `midi-track-workspace`, `midi-clip-library`, `midi-inspector`, `midi-learn`, `midi-record`, `midi-output-status`; desktop/tablet/mobile when layout changes | `npm run test-e2e -- e2e/midi-ui.spec.js`; `npm run capture:e2e:midi -- --viewport=desktop --json`; `npm run capture:e2e:midi -- --viewport=tablet --json`; `npm run capture:e2e:midi -- --viewport=mobile --json` | `temp/e2e-captures/`, `temp/midi-lane-*.md` | +| Editor productization | #929-#932 | states `shell`, `canvas-palette-inspector`, `validation`, `save-import-export`, `playtest`; desktop required, tablet/mobile when controls change | `npm run test-editor`; `npm run test-e2e:harness`; `npm run capture:e2e:editor -- --viewport=desktop --json` | `temp/e2e-captures/`, `temp/editor-lane-*.md` | +| Procgen productization | #933-#938 | states `overview`, `frontier`, `newest-pieces`; desktop required for seed review, targeted mobile only for shell/layout changes | `npm run test-e2e -- e2e/procgen.spec.js`; `npm run capture:e2e:procgen -- --viewport=desktop --json`; `npm run bench-procgen-soak`; `npm run test-bench-unit` | `temp/e2e-captures/`, `temp/procgen-lane-*.md` | +| Solver platform | #939-#949 | no standalone screenshot gate until a UI/editor advisory surface changes; capture editor advisory or solver failure surfaces only when they are user-visible | `npm test`; focused `test/solver*.test.js`; `npm run test-e2e:harness` when runtime adapters or editor advisory flows change; MCP checks after solver tools change | `temp/solver-lane-*.md`, optional `temp/solver-failures/` | +| Validation and closeout | #950-#951 | capture docs and runner drift are checked by tests; do not create committed galleries or manifests | `npm run format`; `npm run check-undefined`; `npm run lint`; `npm run typecheck:critical`; `npm test`; `npm run test-bench-unit` | `temp/milestone-integration-*.md` | + +GitHub issue closeout comments should cite the commands that actually ran, any +relevant ignored artifact paths, and any skipped checks with the concrete +reason. Do not close an issue on visual captures alone when behavior changed; +pair captures with a deterministic unit, harness, or E2E check where available. +Keep capture output disposable: do not create committed galleries or manifests. +Use `npm run release-readiness` only when the release checklist or release gate +scripts change; it is not the issue closeout checklist. diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 00000000..ab391f64 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,81 @@ +# Privacy-First Analytics + +This project uses a privacy-first analytics model with an explicit opt-in +consent default and a local-only event buffer. + +## Consent Defaults + +- Default state: analytics is disabled. +- No cookies are required. +- No device fingerprinting or cross-site identity is used. +- Consent is stored locally in `localStorage` only: + - key: `lemmings.analytics.consent.v1` + - values: `granted` or `denied` + +Runtime query overrides: + +- `?analytics=1` enables consent for the session and stores `granted`. +- `?analytics=0` (or `?analytics=off`) disables consent and stores `denied`. +- `?analyticsOff=1` forces runtime disable regardless of consent. + +## Data Minimization + +Analytics events are schema-locked and low-cardinality: + +- `visitor.page_view` +- `gameplay.level_select` +- `gameplay.midi_toggle` +- `gameplay.saved_level` +- `editor.action` +- `runtime.boot_error` + +Only bounded enum/boolean/integer fields are captured. Free-form text payloads +and high-cardinality identifiers are intentionally excluded. + +## Local Ring Buffer + +Events are stored in a fixed-size in-memory ring buffer and mirrored to +`localStorage`: + +- key: `lemmings.analytics.buffer.v1` +- schema version: `1` +- explicit export/import API for local workflows: + - `window.__LEMMINGS_ANALYTICS__.exportBuffer()` + - `window.__LEMMINGS_ANALYTICS__.importBuffer(data, { replace })` + - `window.__LEMMINGS_ANALYTICS__.clearBuffer()` + +This supports development telemetry with no backend service. + +## Optional Managed Beacon Path + +Managed beacon delivery is optional and disabled by default. + +Enable only when all conditions are met: + +1. consent is granted, +2. a valid HTTPS endpoint is configured, +3. managed beacon is explicitly requested. + +Runtime controls: + +- `?analyticsBeacon=1` requests managed beacon delivery. +- `?analyticsSample=<0..1>` sets managed-beacon sampling. + +Endpoint sources: + +- runtime override (`__LEMMINGS_ANALYTICS_BEACON_ENDPOINT__`) +- `` + +## Kill Switches + +Hard-disable (deployment-wide): + +- `__LEMMINGS_ANALYTICS_HARD_DISABLED__ = true` +- or `` + +Runtime disable: + +- `__LEMMINGS_ANALYTICS_DISABLED__ = true` +- or query `?analyticsOff=1` + +Even when analytics code is present, these switches keep collection disabled. diff --git a/docs/architecture-internals.md b/docs/architecture-internals.md new file mode 100644 index 00000000..8be93fa2 --- /dev/null +++ b/docs/architecture-internals.md @@ -0,0 +1,82 @@ +# Architecture Internals + +This document is implementation-first guidance for the three subsystems that +currently carry most runtime complexity: renderer, time travel/history, and MCP. + +## Renderer internals (`js/render/*`, `js/game/GameView.js`) + +### Pipeline and ownership +- `Frame`: low-level RGBA + mask container used by decoded terrain/object frames. +- `DisplayImage`: mutable world image buffer for a layer (game or HUD). Writes + happen here (`drawFrame`, rects, overlays), not in `Stage`. +- `Stage`: presentation/compositing layer. It owns the visible canvas and copies + `DisplayImage` buffers into offscreen canvases, then onto the stage canvas. + +### Hot-path rules +- Keep rendering Canvas2D-only. +- Prefer typed-array writes (`Uint32Array`) in `DisplayImage`/`Frame` paths. +- Use dirty-region updates (`markDirtyRect` / `consumeDirtyRects`) so + offscreen `putImageData` work is scoped to changed regions. +- Avoid per-frame transient allocations: + - xBRZ/HQX resized variants are cached per frame/version/target size. + - stage context state writes (`globalAlpha`, `fillStyle`) are coalesced. + +### Safe extension points +- Add new debug/perf overlays in `Stage.drawPerfOverlay`. +- Add new sprite draw modes by extending `DisplayImage._blit` and reusing + `scaleNearest`/`scaleXbrz`/`scaleHqx` helpers. +- If you mutate a `Frame`, preserve `_version` invalidation semantics so cached + scaled variants cannot go stale. + +## Time travel internals (`js/game/HistoryStore.js`, `js/game/TimeTravelController.js`) + +### Storage model +- History is snapshot + delta based. +- Deltas are grouped into fixed-size blocks to lower metadata overhead and speed + seeks over long sessions. +- Cold blocks can be canonically encoded, deduplicated by hash, and optionally + compressed. +- Idle ranges are tokenized as no-op spans to reduce repetitive growth. + +### Determinism guardrails +- Replay integrity is verified through replay hashes over tick ranges. +- Compression and block thaw/rehydration paths must preserve delta semantics. +- Any new mutable game state must be represented in snapshot/delta extraction, + otherwise rewind/seek divergence is likely. + +### Practical change workflow +- Update `HistoryStore` extraction/apply logic first. +- Add or update `test/history-store.test.js` and + `test/time-travel-controller.test.js`. +- Validate long-run memory behavior with `npm run bench-history`. + +## MCP internals (`mcp/*`, `js/app/e2eHarness.js`) + +### Surface split +- Tool registration is split by surface modules: + - `mcp/tools/game.js` + - `mcp/tools/editor.js` + - `mcp/tools/interact.js` +- Shared session/resource/watch infrastructure remains centralized. +- Runtime routing is strict by enabled surfaces + (`LEMMINGS_MCP_SURFACES`) to prevent cross-surface leakage. + +### Contract and evolution +- Tool names are short-first; only shipped underscore names are accepted. +- Harness methods are the source of runtime behavior for tool handlers. +- When adding a tool: + 1. add harness capability, + 2. add tool schema + handler, + 3. add smoke/client tests, + 4. update docs/examples to shipped names only. + +### Stability checks +- Compatibility checks: `npm run check-mcp-clients` +- Smoke checks: `npm run test-mcp-smoke` + +## Startup profiles and mode boundaries + +Runtime profiles (`classic`, `midi`, `editor`, `e2e`, `perf`) exist to avoid +paying for subsystems that are not needed in a given mode. Legacy +`profile=gameplay` URLs normalize to `classic`. Keep new mode-sensitive +features profile-aware in `js/app/boot.js` and `js/game/GameView.js`. diff --git a/docs/broken-tests.md b/docs/broken-tests.md deleted file mode 100644 index daa82a97..00000000 --- a/docs/broken-tests.md +++ /dev/null @@ -1,93 +0,0 @@ -# Broken tests - -The following unit tests are currently failing and need additional investigation: - -- [`test/gametimer.test.js`](../test/gametimer.test.js) – the *pause/resume via visibilitychange stops ticks* and *catchupSpeedAdjust scales across repeated delays* cases do not yield the expected `speedFactor` when run with fake timers. Mocked `requestAnimationFrame` calls and changes to the timer logic were unable to match the test expectations. - - -The following additional tests failed during the agent's review and could not be fixed: - -### Core tests -- `test/action-systems.test.js` – *ActionDrowningSystem behavior - draw records death once frame >= 15* -- `test/action-systems.test.js` – *ActionExplodingSystem behavior - clears ground and exits at frame 52* -- `test/action-systems.test.js` – *ActionExplodingSystem behavior - process increments frameIndex and disables on first frame* -- `test/action-systems.test.js` – *ActionMineSystem state handling - returns SHRUG when steel or arrow under mask* -- `test/action-systems.test.js` – *ActionMineSystem state handling - increments y at frame 3 and falls without ground* -- `test/action-systems.test.js` – *ActionMineSystem state handling - clears ground and continues when unobstructed* -- `test/action-systems.test.js` – *Action Systems process() - ActionExplodingSystem clears mask and exits* -- `test/action-systems.test.js` – *Action Systems process() - ActionFryingSystem burns then exits* -- `test/action-systems.test.js` – *Action Systems process() - ActionExplodingSystem clears mask on first frame* -- `test/bench-sequence-start.test.js` – *benchSequenceStart - computes extras and starts bench with first count* -- `test/bench-tps.test.js` – *bench TPS - render shows queued frames and TPS in bench mode* -- `test/bitreader.test.js` – *BitReader - reads across byte boundary and errors at EOF* -- `test/displayimage.primitives.test.js` – *DisplayImage primitives - drawDashedRect draws dashed pattern* -- `test/displayimage.primitives.test.js` – *DisplayImage primitives - drawVerticalLine and drawHorizontalLine write pixels* -- `test/displayimage.test.js` – *DisplayImage drawing and scaling - drawVerticalLine clamps to bounds* -- `test/displayimage.test.js` – *DisplayImage drawing and scaling - blits frames with nearest scaling* -- `test/exportScripts.test.js` – *exportPanelSprite.js writes PNG* -- `test/game.test.js` – *Game - timer tick triggers logic, game over check and rendering* -- `test/gamegui.test.js` – *GameGui - setGuiDisplay attaches listeners and creates MiniMap* -- `test/gamegui.test.js` – *GameGui - render draws digits and letters when flags set* -- `test/gamegui.test.js` – *GameGui - mouse clicks adjust release rate and speed* -- `test/gamegui.test.js` – *GameGui - drawSpeedChange and drawSelection trigger drawing* -- `test/gamegui.test.js` – *GameGui - pauses and resumes with pause button* -- `test/gamegui.test.js` – *GameGui - changes speed with fast-forward buttons* -- `test/gamegui.test.js` – *GameGui - queues nuke command after confirmation* -- `test/gamegui.test.js` – *GameGui - selects skills and dispatches command* -- `test/gamegui.test.js` – *GameGui - renders minimap view* -- `test/gametimer.test.js` – *GameTimer - pause/resume via visibilitychange stops ticks* -- `test/gametimer.test.js` – *GameTimer - catchupSpeedAdjust scales across repeated delays* -- `test/gamevictory.test.js` – *Game victory condition - emits GameResult on game end* -- `test/gameview.applyquery.test.js` – *GameView.moveToLevel offsets - advances to next level within group* -- `test/gameview.helpers.test.js` – *moveToLevel transitions - advances to next game type when past last group* -- `test/gameview.test.js` – *GameView - calls updateStageSize when canvas is set* -- `test/gameview.test.js` – *GameView - suspendWithColor fades and resumes timer* -- `test/gameview.test.js` – *GameView - resetFade is called when loading a level* -- `test/keyboardshortcuts.loop.test.js` – *KeyboardShortcuts _step loop - "before each" hook for "updates view when panning"* -- `test/keyboardshortcuts.loop.test.js` – *KeyboardShortcuts _step loop - "after each" hook for "updates view when panning"* -- `test/keyboardshortcuts.test.js` – *KeyboardShortcuts - pans right when ArrowRight held* -- `test/keyboardshortcuts.test.js` – *KeyboardShortcuts - starts and stops loop when arrow key released* -- `test/keyboardshortcuts.zoomClear.test.js` – *KeyboardShortcuts zoom redraw clearing - "before each" hook for "clears both layers when zooming with keyboard"* -- `test/keyboardshortcuts.zoomClear.test.js` – *KeyboardShortcuts zoom redraw clearing - "after each" hook for "clears both layers when zooming with keyboard"* -- `test/stage.updateStageSize.test.js` – *Stage updateStageSize - initializes with HUD offset without resize* -- `test/stage.updateviewpoint.test.js` – *Stage updateViewPoint - keeps cursor position stable while zooming* -- `test/stage.updateviewpoint.test.js` – *Stage updateViewPoint - keeps level bottom glued to the HUD when zooming* -- `test/stage.updateviewpoint.test.js` – *Stage updateViewPoint - preserves world coords at multiple cursor positions* -- `test/stage.updateviewpoint.test.js` – *Stage updateViewPoint - glues bottom when view taller than world* -- `test/stage.utilities.test.js` – *Stage utilities - "before each" hook for "snapScale clamps and snaps to gcd step"* -- `test/stage.utils.test.js` – *Stage utils - clampViewPoint centers or clamps within world* - -### Tools and misc tests -- `test/tools/exportAllSprites.integration.test.js` – *exportAllSprites integration - uses default output path and writes sprites* -- `test/tools/exportAllSprites.integration.test.js` – *exportAllSprites integration - accepts custom paths* -- `test/tools/exportAllSprites.test.js` – *exportAllSprites tool - uses defaults when no arguments provided* -- `test/tools/exportAllSprites.test.js` – *exportAllSprites tool - accepts pack path and output dir arguments* -- `test/tools/exportAllSprites.test.js` – *exportAllSprites tool - exports panel letters and numbers* -- `test/tools/exportAllSprites.test.js` – *exportAllSprites tool - exports lemming sheets and ground objects* -- `test/tools/exportAllSprites.test.js` – *exportAllSprites tool - skips a ground pair when loadBinary throws* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - exports a real ground file* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - with mock NodeFileProvider - writes PNGs using a mock NodeFileProvider* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - with mock NodeFileProvider - uses config.json when run without arguments* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - with mock NodeFileProvider - fails for an out-of-range index* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - with mock NodeFileProvider - defaults to lemmings when config.json is unreadable* -- `test/tools/exportGroundImages.test.js` – *tools/exportGroundImages.js - with mock NodeFileProvider - handles missing files without output* -- `test/tools/exportScripts.test.js` – *export scripts default path - exportAllPacks.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - exportAllSprites.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - exportGroundImages.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - exportLemmingsSprites.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - exportPanelSprite.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - renderCursorSizes.js exports under ./exports* -- `test/tools/exportScripts.test.js` – *export scripts default path - scanGreenPanel.js exports under ./exports* -- `test/tools/listSprites.stdout.test.js` – *tools/listSprites.js stdout - prints sprite listing when no output file is given* -- `test/tools/listSprites.test.js` – *tools/listSprites.js - writes sprite listing to a file* -- `test/tools/listSprites.test.js` – *tools/listSprites.js - writes spriteList.txt with sprite names* -- `test/nodefileprovider.test.js` – *NodeFileProvider - _validateEntry rejects absolute or parent paths* -- `test/nodefileprovider.test.js` – *NodeFileProvider - _findZipEntry matches case-insensitively* -- `test/tools/packLevels.test.js` – *tools/packLevels.js - packs a directory of levels into a DAT file* -- `test/tools/patchSprites.test.js` – *tools/patchSprites.js - patches sprite data using PNG files* -- `test/tools/patchSprites.test.js` – *tools/patchSprites.js - patches multiple sprites and preserves palette offsets* -- `test/tools/patchSprites.test.js` – *tools/patchSprites.js (mocked PNG) - updates frames in an existing sprite sheet* -- `test/processHtmlFile.test.js` – *processHtmlFile - extracts inline event handler attributes* -- `test/squooshhqx.test.js` – *squooshhqx initSync - initializes wasm and forwards resize arguments* -- `test/tools/scanGreenPanel.test.js` – *tools/scanGreenPanel.js - marks green pixels blue* -- `test/tools/scanGreenPanel.test.js` – *tools/scanGreenPanel.js - handles missing panel sprite* diff --git a/docs/camanis/README.md b/docs/camanis/README.md new file mode 100644 index 00000000..2b81ce20 --- /dev/null +++ b/docs/camanis/README.md @@ -0,0 +1,9 @@ +# Camanis Format References + +This folder contains external Lemmings file-format reference material mirrored +or converted from Camanis/community sources. Treat these files as historical +format references, not as canonical descriptions of current project behavior. + +When current code disagrees with these notes, the repo implementation and tests +win. Keep this material intact unless a file is duplicated, malformed, or +actively misleading. diff --git a/docs/ci.md b/docs/ci.md index 36dd67ea..b81e5309 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,24 +1,34 @@ # Continuous Integration -This project uses GitHub Actions to run tests on every push and pull request targeting the `master` branch. +GitHub Actions runs on pushes and pull requests targeting `master`. -The workflow is defined in [`.github/workflows/test.yml`](../.github/workflows/test.yml). Although the `package.json` requires Node 20 or newer, the workflow installs **Node 20**: +Workflow source: [`.github/workflows/test.yml`](../.github/workflows/test.yml). +The job installs Node 20, installs Playwright Chromium, runs the static/unit +gates, starts the local HTTPS server for browser-backed checks, and uploads +coverage. -```yaml -- uses: actions/setup-node@v4 - with: - node-version: 20 -``` - -The CI job performs the following steps: +Current gate order: 1. `npm ci` -2. `npm run format` -3. `git diff --exit-code` -4. `npm run check-undefined` -5. `npm run lint` -6. `npm run depcheck` -7. `npm test` -8. `npm run coverage` +2. `npm audit --audit-level=moderate` +3. `npx playwright install --with-deps chromium` +4. `npm run format` +5. `git diff --check` against changed lines +6. `git diff --exit-code` +7. `npm run check-undefined` +8. `npm run lint` +9. `npm run typecheck:critical` +10. `npm run release-readiness` +11. `npm run depcheck` +12. `npm run check-mcp-clients` +13. `npm run test-bench-unit` +14. `npm test` +15. generate temporary localhost HTTPS certs +16. start `npm run start-https` +17. `npm run test-mcp-smoke` +18. `npm run bench-smoke` +19. stop the HTTPS server +20. `npm run coverage` -All tests must pass before code is merged. +All generated certs, server logs, coverage, and browser artifacts are CI-local +or ignored unless explicitly uploaded as artifacts. diff --git a/docs/compression-format.md b/docs/compression-format.md index f40a17cb..057bf642 100644 --- a/docs/compression-format.md +++ b/docs/compression-format.md @@ -258,6 +258,60 @@ export const patchPack = (origBuf, patches) => { * **`ldecomp` (C decoder)** — [https://www.camanis.net/lemmings/tools.php](https://www.camanis.net/lemmings/tools.php) * **Format write‑up & Compressor.cpp** — [https://www.camanis.net/lemmings/files/docs/lemmings\_dat\_file\_format.txt](https://www.camanis.net/lemmings/files/docs/lemmings_dat_file_format.txt) + +--- + +## 10 · Replay Cold-Block Binary Layout + +`HistoryStore` cold compaction now uses a binary payload (`js/game/HistoryStore.js`) +instead of JSON clone/serialize. This is an internal replay format used only for +in-memory cold blocks. + +### 10.1 Block Envelope + +* `magic` (`u32`, LE): `0x42534c48` (`HLSB`) +* `version` (`u8`): `1` +* `noOpCount` (`varuint`) +* repeated `noOpCount` times: + * `startOffset` (`varuint`) + * `length` (`varuint`) +* `deltaCount` (`varuint`) +* `deltaOffsets[deltaCount]` (`varuint[]`) +* `deltaLengths[deltaCount]` (`varuint[]`) +* `deltaPayloadBytes` (concatenated byte slices) + +The envelope may optionally be RLE-compressed by the existing cold-block +compression toggle. + +### 10.2 Delta Payload + +Each delta payload starts with: + +* `deltaCodecVersion` (`u8`): `1` +* `flags` (`u32`, LE): section bitmap + +Sections are written in flag order. Lemming mutation streams are field-packed in +typed-array style sections (contiguous per field) for: + +* `lemChanges` +* `lemAdded` +* `lemRemoved` + +Non-lemming sections use canonical tagged-value encoding with sorted object keys +to keep payload bytes deterministic for hashing/deduping. + +### 10.3 Cold-Block Dedupe Dictionary + +Cold blocks can be feature-gated with: + +* `enableColdBlockCompression` (RLE envelope compression) +* `enableColdBlockDedupe` (dictionary-style byte sharing by encoded payload) + +The dedupe key includes encoding, FNV-1a payload hash, and encoded length. +Hash collisions are handled safely by a byte-for-byte equality check before +reusing a dictionary entry, so mismatched payloads stay isolated even when +bucket keys collide. + * **NeoLemmix DAT Manager (C#)** — [https://www.neolemmix.com/old/lemtools.html](https://www.neolemmix.com/old/lemtools.html) * **Lemmix Pascal source** — [https://github.com/arjanadriaanse/lemmix](https://github.com/arjanadriaanse/lemmix) * **Community edge‑case thread** — [https://www.lemmingsforums.net/index.php?topic=6902.0](https://www.lemmingsforums.net/index.php?topic=6902.0) diff --git a/docs/config.md b/docs/config.md index a7279ab9..d929dc26 100644 --- a/docs/config.md +++ b/docs/config.md @@ -13,4 +13,28 @@ - `level.useOddTable` – Set to `true` when the pack uses an ODDTABLE resource. - `mechanics` *(optional)* – Object of gameplay flags that override or extend the defaults. -`packMechanics.js` supplies defaults like `classicBuilder` or `bomberAssist` for each pack. `ConfigReader` merges these defaults with the `mechanics` object from `config.json` so game code only needs to consult a single merged `mechanics` field. +`packMechanics.js` supplies defaults like `classicBuilder`, `bomberAssist`, `pauseGlitch`, `nukeGlitch`, and `rightClickGlitch` for each pack. `ConfigReader` merges these defaults with the `mechanics` object from `config.json` so game code only needs to consult a single merged `mechanics` field. + +## Runtime startup profiles + +The browser runtime also supports URL startup profiles: + +- `profile=classic` (default): normal gameplay startup behavior. +- `profile=editor`: boots gameplay once, then enters editor mode and loads the selected level into the editor. +- `profile=midi`: normal gameplay with MIDI UI startup defaults. +- `profile=e2e`: E2E-oriented startup defaults. +- `profile=perf`: enables perf-focused runtime defaults (`performanceAPI=true` and `perfOverlay=true`). + +Short alias: `pr=`. + +Legacy `profile=gameplay` URLs are normalized to `classic`. + +## Bench Profiles + +`scripts/bench-performance.js` supports benchmark profiles: + +- `--profile=default`: sequence benchmark with perf instrumentation. +- `--profile=stress`: high-entity stress run (`bench2` path). +- `--profile=reverse`: sustained reverse-playback stress run. + +Overrides (`--mode`, `--duration`, `--sample`, `--entrances`) still apply per run. diff --git a/docs/e2e-editor-state.md b/docs/e2e-editor-state.md index 91af51cd..c8a1d1ee 100644 --- a/docs/e2e-editor-state.md +++ b/docs/e2e-editor-state.md @@ -3,6 +3,10 @@ Editor state is returned under `window.__E2E__.getState().editor`. This document lists every field surfaced by the harness for editor mode. +Editor mutations for Playwright and MCP go through +`window.__E2E__.editorApply(ops, options)`. The operation contract is documented +in [`mcp/editor-apply.md`](mcp/editor-apply.md). + ## Top-level editor fields - `mode`: `true` when editor mode is active. - `playtest`: editor playtest toggle (from GameView). diff --git a/docs/e2e-state.md b/docs/e2e-state.md index a919df6a..12f9ac05 100644 --- a/docs/e2e-state.md +++ b/docs/e2e-state.md @@ -5,7 +5,20 @@ API at `window.__E2E__` for Playwright to read state and drive time travel. ## API - `window.__E2E__.getState()` returns a JSON-safe snapshot. +- `window.__E2E__.getDiagnostics()` returns deterministic environment diagnostics + (runtime profile, rollout/capability snapshots, feature flags, and active + cache snapshots). +- `window.__E2E__.getCanvasMetrics()` returns canvas/stage sizing diagnostics. +- `window.__E2E__.getCaptureRects(options?)` returns page-space CSS rectangles + for visual capture tooling. - `window.__E2E__.getBuffer(name)` returns one heavy buffer at a time. +- `window.__E2E__.stageWorldFromPage(point)` converts a page point to a game + world point. +- `window.__E2E__.stagePageFromWorld(point)` converts a game world point to a + page point. +- `window.__E2E__.centerStageOn(point)` centers the stage on a world point. +- `window.__E2E__.getMinimapPagePoint(options?)` returns a usable page point in + the minimap. - `window.__E2E__.step(count)` steps forward/backward (negative allowed). - `window.__E2E__.seek(tickIndex)` seeks via time travel (if available). - `window.__E2E__.pause()` / `window.__E2E__.resume()` control the game timer. @@ -17,11 +30,88 @@ API at `window.__E2E__` for Playwright to read state and drive time travel. - `window.__E2E__.startBenchSequence()` starts the sequence bench run. - `window.__E2E__.startBench(entrances)` starts a single bench run. - `window.__E2E__.stopBench()` stops bench flags. +- `window.__E2E__.getDelta(tickIndex)` and + `window.__E2E__.getDeltas(fromTick, toTick, maxTicks?)` return history deltas. - `window.__E2E__.setEditorPlaytest(enabled)` toggles editor playtest. - `window.__E2E__.getEditorHistoryEntry(index)` returns one editor history entry with full text. - `window.__E2E__.selectLemmingById(id)` selects a lemming by ID (returns `true` on success). +- `window.__E2E__.midiGetProject()` returns the current MIDI project. +- `window.__E2E__.midiGetRuntimeConfig()` returns the lowered runtime MIDI + config used by the existing MIDI router and scheduler. +- `window.__E2E__.midiDispatchProjectIntent(intent)` dispatches a MIDI project + intent. +- `window.__E2E__.midiResetProject(templateId)` resets the project from the + factory template or a saved template. +- `window.__E2E__.midiExportProject(options)` returns a sanitized MIDI project + export payload. +- `window.__E2E__.midiImportProject(payload)` imports a sanitized MIDI project + or template payload. +- `window.__E2E__.midiSaveProjectTemplate(options)` stores the current project + as a user template. +- `window.__E2E__.midiGetProjectTemplates()` returns saved MIDI templates. +- `window.__E2E__.midiGetUiMetrics()` returns MIDI UI render metrics. +- `window.__E2E__.midiStartLearn()` / `midiConfirmLearn()` / + `midiCancelLearn()` drive MIDI learn. +- `window.__E2E__.midiCaptureLearnNote(note, velocity, channel)` injects a + learned note into the active learn capture. +- `window.__E2E__.midiStartRecording()` / `midiCommitRecording()` / + `midiCancelRecording()` drive MIDI clip recording. +- `window.__E2E__.midiCaptureRecordMessage(message)` injects a MIDI message into + the active recording capture. +- `window.__E2E__.midiAudition(request)` triggers project audition through the + live MIDI router. + +## getCaptureRects(options?) + +`getCaptureRects` returns: + +```js +{ + version: 1, + rects: { + canvas: { x, y, width, height } + }, + diagnostics: { + route, + mode, + missing, + available + } +} +``` + +Runtime rectangle ids are omitted when the current route cannot provide them. +Known ids include: + +- `canvas`: `#gameCanvas` or `#editorCanvas`. +- `stageCanvas`: the Stage canvas element. +- `game`: the rendered game image rectangle. +- `gui`: the rendered HUD image rectangle. +- `minimap`: the minimap area inside the HUD. +- `editorCanvas`: the editor canvas element. +- `editorSelection`: current editor selection bounds when a selection exists. + +For world-space capture, pass a world rectangle and optional padding: + +```js +window.__E2E__.getCaptureRects({ + worldRect: { + id: 'lead-area', + rect: { x: 0, y: 0, width: 320, height: 160 }, + padding: 8 + } +}); +``` + +All returned rectangles are finite positive CSS-pixel rectangles suitable for +the Playwright visual capture helper. The helper writes disposable screenshots +under ignored `temp/e2e-captures/`; no manifests, baselines, or generated images +are checked in. + +`diagnostics.missing` explains omitted ids. Missing surfaces are expected on +routes that do not expose that surface. ## getState() structure @@ -35,7 +125,9 @@ Top-level fields: - `game`: game simulation state (null before load). - `editor`: editor state snapshot (see `docs/e2e-editor-state.md`). - `bench`: bench metrics snapshot (if available). +- `diagnostics`: runtime profile + feature flags + cache snapshot summary. - `midi`: midi enable/router summary. +- `procgen`: compact procgen debug state when the procgen controller is active. ### view - `gameType`, `levelGroupIndex`, `levelIndex`. @@ -49,6 +141,38 @@ Top-level fields: - `midiEnabled`. - `configName`, `configPath` from the active pack config. +### diagnostics +- `version`: schema version (currently `1`). +- `profile`: runtime profile (`classic`, `midi`, `editor`, `e2e`, `perf`, + etc.). Legacy `gameplay` query values are normalized to `classic`. +- `rolloutFlags`: active staged-rollout/rollback flags. +- `capabilities`: runtime/browser capability matrix for WebMIDI and render + paths. +- `featureFlags`: normalized boolean flag snapshot from `GameView`. +- `caches.fileProvider`: `memoryEntries`, `localStorageBytes`, + `indexedDbBytes` when available. +- `caches.cacheStorageKeys`: sorted Cache Storage keys (`null` in + `getState()`, populated by `getDiagnostics()`). +- `serviceWorker`: `supported`, `controlled`. +- `location`: `protocol`, `hostname`, `pathname`. + +### midi +- `enabled`: current MIDI enabled state. +- `hasRouter`: whether the runtime MIDI router is attached. +- `outputName`: selected MIDI output device name (or `null`). +- `projectId`: current MIDI project id. +- `selectedTrackId`: selected MIDI track id. +- `selectedSourceId`: selected MIDI source id. + +### procgen +- `selectedTheme`: selected style/theme for the seeded run. +- `seed`: normalized procgen seed. +- `generatedEndX`: current generated terrain extent. +- `frontier`: live rightmost viable lemming summary. +- `lookahead`: threshold/distance data used to decide when to generate. +- `recentChunks`, `recentPieces`, `recentAssists`: bounded debug lists. +- `trackingSizes`: sizes of bounded procgen tracking structures. + ### stage - `panEnabled`. - `cursor` (screen coords). diff --git a/docs/excluded-tests.md b/docs/excluded-tests.md index cad167ac..5e7fff47 100644 --- a/docs/excluded-tests.md +++ b/docs/excluded-tests.md @@ -1,3 +1,4 @@ # Excluded tests -No tests are currently excluded from CI. +No tests are currently excluded from CI. Keep this file as the only place for +deliberate manual exclusions if one is added later. diff --git a/docs/exporting-sprites.md b/docs/exporting-sprites.md index d58b78a0..2d20a672 100644 --- a/docs/exporting-sprites.md +++ b/docs/exporting-sprites.md @@ -16,7 +16,8 @@ - `npm run export-ground-images` – export ground and object images from a single ground set - `npm run export-all-sprites` – export the panel, lemmings and ground sprites for one level pack - `npm run list-sprites` – list sprite names with sizes and frame counts - - `npm run patch-sprites` – verify a directory of edited sprites (patching not yet implemented) + - `npm run patch-sprites` – replace sprites in an existing DAT archive from + edited PNG frames or sprite sheets All exported assets now reside under the `exports/` directory. diff --git a/docs/fixable-tests.md b/docs/fixable-tests.md deleted file mode 100644 index 0b5f41a7..00000000 --- a/docs/fixable-tests.md +++ /dev/null @@ -1,4 +0,0 @@ -# Fixable tests - -At this time the agent was unable to repair any failing tests. Most failures stem from missing browser APIs or complex game logic that requires extensive refactoring. - diff --git a/docs/gamepad-bindings.md b/docs/gamepad-bindings.md new file mode 100644 index 00000000..40e46bfe --- /dev/null +++ b/docs/gamepad-bindings.md @@ -0,0 +1,53 @@ +# Gamepad Bindings + +Gamepad input is action-driven, like keyboard bindings. + +- Default mappings are in `gamepadbindings.json`. +- Runtime parser/dispatcher lives in `js/input/GamepadInputController.js`. +- Gameplay bindings are consumed by `js/input/KeyboardShortcuts.js`. +- Editor bindings are consumed by `js/input/EditorKeybindings.js`. + +## Mapping Format + +```json +{ + "version": 1, + "bindings": { + "gameplay": { + "togglePause": ["button:9"], + "panLeft": ["button:14", "axis:0:-:0.35"] + }, + "editor": { + "editorToolTerrain": ["button:0"], + "editorNudgeRight": ["button:15", "axis:0:+:0.35"] + } + } +} +``` + +Binding token formats: + +- `button:[:threshold]` +- `axis::<+|->[:deadZone]` + +Examples: + +- `button:0` -> face button 0 press. +- `button:7:0.7` -> trigger press with analog threshold. +- `axis:1:+:0.35` -> axis 1 positive direction past dead-zone. + +## Remapping and Persistence + +- Bindings are composed in this order: + 1. hardcoded defaults + 2. file defaults from `gamepadbindings.json` + 3. persisted user overrides + 4. in-session overrides +- Runtime remaps can be applied via: + - `KeyboardShortcuts.setGamepadBindings(config, options)` + - `EditorKeybindings.setGamepadBindings(config, options)` +- Persisted remaps are stored in local storage key + `lem-gamepad-bindings-v1`. +- Only the persisted user-override layer is written to local storage. File + defaults can change without clobbering user overrides, and non-persisted + session remaps are discarded on reload. diff --git a/docs/incoherent-tests.md b/docs/incoherent-tests.md deleted file mode 100644 index c468b0ff..00000000 --- a/docs/incoherent-tests.md +++ /dev/null @@ -1,102 +0,0 @@ -# Incoherent tests - -These tests were removed because they are outside the core game simulation focus (browser/UI rendering or offline tooling/asset export paths), or rely on behavior that cannot be reliably exercised in the test runner. - -| Test | Reason | -| --- | --- | -| `test/bench-entrance-placement.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-measure-extras.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-no-end.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-sequence-start.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-sequence.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-speed-adjust.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-start.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/bench-tps.test.js` | Bench mode depends on RAF/visibility timing and UI scheduling, not core simulation. | -| `test/crosshaircursor.test.js` | Cursor rendering is UI-only, not core simulation. | -| `test/displayimage.primitives.test.js` | Pixel rendering utilities are view-layer concerns, not core simulation. | -| `test/displayimage.scaling.test.js` | Pixel rendering utilities are view-layer concerns, not core simulation. | -| `test/displayimage.test.js` | Pixel rendering utilities are view-layer concerns, not core simulation. | -| `test/gamedisplay.extra.test.js` | Render buffer and click-selection plumbing are display-layer concerns, not core simulation. | -| `test/gamedisplay.test.js` | Render buffer and click-selection plumbing are display-layer concerns, not core simulation. | -| `test/gamegui.behavior.test.js` | HUD/controls rendering and command wiring live in the UI layer, not core simulation. | -| `test/gamegui.drawhelpers.test.js` | HUD/controls rendering and command wiring live in the UI layer, not core simulation. | -| `test/gamegui.misc.test.js` | HUD/controls rendering and command wiring live in the UI layer, not core simulation. | -| `test/gamegui.release-rate.render.test.js` | HUD/controls rendering and command wiring live in the UI layer, not core simulation. | -| `test/gamegui.test.js` | HUD/controls rendering and command wiring live in the UI layer, not core simulation. | -| `test/gametimer.test.js` | GameTimer ties to document visibility and RAF scheduling, which is browser-specific. | -| `test/gameview.applyquery.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.canvas-reset.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.controls.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.dispose.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.enableDebug.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.frames-no-game.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.helperExtras.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.helpers.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.loadlevel-missing.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.loadlevel.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.loadReplay.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.menu-selects.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.movelevel.paths.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.onGameEnd.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.setup.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.sound.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.start-existing.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.stepSpeed.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.suspendWithColor.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/gameview.test.js` | GameView wires browser UI, canvas setup, and view lifecycle; out of scope for core simulation. | -| `test/keyboardshortcuts.branches.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.eventpaths.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.instantNuke.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.keys.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.loop.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/keyboardshortcuts.zoomClear.test.js` | Keyboard input loops rely on browser events/RAF, not core simulation. | -| `test/lemmingsnamespace.test.js` | Global window namespace attachment is browser integration, not core simulation. | -| `test/minimap.extra.test.js` | Minimap rendering is UI visualization, not core simulation. | -| `test/minimap.test.js` | Minimap rendering is UI visualization, not core simulation. | -| `test/overlay-ants.test.js` | Overlay rendering effects are UI-only, not core simulation. | -| `test/particletable.lookup.test.js` | Tests the window.atob browser branch, not core simulation. | -| `test/smoothscroller.test.js` | Scroll animation and viewport smoothing are UI-only, not core simulation. | -| `test/stage.draw.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.drawbranches.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.lifecycle.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.overlayfade.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.setGameViewPointPosition.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.updateStageSize.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.updateviewpoint.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.utilities.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stage.utils.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/stageimageprops.test.js` | Stage/camera surfaces and GUI layout depend on canvas sizing, not core simulation. | -| `test/userinput.dispose.test.js` | Pointer event translation depends on DOM/canvas coordinates, not core simulation. | -| `test/userinput.events.test.js` | Pointer event translation depends on DOM/canvas coordinates, not core simulation. | -| `test/userinput.test.js` | Pointer event translation depends on DOM/canvas coordinates, not core simulation. | -| `test/viewpoint.test.js` | Viewpoint/camera math is presentation-layer logic, not core simulation. | -| `test/exportLemmingsSprites.test.js` | Sprite export relies on full asset packs and filesystem output; outside core simulation. | -| `test/exportScripts.test.js` | Export script integration depends on asset packs and filesystem layout; outside core simulation. | -| `test/listSprites.defaultPack.test.js` | CLI sprite listing depends on pack files/config and tool output, not core simulation. | -| `test/listSprites.stdout.test.js` | CLI sprite listing depends on pack files/config and tool output, not core simulation. | -| `test/listsprites.test.js` | CLI sprite listing depends on pack files/config and tool output, not core simulation. | -| `test/patchSprites.cli.test.js` | CLI patching depends on tool entrypoints and file IO, not core simulation. | -| `test/tools/exportAllSprites.integration.test.js` | Integration export depends on asset packs and filesystem output, not core simulation. | -| `test/fileprovider.test.js` | `_hashBuffer throws when crypto unavailable` relied on mutating `node:crypto` exports, which is not supported in ESM. | -| `test/levelloader.test.js` | `uses OddTableReader when configured` tried to stub static imports that `LevelLoader` does not resolve from dependencies. | -| `test/tools/archiveDir.test.js` | Offline tooling tests depend on local archives and filesystem layout, not core simulation. | -| `test/tools/cleanExports.test.js` | Offline tooling tests depend on local exports layout, not core simulation. | -| `test/tools/exportAllPacks.test.js` | Offline tooling tests depend on asset packs and script entrypoints, not core simulation. | -| `test/tools/exportAllSprites.test.js` | Offline tooling tests depend on asset packs and script entrypoints, not core simulation. | -| `test/tools/exportGroundImages.test.js` | Offline tooling tests depend on asset packs and filesystem output, not core simulation. | -| `test/tools/exportScripts.test.js` | Offline tooling tests depend on script entrypoints and filesystem output, not core simulation. | -| `test/tools/listSprites.test.js` | Offline tooling tests depend on asset packs and script output, not core simulation. | -| `test/tools/packLevels.test.js` | Offline tooling tests depend on pack inputs/outputs, not core simulation. | -| `test/tools/patchSprites.test.js` | Offline tooling tests depend on pack inputs/outputs, not core simulation. | -| `test/tools/processHtmlFile.snippets.test.js` | Offline tooling tests are for HTML processing utilities, not core simulation. | -| `test/tools/processHtmlFile.test.js` | Offline tooling tests are for HTML processing utilities, not core simulation. | -| `test/tools/scanGreenPanel.test.js` | Offline tooling tests depend on sprite assets, not core simulation. | -| `test/archiveDir.test.js` | Offline tooling tests depend on local archives and filesystem layout, not core simulation. | -| `test/exportPanelSprite.defaultPack.test.js` | Offline tooling tests depend on asset packs and filesystem output, not core simulation. | -| `test/packLevels.test.js` | Offline tooling tests depend on pack inputs/outputs, not core simulation. | -| `test/patchSprites.coverage.test.js` | Offline tooling tests depend on pack inputs/outputs, not core simulation. | -| `test/patchsprites.test.js` | Offline tooling tests depend on pack inputs/outputs, not core simulation. | -| `test/nodefileprovider.test.js` | Node-only file provider tests are for offline tooling, not core simulation. | -| `test/vgaspecreader.test.js` | `handles run-length chunks across sections` expected decode output that does not match the current VGASpecReader behavior. | diff --git a/docs/keybindings-design.md b/docs/keybindings-design.md index 482e828d..cdfaeb0f 100644 --- a/docs/keybindings-design.md +++ b/docs/keybindings-design.md @@ -1,19 +1,19 @@ -# Keybindings Configuration Design +# Keybindings Configuration -## Overview -Introduce a JSON-driven keyboard binding system so every action handled by `KeyboardShortcuts` can be remapped without code changes. The config is loaded at runtime from `keybindings.json` and falls back to built-in defaults if the file is missing or invalid. +Keyboard bindings are JSON-driven so gameplay and editor actions can be +remapped without code changes. The current file-level defaults live in +[`../keybindings.json`](../keybindings.json); this document describes the format +and loading rules rather than duplicating the full action list. -## Goals -- Make every keyboard action configurable through JSON. -- Allow multiple bindings per action. -- Keep defaults stable and documented. -- Preserve existing behavior when no config file is present. +## Scope -## Non-goals -- UI for live rebinding in-game. -- Saving keybindings to localStorage (future work). +- Gameplay actions: pause/step/reverse, rate and speed controls, skill + selection, nuke/restart, level navigation, panning/zooming, shortcut overlay. +- Editor actions: mode toggle, tool selection, MIDI flag tool, copy/paste, + duplicate, nudge, ordering, playtest, undo/redo/delete, shortcut overlay. +- Multiple bindings per action are supported. -## JSON Schema +## JSON shape ```json { @@ -21,91 +21,41 @@ Introduce a JSON-driven keyboard binding system so every action handled by `Keyb "bindings": { "togglePause": ["Space"], "stepForward": ["BracketRight"], - "stepBackward": ["BracketLeft", "Alt+BracketRight"], - "toggleReverse": ["KeyB"], - "panLeft": ["ArrowLeft"], - "panRight": ["ArrowRight"], - "panUp": ["ArrowUp"], - "panDown": ["ArrowDown"], - "panBoost": ["ShiftLeft", "ShiftRight"], - "zoomIn": ["KeyZ"], - "zoomOut": ["KeyX"], - "zoomReset": ["KeyV"], - "releaseRateDown": ["Digit1"], - "releaseRateDownMax": ["Shift+Digit1"], - "releaseRateUp": ["Digit2"], - "releaseRateUpMax": ["Shift+Digit2"], - "selectSkillClimber": ["Digit3"], - "selectSkillFloater": ["Digit4"], - "selectSkillBomber": ["Digit5"], - "selectSkillBlocker": ["Digit6"], - "selectSkillBuilder": ["KeyQ"], - "selectSkillBasher": ["KeyW"], - "selectSkillMiner": ["KeyE"], - "selectSkillDigger": ["KeyR"], - "cycleSkillNext": ["Tab"], - "cycleSkillPrev": ["Shift+Tab"], - "applySkillToSelected": ["KeyK"], - "nuke": ["KeyT"], - "nukeInstant": ["Shift+KeyT"], - "restartLevel": ["Backspace"], - "toggleDebug": ["Backslash"], - "speedDown": ["Minus", "NumpadSubtract", "Alt+Equal", "Alt+NumpadAdd"], - "speedDownFast": ["Shift+Minus", "Shift+NumpadSubtract", "Alt+Shift+Equal", "Alt+Shift+NumpadAdd"], - "speedUp": ["Equal", "NumpadAdd"], - "speedUpFast": ["Shift+Equal", "Shift+NumpadAdd"], - "levelPrev": ["Comma"], - "levelNext": ["Period"], - "levelGroupPrev": ["Shift+Comma"], - "levelGroupNext": ["Shift+Period"], - "editorToggle": ["Shift+Backquote"], - "editorToolSelect": ["KeyS"], "editorToolTerrain": ["KeyT"], - "editorToolGadget": ["KeyG"], - "editorToolTrigger": ["KeyR"], - "editorToolEntrance": ["KeyE"], - "editorToolExit": ["KeyX"], - "editorToolSteel": ["KeyF"], - "editorToolBrush": ["KeyB"], - "editorToolEraser": ["KeyD"], - "editorCopy": ["Ctrl+KeyC"], - "editorPaste": ["Ctrl+KeyV"], - "editorDuplicate": ["Ctrl+KeyD"], - "editorNudgeLeft": ["ArrowLeft"], - "editorNudgeRight": ["ArrowRight"], - "editorNudgeUp": ["ArrowUp"], - "editorNudgeDown": ["ArrowDown"], - "editorNudgeLeftFast": ["Shift+ArrowLeft"], - "editorNudgeRightFast": ["Shift+ArrowRight"], - "editorNudgeUpFast": ["Shift+ArrowUp"], - "editorNudgeDownFast": ["Shift+ArrowDown"], - "editorSnapSelection": ["Ctrl+KeyG"], - "editorTogglePlaytest": ["KeyP"], - "editorUndo": ["KeyZ"], - "editorRedo": ["Shift+KeyZ"], - "editorDelete": ["Delete", "Backspace"] + "editorBringToFront": ["Ctrl+Shift+BracketRight"] } } ``` -## Parsing Rules -- Binding strings use `+` to join modifiers with a physical key `code` (e.g. `Shift+KeyT`). -- Modifiers are case-insensitive: `Ctrl`, `Control`, `Alt`, `Shift`, `Meta`, `Cmd`, `Command`, `Win`. -- The final token is treated as a `KeyboardEvent.code` value. -- For pure modifier keys, use `ShiftLeft`, `ShiftRight`, `ControlLeft`, etc. +## Parsing rules + +- Binding strings use `+` to join modifiers with a physical + `KeyboardEvent.code`, for example `Shift+KeyT`. +- Modifiers are case-insensitive: `Ctrl`, `Control`, `Alt`, `Shift`, `Meta`, + `Cmd`, `Command`, `Win`. +- The final token is treated as the physical key code. +- Pure modifier keys use physical codes such as `ShiftLeft`, `ShiftRight`, or + `ControlLeft`. - Invalid bindings are ignored and do not crash the loader. -## Loading Flow -- `KeyboardShortcuts` loads defaults immediately. -- It attempts to load `keybindings.json` from the project root via `FileProvider.loadString` and overrides defaults if parsing succeeds. -- If loading fails (missing file, non-browser tests), defaults remain active. +## Loading flow + +- Built-in defaults are available immediately. +- `KeyboardShortcuts` attempts to load `keybindings.json` via + `FileProvider.loadString`. +- If the file is missing, invalid, or unavailable in a test harness, built-in + defaults remain active. +- Runtime code can update bindings through `KeyboardShortcuts.keybindings` APIs. + +## Conflict resolution -## Conflict Resolution -- Multiple actions can bind to the same chord; all are dispatched in declaration order. -- If an exact modifier match exists, only those actions fire. -- If no exact match exists, Shift is treated as optional unless Ctrl/Alt/Meta are pressed. -- If Ctrl/Alt/Meta are pressed and no exact match exists, the event is ignored to preserve browser shortcuts. +- Multiple actions can bind to the same chord; actions dispatch in declaration + order. +- If an exact modifier match exists, only exact-match actions fire. +- If no exact match exists, Shift is optional unless Ctrl, Alt, or Meta are + pressed. +- If Ctrl, Alt, or Meta are pressed and no exact match exists, the event is + ignored to preserve browser shortcuts. -## Extensibility -- Future actions can be added by extending the defaults and adding handlers in `KeyboardShortcuts`. -- A UI layer can update bindings by calling `KeyboardShortcuts.keybindings.setConfig()`. +Gamepad binding format is documented separately in +[`gamepad-bindings.md`](gamepad-bindings.md). diff --git a/docs/level-editor/audit.md b/docs/level-editor/audit.md new file mode 100644 index 00000000..1cb72433 --- /dev/null +++ b/docs/level-editor/audit.md @@ -0,0 +1,111 @@ +# Level Editor Productization Audit + +Date: 2026-05-06 + +## Scope Decision + +This checkpoint productizes the editor as a classic-subset editor. It may import +and preserve `.nxlv` data that is outside the classic subset, but it does not +claim NeoLemmix feature parity. Unsupported or lossy operations must be visible +before export, quick-fix, playtest, or import acceptance can surprise a user. + +## Docs vs Code + +| Area | Documented | Current implementation | Gap | +| --- | --- | --- | --- | +| Standalone editor | `editor.html` with canvas, palette, inspector, validation, local save, import/export | Implemented through `EditorUiController`, editor session, controller, and E2E harness | Product status and warnings need to state classic-subset limits directly in the UI | +| `.nxlv` import/export | Structured editor model with comments and unknown sections preserved | Parser/writer preserves top-level unknown lines, section-local unknown lines, unknown sections, and terrain groups | Round-trip coverage must prove semantic preservation for comments, unknown sections, and grouped terrain | +| Classic `.lvl` import/export | Supported as classic workflow | Implemented via `LevelReader`, `LevelWriter`, and classic conversion helpers | Export is intentionally lossy; the UI and validation need explicit warnings before users rely on it | +| Validation | Errors/warnings with quick fixes | Implemented in `EditorValidator` and rendered in the validation panel | Destructive fixes need clear labels/metadata so users can distinguish repair from data-dropping cleanup | +| Playtest | Toggle playtest without leaving editor | Implemented through editor preview/runtime path | Capture coverage should show both edit and playtest states | +| NeoLemmix expansion | Documented as out of scope | Parser preserves some unsupported data but runtime preview remains classic | Unsupported sections/properties must be visible warnings, not silent preservation claims | + +## Implemented vs Claimed + +Implemented and suitable for productized classic-subset use: + +- Create blank levels and edit headers, skill counts, terrain, gadgets, steel, + MIDI flags, and entrance/exit gadgets. +- Select, multi-select, marquee-select, move, nudge, duplicate, copy/paste, + reorder, align/distribute, replace, randomize, and delete entries. +- Save and load editor levels from local storage. +- Export/import `.nxlv` editor levels. +- Import/export classic `.lvl` through the classic runtime representation. +- Validate common structural issues and apply quick fixes. +- Playtest the editor level through the game runtime. +- Expose editor state and operations through `window.__E2E__` for Playwright and + MCP tooling. + +Not implemented or not claimed in this checkpoint: + +- Full NeoLemmix section semantics for `$TERRAINGROUP`, `$TALISMAN`, + `$PRETEXT`, `$POSTTEXT`, multiple entrances/exits beyond classic limits, + custom trigger boxes, or style metadata. +- Classic `.lvl` preservation of editor-only terrain flags such as `ONE_WAY`, + editor-only transforms, comments, unknown sections, or grouped terrain. +- Solver-backed export blocking. +- Pack/project bundle export. + +## Workflow Coverage Map + +| Workflow | Current coverage | Required checkpoint coverage | +| --- | --- | --- | +| Create playable level | Unit and harness coverage exists for editor state, tools, entrance/exit, round-trip workflow | Keep semantic create/validate/playtest/save/export/import test green | +| Import `.nxlv` with unknown data | Parser/writer tests cover parts of preservation | Add explicit semantic round-trip for comments, unknown sections, terrain groups, and unsupported data warnings | +| Classic `.lvl` import/export | Harness coverage exports/imports a classic level | Add lossy-export contract tests and cap validation before export | +| Import failures | UI file handlers catch some read errors, but failures can still be console-oriented | Surface parse/read failures in editor status/validation UI | +| Validation quick fixes | Unit coverage exists for validation and fix callbacks | Mark destructive fixes and expose that status to UI/E2E | +| Visual states | Editor capture target exists | Split capture states for shell, palette/inspector, validation, file controls, and playtest | + +## UX Issues + +1. Classic-subset boundaries are too implicit. Users can import a richer `.nxlv` + and see a playable preview without understanding which data is preserved but + unsupported by preview/export. +2. Classic `.lvl` export is available next to `.nxlv` export, but its lossy + nature is not prominent enough. +3. Validation quick fixes can remove or clamp data. The UI needs to label those + actions as destructive when they drop unsupported properties or entries. +4. Import failures should land in the same status/validation surface as normal + editor issues, not only browser alerts or console errors. +5. Multi-selection is powerful but status text should keep reflecting batch + scope and avoid implying single-entry edits when multiple entries are active. + +## Correctness And Data-Integrity Risks + +1. Unknown `.nxlv` sections are preserved by the parser/writer, but regressions + would be easy without a focused semantic test. +2. Terrain groups are preserved in the editor model and serialized back, but the + classic preview/runtime path does not implement full group behavior. +3. Classic `.lvl` export cannot carry comments, unknown sections, terrain + groups, `ONE_WAY` terrain flags, or unsupported transforms. +4. Classic runtime caps need validation before export: title length, object + count, terrain count, steel count, level size, entrance/exit limits, and + unsupported props. +5. Object/gadget round-trip tests should compare meaningful fields, not merely + assert that import succeeds. + +## Prioritized Fixes + +P0: + +- Add visible classic-subset warnings for unknown sections, terrain groups, + unsupported NeoLemmix data, destructive fixes, and classic `.lvl` export. +- Add semantic `.nxlv` and classic lossy round-trip tests. +- Add classic export cap validation. +- Fix import error reporting so bad `.nxlv` and `.lvl` files update editor + status/validation. + +P1: + +- Expand editor E2E coverage for visible warnings, import failures, and + semantic import/export acceptance. +- Split editor capture targets by workflow state and keep outputs under + ignored `temp/`. + +P2: + +- Add a small multi-selection batch edit affordance only where the controller + already supports safe bulk operations. +- Use audit captures to decide whether palette favorites or stronger recent + item affordances are worth the next editor milestone. diff --git a/docs/level-editor/classic-subset-contract.md b/docs/level-editor/classic-subset-contract.md new file mode 100644 index 00000000..816ab871 --- /dev/null +++ b/docs/level-editor/classic-subset-contract.md @@ -0,0 +1,51 @@ +# Classic-Subset Editor Contract + +The editor is productized as a classic-subset editor in this checkpoint. It can +load richer `.nxlv` files, but the editable and playable contract is the classic +runtime subset. + +## `.nxlv` + +- `.nxlv` is the preferred editor format. +- Header fields, classic skill counts, terrain entries, gadget entries, steel + rectangles, comments, unknown lines, unknown sections, and terrain-group data + are preserved when parsing and writing the editor model. +- Preserved does not mean previewed or editable. Unsupported sections remain + data-preservation payloads until NeoLemmix expansion work implements their + runtime and UI semantics. +- Importing unsupported sections must produce a warning in the editor surface. + +## Classic `.lvl` + +- Classic `.lvl` import/export is for DOS/classic-compatible level data. +- Export to `.lvl` is lossy by design. It cannot preserve comments, unknown + sections, terrain groups, editor-only terrain flags, custom NeoLemmix + sections, or unsupported transforms. +- Classic export should run validation first and warn about classic caps before + bytes are written. +- Classic import produces a clean editor model from the runtime-readable data; + any data absent from the classic format is not recoverable. + +## Validation And Quick Fixes + +- Errors identify conditions that make a level invalid for the editor's current + export/playtest path. +- Warnings identify lossy, unsupported, or risky conditions that still allow + editing. +- Quick fixes that clamp, delete, strip, or synthesize data must be visible as + data-changing actions. They should not be described as harmless cleanup when + they can drop unsupported properties or entries. + +## NeoLemmix Data + +The following NeoLemmix-oriented data is preserved where the current parser can +round-trip it, but is not implemented as editor/runtime behavior in this pass: + +- `$TERRAINGROUP` semantics beyond preservation. +- `$TALISMAN`, `$PRETEXT`, `$POSTTEXT`, pack metadata, and unlock rules. +- Multiple entrances/exits beyond classic limits. +- Custom trigger boxes and non-classic gadget semantics. +- Advanced style metadata and non-classic skill definitions. + +Future NeoLemmix expansion should add parser/model/runtime/UI support before +removing these warnings. diff --git a/docs/level-editor/data-model.md b/docs/level-editor/data-model.md index 3e0f5647..cbbc4509 100644 --- a/docs/level-editor/data-model.md +++ b/docs/level-editor/data-model.md @@ -55,5 +55,9 @@ Editor asset metadata is loaded from classic DAT files and used for: The editor mapping converts `EditorLevel` to classic runtime data: - `STYLE` -> `graphicSet1` via `StyleRegistry` ground set. - Terrain and gadget entries -> `LevelElement` with `DrawProperties`. +- Terrain `ONE_WAY` is an editor/NXLV terrain flag and does not lower into + classic `.lvl` terrain data. Classic arrow behavior comes from one-way object + triggers, so preserving it requires explicit arrow gadgets rather than a + terrain flag round-trip. - `TIME_LIMIT` of `INFINITE` -> 6039 seconds (99:99) for classic runtime. - Steel rectangles are projected into classic `Level.steel` ranges. diff --git a/docs/level-editor/design-overview.md b/docs/level-editor/design-overview.md index 7d0fc116..e62689fe 100644 --- a/docs/level-editor/design-overview.md +++ b/docs/level-editor/design-overview.md @@ -1,8 +1,9 @@ # Level Editor Design Overview ## Goals -- Provide a fully featured in-game level editor for NeoLemmix `.nxlv`. -- Support save/load to localStorage and import/export `.nxlv` files. +- Provide a classic-subset level editor for `.nxlv` workflows. +- Support save/load to localStorage plus import/export for `.nxlv` and + classic `.lvl` files. - Offer comprehensive tools for terrain, gadgets, triggers, entrances, exits, steel, and selection edits. - Keep editor logic deterministic and testable (100% coverage in `js/editor/**`). @@ -38,7 +39,8 @@ - LocalStorage entries: - `lemmings.editor.levels` (index) - `lemmings.editor.level.` (NXLV text) -- Import/Export uses file picker + Blob download. +- Import/Export uses file picker + Blob download for text `.nxlv` and binary + classic `.lvl` payloads. ## Testing - All `js/editor/**` logic is covered at 100% via `npm run coverage-editor`. diff --git a/docs/level-editor/history.md b/docs/level-editor/history.md index 0a43081a..797ffba9 100644 --- a/docs/level-editor/history.md +++ b/docs/level-editor/history.md @@ -23,8 +23,14 @@ ## Limits and coalescing -- History keeps a configurable number of snapshots; the editor UI sets this to a very large cap for full-session history. -- Drag operations coalesce into a single snapshot when the drag completes. +- History keeps configurable entry and byte limits. The editor UI uses bounded + defaults (`maxEntries` and `maxBytes`) so long sessions cannot grow without a + retention cap. +- Drag and brush operations commit one snapshot when the pointer interaction + completes. Programmatic batch edits can use explicit transactions to produce + one undo step. +- When configured, adjacent snapshots with the same label can coalesce within a + short time window. History exposes `getStats()` for UI/debug telemetry. ## API @@ -32,3 +38,5 @@ - `undo()` / `redo()` - `canUndo()` / `canRedo()` - `clear()` +- `getStats()` +- `beginTransaction(label)` / `endTransaction(label)` / `cancelTransaction()` diff --git a/docs/level-editor/neolemmix-expansion.md b/docs/level-editor/neolemmix-expansion.md index a4c4af05..b7af7187 100644 --- a/docs/level-editor/neolemmix-expansion.md +++ b/docs/level-editor/neolemmix-expansion.md @@ -34,8 +34,42 @@ Status: no NeoLemmix expansion features are implemented yet; this is a backlog. - Custom trigger areas and rotation modes not supported by classic renderer. - Gimmicks (zombies, water, clock terrain, etc.) need engine support to preview. -## Suggested phasing -1. Parser/writer expansion for the new sections. -2. Editor data model additions + UI panels. -3. Validation + pack metadata tooling. -4. Runtime preview parity work. +## Phased implementation plan + +1. Parser and writer preservation: + - Parse and round-trip `$LEMMING`, `$TALISMAN`, `$PRETEXT`, `$POSTTEXT`, + `$TERRAINGROUP`, expanded `$GADGET` fields, `levels.nxmi`, and + `info.nxmi`. + - Acceptance: unknown values survive save/export, unsupported sections are + visible in validation, and classic subset export warnings remain explicit. + +2. Data model hard cutover: + - Add typed model fields for placed lemmings, terrain groups, talismans, + text blocks, custom trigger boxes, style metadata, and pack metadata. + - Acceptance: editor state has one canonical owner for each feature; no + compatibility aliases or duplicate editable paths are introduced. + +3. UI editing slices: + - Add focused panels for terrain-group order/visibility, talisman goals, + pre/post text, lemming placement flags, custom trigger boxes, and pack + metadata. + - Acceptance: unsupported runtime-preview behavior is shown as warning-only + state beside the relevant controls. + +4. Validation and pack tooling: + - Extend validation reports from classic caps into NeoLemmix metadata, + cross-level references, missing style aliases, talisman constraints, and + pack ordering. + - Acceptance: pack export can emit a single report covering level-level and + pack-level issues without claiming solver-backed solvability. + +5. Runtime preview parity: + - Implement only the preview/runtime mechanics needed to make the UI truthful: + placed lemmings, custom trigger areas, rotation variants, zombies/water + where supported by engine work, and style variant resolution. + - Acceptance: features without runtime parity stay editable only when the UI + clearly marks them as preserved/unpreviewed data. + +Classic-subset behavior remains the stable baseline throughout these phases. +Expansion work should add explicit unsupported-state warnings before enabling +editing for any feature the runtime cannot preview truthfully. diff --git a/docs/level-editor/remaining.md b/docs/level-editor/remaining.md index 302d5abd..d09a7f82 100644 --- a/docs/level-editor/remaining.md +++ b/docs/level-editor/remaining.md @@ -19,9 +19,36 @@ The classic subset editor is implemented; items below remain out of scope. ## Workflow - Live keybinding editing UI. -- Project export bundles (pack structure, metadata, validation). - Level versioning and multi-file history. ## Validation -- Pack-level consistency checks (cross-level references). - Advanced rule validation (talismans, skill gating). + +## Project and Pack Workflow Design + +Project work should extend the current single-level editor without creating a +second roadmap file. The durable contract is: + +- Local project storage owns editable project metadata, level ordering, the + active level id, and per-level `.nxlv` text. +- Exported `.nxlv` owns one level and preserves comments or unknown sections + that the editor does not understand. +- Classic `.lvl` export remains a lossy compatibility target and must keep + validation warnings for classic caps, unsupported properties, and preserved + NeoLemmix data. +- Pack bundles own `levels.nxmi`, `info.nxmi`, style references, level order, + and a validation report summary for every included level. + +Initial UI entry points should stay small: project name, level list, +duplicate/rename/delete, import errors, validation report export, and pack +export. Pack export should refuse only true validation errors; classic +lossiness and solver advisory findings remain warnings. + +## Pack-Level Validation + +The validation report contract is implemented as JSON so editor UI, harnesses, +and future pack export code can share one shape. A report entry includes +severity, blocker/export-blocker flags, target, message, destructive-fix +metadata, and unsupported preserved-data markers. Pack-level checks currently +cover missing levels, duplicate ids/titles, missing styles, and missing +style-asset references when a style map is available. diff --git a/docs/level-editor/ui-and-tools.md b/docs/level-editor/ui-and-tools.md index 827e1e4b..4a2380d1 100644 --- a/docs/level-editor/ui-and-tools.md +++ b/docs/level-editor/ui-and-tools.md @@ -5,8 +5,12 @@ See `docs/level-editor/workflows.md` for end-to-end editing flows. ## UI layout - **Editor toolbar** (left of canvas): tool buttons and mode status. -- **Palette panel** (right of canvas): tabs for Terrain, Gadgets, Triggers. -- **Inspector panel**: properties for selected entry (position, flags, delete). +- **Palette panel** (left side, below tools): tabs for Terrain, Objects, and + Triggers, with List/Grid view modes and Ctrl+wheel grid-density changes in + grid view. +- **Inspector panel** (right side): properties for selected entry (position, + flags, ordering, alignment/distribution, piece replacement, randomized + replacement, transform scale, and delete). - **Saved levels**: the existing Saved dropdown stays available while editing. ## Tools @@ -15,6 +19,7 @@ See `docs/level-editor/workflows.md` for end-to-end editing flows. - **Terrain stamp**: place a single terrain piece at cursor. - **Gadget stamp**: place a gadget (objects like entrance/exit/traps). - **Trigger stamp**: place gadgets filtered to trigger-only objects. +- **MIDI Flag**: place the MIDI flag gadget used by MIDI-trigger workflows. - **Entrance**: places the entrance gadget. - **Exit**: places the exit gadget. - **Steel**: place a resizable steel rectangle (editor-only). @@ -26,7 +31,8 @@ See `docs/level-editor/workflows.md` for end-to-end editing flows. - Selected entry is outlined on the preview. - Inspector reflects the selected entry’s properties. -- Resize is offered when the entry exposes `WIDTH/HEIGHT`. +- Resize is currently offered for steel rectangles; terrain/gadget dimensions + come from asset metadata unless a specific entry type supports sizing. - Copy/paste/duplicate operate on the current selection. ## Keyboard and mouse @@ -39,7 +45,7 @@ See `docs/level-editor/workflows.md` for end-to-end editing flows. - Right click cancels placement or clears selection. - Alt-drag duplicates the active selection before moving it. - Arrow keys nudge the selection; shift+arrows nudge by the grid size. -- Mouse wheel zoom and arrow-key panning still work in editor mode. +- Mouse wheel zoom remains available. Arrow keys nudge selections while editing. - Preview reloads preserve the current viewport during edit operations. ## Brush feasibility diff --git a/docs/level-editor/ui-spec.md b/docs/level-editor/ui-spec.md index 2f2a922b..5f444a18 100644 --- a/docs/level-editor/ui-spec.md +++ b/docs/level-editor/ui-spec.md @@ -19,7 +19,8 @@ This spec defines the standalone editor page UI and interaction model for the cl - Left: tool palette, piece palettes, brush/snap controls. - Center: game canvas preview + status strip. - Right: header fields, selection inspector, validation output. -- DOM overlay panels layered over the canvas using CSS grid; no canvas-rendered UI. +- Side panels and the canvas are arranged in the editor CSS grid shell; no + canvas-rendered UI. ## Visual Style - High-contrast, print-like UI with warm neutrals. @@ -30,10 +31,12 @@ This spec defines the standalone editor page UI and interaction model for the cl ## Header Bar - Title: "Lemmings Editor". - Level selectors (game type, group, level) for classic levels. -- Saved levels dropdown (localStorage) with Save/Export/Import buttons. +- Saved levels dropdown (localStorage) with New Level, Save, Export, Export + LVL, Import, Import LVL, Undo/Redo, dirty state, and Playtest controls. - Save prompts for a name before writing to localStorage. - Playtest toggle that resumes gameplay without leaving the editor. -- Status chip showing: current style, selected tool, last action, playtest state. +- Dirty chip shows Saved/Unsaved; the status strip shows cursor, grid, + edit/playtest state, and selection summary. ## Tools Panel (Left) - Tool buttons in a vertical stack: @@ -41,6 +44,7 @@ This spec defines the standalone editor page UI and interaction model for the cl - Terrain Stamp - Object - Trigger + - MIDI Flag - Entrance - Exit - Steel @@ -64,6 +68,9 @@ This spec defines the standalone editor page UI and interaction model for the cl - Optional flags (steel, trigger) - Active selection highlight per tab. - Search/filter input to narrow list (client-side only). +- Recently used pieces appear in a compact strip, capped to the most recent + eight terrain/object/trigger selections. +- List and Grid view modes; Ctrl+wheel adjusts grid density in grid view. ## Canvas Preview (Center) - Uses existing game renderer for live preview. @@ -75,10 +82,11 @@ This spec defines the standalone editor page UI and interaction model for the cl - Selection summary: - Type (terrain/gadget) - Piece name + id - - Bounding box (from WIDTH/HEIGHT or asset sizes) + - Bounding box (from steel WIDTH/HEIGHT or terrain/gadget asset sizes) - Transform fields: - X, Y (number) - - WIDTH, HEIGHT (number, optional) + - WIDTH, HEIGHT for steel rectangles; terrain/gadget size fields are + informational unless supported. - Rotate (number, degrees, snapped to 0/90/180/270) - Flip H, Flip V (checkboxes) - Terrain-only flags: @@ -119,7 +127,8 @@ This spec defines the standalone editor page UI and interaction model for the cl ## Interaction Model - Click to select; shift-click to multi-select; drag to move. - Drag a marquee box (marching ants) to select multiple entries. -- Resize handles appear for single selection. +- Resize handles appear for single resizable selections, currently steel + rectangles. - Brush/eraser: click and drag to paint/erase terrain stamps along the drag path. - Alt-drag duplicates the current selection before moving it. - Right-click clears selection. @@ -131,7 +140,7 @@ This spec defines the standalone editor page UI and interaction model for the cl ## Accessibility - All buttons and inputs are keyboard-focusable. - Tool buttons are ` Saved - + @@ -47,11 +49,12 @@