diff --git a/js/app-export.js b/js/app-export.js index d68b954..daa0267 100644 --- a/js/app-export.js +++ b/js/app-export.js @@ -215,7 +215,12 @@ class AppExport { } else { prompt('Copy this share link:', url); } - try { history.replaceState(null, '', '#d=' + enc); } catch { /* ignore */ } + // Deliberately NOT written into the address bar. The link goes to the + // clipboard (or the prompt fallback above), which is how it reaches anyone. + // Putting it in the location hash pinned this tab to that snapshot instead: + // _initDiagram reads #d= ahead of autosave, so every later reload silently + // reverted to the moment of sharing and the first commit afterwards + // overwrote the autosave that still held the newer work. } } diff --git a/js/app-fields.js b/js/app-fields.js index 46add46..cc51297 100644 --- a/js/app-fields.js +++ b/js/app-fields.js @@ -371,7 +371,13 @@ class AppFields { b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', String(v === value)); b.textContent = t; - b.addEventListener('click', () => { if (v !== value) onChange(v); }); + // Commit here. A chip is the whole edit: unlike an input it fires no + // `change` event, so nothing else in the panel picks it up, and the switch + // never reached the undo stack or autosave. Undo afterwards then reverted + // the previous genuine edit while silently dropping the mode switch. + // _commit() is a no-op when the snapshot is unchanged, so this stays safe + // if a callback ever commits on its own. + b.addEventListener('click', () => { if (v !== value) { onChange(v); this._commit(); } }); row.appendChild(b); } panel.appendChild(row); @@ -402,7 +408,13 @@ class AppFields { // ── Live update helpers ─────────────────────────────────────────────────── _refreshResourceCount() { - if (this._selectedType !== 'node') return; + // A diagram-rail feature (Parameters, Custom variables, Artificial player, + // Design tests) borrows the properties panel without clearing the node + // selection, so this used to keep running and write the node's live count + // into whatever the panel's first number input happened to be. That is the + // parameter's own value box, which then counted up with the node while the + // model kept the authored number, and one edit committed the wrong value. + if (this._activeFeature || this._selectedType !== 'node') return; const node = this.diagram.nodes.get(this._selectedId); if (!node || node.type === NodeType.SOURCE) return; @@ -415,7 +427,7 @@ class AppFields { if (node.type === NodeType.REGISTER || node.type === NodeType.DRAIN || node.type === NodeType.TRADER) return; - // First number input is always the Resources field. + // With a node owning the panel, the first number input is the Resources field. const inp = document.querySelector('#props-content input[type="number"]'); if (inp && document.activeElement !== inp) inp.value = node.resources; diff --git a/js/app.js b/js/app.js index aa9feee..1b10e03 100644 --- a/js/app.js +++ b/js/app.js @@ -771,6 +771,17 @@ class App { this.renderer.fitView(); this._resetHistory(); this._renderProps(); + // A share link is a one-time import, not a permanent binding for the tab. + // Adopt it into autosave (_resetHistory only sets the in-memory baseline, + // it does not persist) and then drop the hash, so the next reload restores + // what is actually on screen instead of replaying the sender's snapshot + // over the top of the reader's own work. Embed mode keeps its hash: there + // the URL is the document, and an embed must not write over the host + // page's autosave. + if (!document.body.classList.contains('embed')) { + try { localStorage.setItem('sim_autosave', this._lastState); } catch { /* blocked storage */ } + try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ } + } return; } diff --git a/js/dsl.js b/js/dsl.js index 1acc1eb..9a1f07a 100644 --- a/js/dsl.js +++ b/js/dsl.js @@ -637,6 +637,7 @@ function dslParse(text) { function _econParseNodeLine(kind, tokens, lineNo, colorOf) { const nd = new MNode(kind, 0, 0).toJSON(); + let startColor = null; // a source's `of color`, applied after the whole line is read nd.id = ''; // assigned after all nodes parse let i = 1; if (i >= tokens.length) throw _econErr(`${kind} needs a name`, lineNo); @@ -666,6 +667,10 @@ function _econParseNodeLine(kind, tokens, lineNo, colorOf) { let color = kind === 'source' ? null : DEFAULT_COLOR; if (tokens[i + 1] === 'of') { color = colorOf(tokens[i + 2] || '', lineNo); i += 2; } if (kind !== 'source' && amount > 0) nd.colorMap = { [color]: amount }; + // A source cannot be given its colorMap here: `limited` may still be + // further along this line, and an unlimited source has its stock zeroed + // after the loop. Keep the color and decide once the line is fully read. + else if (kind === 'source') startColor = color; } i++; continue; @@ -700,8 +705,23 @@ function _econParseNodeLine(kind, tokens, lineNo, colorOf) { i++; } // Sources keep their stock in `resources` only when limited; the JSON shape - // mirrors MNode.toJSON (resources 0 for unlimited). - if (kind === 'source' && !nd.limited) nd.resources = 0; + // mirrors MNode.toJSON (resources 0 for unlimited). A limited source also has + // to get its colorMap back: setCount() gave it one when the user ticked + // "Limited stock", dslSerialize writes it as `= N of color`, but nothing read + // it back, so reconcile() refilled the stock as untyped grey. Any outgoing + // colorFilter or downstream recipe then matched nothing and the round-trip + // that docs/ECONOMY_AS_CODE.md calls lossless silently changed the run. + if (kind === 'source') { + if (!nd.limited) nd.resources = 0; + else if (nd.resources > 0) { + // Mirror the serializer exactly: it emits `of X` only when the color is + // not DEFAULT_COLOR, so an absent `of` means the default, NOT the node's + // resourceColor. Falling back to resourceColor instead would invent a + // color the file never carried (the kitchen-sink fixture has a limited + // source whose stock is default grey while its resourceColor is orange). + nd.colorMap = { [startColor || DEFAULT_COLOR]: nd.resources }; + } + } return nd; } diff --git a/js/editor.js b/js/editor.js index 9f81911..e29f36a 100644 --- a/js/editor.js +++ b/js/editor.js @@ -165,10 +165,32 @@ class Editor { this.svg.addEventListener('touchmove', e => this._onTouchMove(e), { passive: false }); this.svg.addEventListener('touchend', e => this._onTouchEnd(e), { passive: false }); + // A gesture that starts on the canvas has to keep receiving events after the + // pointer leaves it. Release the button over the properties panel or the + // palette and the canvas never sees the mouseup, so _onUp never runs: the + // gesture stays live, the next move drags or pans with no button held, and + // since _onUp is also what calls _changed(), the move never reaches undo or + // autosave. The minimap already binds this way (renderer.js + // _bindInteraction). Guarded on a live gesture so ordinary page movement + // costs nothing, and on the target sitting outside the canvas so the svg + // listeners above never run twice for one event. + window.addEventListener('mousemove', e => { + if (this._gestureActive() && !this.svg.contains(e.target)) this._onMove(e); + }); + window.addEventListener('mouseup', e => { + if (this._gestureActive() && !this.svg.contains(e.target)) this._onUp(e); + }); + window.addEventListener('keydown', e => this._onKey(e)); window.addEventListener('keyup', e => this._onKeyUp(e)); } + // Any pointer gesture that began on the canvas and has not been ended yet. + _gestureActive() { + return !!(this._drag || this._panDrag || this._specialDrag || this._resizeDrag + || this._labelDrag || this._connHandleDrag || this._marquee || this._groupPlaceDrag); + } + _onDown(e) { // Pan with middle-mouse, Alt+drag, or Space+drag (from anywhere). if (e.button === 1 || (e.button === 0 && (e.altKey || this._spaceDown))) { @@ -954,11 +976,30 @@ class Editor { } } if (e.key === 'Escape') { + // Escape releases every gesture, not just the three that used to be listed + // here. A drag, pan or resize left in flight otherwise stays glued to the + // cursor with no way out. Where the gesture actually moved something, + // commit it on the way out so undo and autosave match what is on screen. + const moved = this._dragMoved + || !!(this._resizeDrag && this._resizeDrag.moved) + || !!(this._specialDrag && this._specialDrag.moved) + || !!this._labelDrag || !!this._connHandleDrag; + const hadGesture = this._gestureActive(); + this._drag = null; + this._dragMoved = false; + this._dragSourceNodeId = null; + this._panDrag = null; + this._specialDrag = null; + this._resizeDrag = null; + this._labelDrag = null; + this._connHandleDrag = null; this._connecting = null; this._marquee = null; this._groupPlaceDrag = null; this.renderer.clearTemp(); this.renderer.clearMarquee(); + if (hadGesture) this._restoreCursor(); + if (moved) this._changed(); this._select(null, null); } // Arrow keys nudge the selection (grid step when snap is on, else 4px; diff --git a/test/run.js b/test/run.js index 3a614f1..bbb7085 100644 --- a/test/run.js +++ b/test/run.js @@ -734,6 +734,55 @@ test('a limited source emits its stock then runs dry', () => { eq(s.produced, 10, 'produced equals the emitted stock'); }); +test('.econ round-trip keeps a limited source colored stock', () => { + // dslSerialize writes the stock as `= N of color`, but the parser skipped the + // colorMap for sources, so reconcile() refilled it as untyped grey. Any + // outgoing colorFilter then matched nothing and the run changed completely. + const ORE = '#8d6e63'; + const build = () => { + const d = new Diagram(); + const s = new MNode(NodeType.SOURCE, 100, 100); s.label = 'Mine'; + s.limited = true; s.resourceColor = ORE; s.setCount(20, ORE); + const p = new MNode(NodeType.POOL, 400, 100); p.label = 'Store'; + d.addNode(s); d.addNode(p); + const c = new MConnection(s.id, p.id, ConnectionType.RESOURCE); + c.colorFilter = ORE; + d.addConnection(c); + return d; + }; + const back = new Diagram(); + back.loadJSON(dslParse(dslSerialize(build().toJSON()))); + const s2 = [...back.nodes.values()].find(n => n.type === NodeType.SOURCE); + eq(s2.colorMap[ORE], 20, 'colored stock survives the round-trip'); + + const run = (d) => { + const e = new SimEngine(d); e.reset(); + for (let i = 0; i < 10; i++) e.doStep(); + return [...d.nodes.values()].find(n => n.label === 'Store').resources; + }; + eq(run(back), run(build()), 'the round-tripped diagram simulates identically'); +}); + +test('.econ round-trip leaves an unlimited source unstocked', () => { + // The guard that caused the bug above was load-bearing: `limited` can appear + // later on the line, and an unlimited source has its stock zeroed afterwards. + // Restoring the colorMap must not give an unlimited source a phantom stock. + const d = new Diagram(); + const u = new MNode(NodeType.SOURCE, 0, 0); u.label = 'Inf'; u.resourceColor = '#8d6e63'; + d.addNode(u); + const back = new Diagram(); + back.loadJSON(dslParse(dslSerialize(d.toJSON()))); + const u2 = [...back.nodes.values()][0]; + eq(u2.limited, false, 'still unlimited'); + eq(Object.keys(u2.colorMap).length, 0, 'no stale colorMap'); + + // Same for hand-written .econ that gives an unlimited source an amount. + const hand = new Diagram(); + hand.loadJSON(dslParse('source X @ 0,0 = 20 of "#8d6e63"\n')); + const h = [...hand.nodes.values()][0]; + eq(Object.keys(h.colorMap).length, 0, 'hand-written unlimited source keeps no stock'); +}); + test('an unlimited source is unaffected (regression)', () => { const { d, e } = setup(); const s = node(d, NodeType.SOURCE); diff --git a/test/smoke.js b/test/smoke.js index 49fe695..14d9196 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -232,6 +232,59 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`no demo buries a node under a note or chart (${buriedSweep.demos} demos swept)`); else fail('buried demo nodes: ' + JSON.stringify(buriedSweep)); + // mousedown/mousemove/mouseup used to be bound to the canvas alone, so letting + // go over the properties panel never reached _onUp: the gesture stayed live, + // the next move dragged or panned with no button held, and the move never got + // committed because _changed() lives inside _onUp. Escape did not clear it + // either. Releasing outside must end the gesture and commit it. + const outsideRelease = await page.evaluate(() => { + window.app._clearAll(); + window.app._closeFeature(); + const n = window.app.diagram.addNode(new MNode(NodeType.POOL, 400, 400)); + window.app.editor.setTool('select'); + window.app.renderer.render(); + window.app._commit(); + + const canvas = document.getElementById('canvas'); + const r = canvas.getBoundingClientRect(); + const rn = window.app.renderer; + const cx = r.left + n.x * rn._scale + rn._panX; + const cy = r.top + n.y * rn._scale + rn._panY; + const at = (el, type, x, y) => el.dispatchEvent( + new MouseEvent(type, { clientX: x, clientY: y, button: 0, bubbles: true })); + + const undoBefore = window.app._undoStack.length; + at(canvas, 'mousedown', cx, cy); + at(canvas, 'mousemove', cx + 60, cy + 20); + // Release well outside the canvas, on an element that is not inside the svg. + at(document.body, 'mousemove', r.right + 80, cy + 20); + at(document.body, 'mouseup', r.right + 80, cy + 20); + + const stuck = !!window.app.editor._drag; + const committed = window.app._undoStack.length > undoBefore; + const posAfterRelease = { x: n.x, y: n.y }; + // A move with no button held must not drag the node any further. + at(canvas, 'mousemove', cx - 120, cy - 90); + const crept = n.x !== posAfterRelease.x || n.y !== posAfterRelease.y; + + // Same for a pan: it used to keep panning under a buttonless cursor. + window.app.editor._panDrag = null; + const pan0 = rn._panX; + canvas.dispatchEvent(new MouseEvent('mousedown', { clientX: cx, clientY: cy, button: 1, bubbles: true })); + at(canvas, 'mousemove', cx + 40, cy); + at(document.body, 'mouseup', r.right + 80, cy); + const panStuck = !!window.app.editor._panDrag; + const panAfter = rn._panX; + at(canvas, 'mousemove', cx - 200, cy - 150); + const panCrept = rn._panX !== panAfter; + + return { stuck, committed, crept, panStuck, panCrept, pan0 }; + }); + if (!outsideRelease.stuck && outsideRelease.committed && !outsideRelease.crept + && !outsideRelease.panStuck && !outsideRelease.panCrept) + ok('gesture released outside the canvas ends and commits, nothing follows a buttonless cursor'); + else fail('outside release: ' + JSON.stringify(outsideRelease)); + // Navigation: zoom controls step the scale and update the readout; fit-to-content // re-frames without error. const nav = await page.evaluate(() => {