Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion js/app-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
18 changes: 15 additions & 3 deletions js/app-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down
11 changes: 11 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
24 changes: 22 additions & 2 deletions js/dsl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
41 changes: 41 additions & 0 deletions js/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions test/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading