From 21a9500cc09a9e99afc72154ef085465300c0f09 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 31 Aug 2026 15:06:28 +0000
Subject: [PATCH 1/4] Close the untrusted-diagram attack surface
Round-6 findings 1, 3 and 4, plus the formula code-execution sink I confirmed
before the review ran and the display-font beacon it flagged as a lead.
A diagram is untrusted input: it arrives as a shared #d= link, a downloaded
.json or .econ, a library component, or a cli.js argument. Four ways it could
act on the reader.
1. evalFormula fell through to new Function(expr) whenever math.js could not
parse an expression, and validateFormula reported such a payload as valid so
nothing warned. reset() evaluates register formulas on load, so opening a
share link ran the author's JavaScript with no click at all, and running
"node cli.js someones-economy.econ" ran it with the invoking user's
privileges. The fallback is gone. Nothing needs it: all 110 distinct
expressions across the 12 demos and 11 templates compile under math.js, and
the JS-flavoured spellings a saved diagram might use are handled on the
math.js path instead, by exposing a seeded Math object in scope and by
retrying an expression math.js has already refused with the operators
rewritten (&& to and, || to or, ! to not, ** to ^, === to ==).
One deliberate semantic change: "a && b" used to return b's value under JS
truthiness and now returns 1 or 0. These are numeric fields, so a boolean is
the more useful answer, and it is the same answer math.js gives everywhere
else.
2. Parameter names were interpolated into the sweep results table with
innerHTML and no escaping, so a parameter named with an img onerror ran the
author's script in the app's origin the moment the reader pressed Run sweep,
with read access to sim_library and sim_autosave. Every other interpolation
in that function already escaped; this one did not. Checked the rest of the
app's innerHTML sinks: this was the only unescaped one carrying diagram text.
3. meta.name was interpolated into the header banner of the generated module,
so a name containing the comment terminator ended it early and the rest
became top-level code that ran on require(). The banner escapes it now, and
the module's global name is held to an identifier.
4. meta.font was pasted into a fonts.googleapis.com stylesheet URL, so opening
a shared diagram fired a request to a third party carrying a string of the
sender's choosing, telling them the reader's IP and that they had opened it.
It also contradicted the no-Google-Fonts-at-runtime claim in CLAUDE.md. Only
a family from the curated list is honoured now.
Separately, opening a share link replaced the reader's autosaved diagram with
no prompt, and a page load has no undo stack for state from before it, so the
work was simply gone. The link is still adopted (a reload has to restore what
is on screen), but it asks first when there is real work in the saved slot, and
puts the reader's diagram back if they decline.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
---
js/app-analysis.js | 8 +++-
js/app-props.js | 14 ++++--
js/app.js | 46 +++++++++++++++++--
js/codegen.js | 21 ++++++---
js/model.js | 79 ++++++++++++++++++++-------------
test/run.js | 63 +++++++++++++++++++++++++-
test/smoke.js | 108 +++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 295 insertions(+), 44 deletions(-)
diff --git a/js/app-analysis.js b/js/app-analysis.js
index 8c79051..4fa6277 100644
--- a/js/app-analysis.js
+++ b/js/app-analysis.js
@@ -261,8 +261,14 @@ class AppAnalysis {
+ `, ${runs} runs × ${steps} steps per value`
+ (seed ? `, seed ${this._esc(seed)}` : '') + '
'
+ 'Cells show the mean final value across runs.
';
+ // Escaped: `name` is a parameter name straight out of the loaded diagram,
+ // which is untrusted input (a shared #d= link, a downloaded .json/.econ,
+ // a library component). Every other interpolation in this function
+ // escapes; this one did not, so a parameter named with an
+ // ran the diagram author's script in the app's own origin the moment the
+ // reader pressed Run sweep.
html += '| Node | '
- + values.map(v => `${name}=${v} | `).join('') + '
';
+ + values.map(v => `${this._esc(name)}=${v} | `).join('') + '';
for (let n = 0; n < results[0].nodes.length; n++) {
html += `| ${this._esc(results[0].nodes[n].label || results[0].nodes[n].type)} | `
+ results.map(r => `${r.nodes[n].mean} | `).join('') + '
';
diff --git a/js/app-props.js b/js/app-props.js
index d8cc092..5914520 100644
--- a/js/app-props.js
+++ b/js/app-props.js
@@ -100,9 +100,17 @@ class AppProps {
// link, then point the --font stack at the family. '' restores the
// built-in stack. If the fetch fails (offline), the fallbacks apply.
let link = document.getElementById('gfont-link');
- if (meta.font) {
+ // Only a family from the curated list. meta.font arrives with the diagram,
+ // which is untrusted input, and it was pasted straight into a third-party
+ // stylesheet URL: opening a shared link fired a request to
+ // fonts.googleapis.com carrying a string of the diagram author's choosing,
+ // telling them the reader's IP and that they had opened it. It also went
+ // raw into the --font CSS value. Anything not on the list falls back to the
+ // built-in stack, which is what an unset font already does.
+ const font = GOOGLE_FONTS.includes(meta.font) ? meta.font : '';
+ if (font) {
const href = 'https://fonts.googleapis.com/css2?family='
- + encodeURIComponent(meta.font).replace(/%20/g, '+')
+ + encodeURIComponent(font).replace(/%20/g, '+')
+ ':wght@400;600;700&display=swap';
if (!link) {
link = document.createElement('link');
@@ -111,7 +119,7 @@ class AppProps {
document.head.appendChild(link);
}
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
- rootStyle.setProperty('--font', `'${meta.font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`);
+ rootStyle.setProperty('--font', `'${font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`);
} else {
if (link) link.remove();
rootStyle.removeProperty('--font');
diff --git a/js/app.js b/js/app.js
index 05dacad..9dc1d24 100644
--- a/js/app.js
+++ b/js/app.js
@@ -842,8 +842,16 @@ class App {
// the URL is the document, and an embed must not write over the host
// page's autosave.
if (!document.body.classList.contains('embed')) {
- this._persistAutosave();
- try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
+ let prior = null;
+ try { prior = JSON.parse(localStorage.getItem('sim_autosave') || 'null'); } catch { /* blocked or corrupt */ }
+ const priorNodes = prior && Array.isArray(prior.nodes) ? prior.nodes.length : 0;
+ if (priorNodes && this._canLoadDiagram(prior)) {
+ // There is real work in the saved slot. Ask before the link takes it.
+ this._adoptSharedDiagram(prior);
+ } else {
+ this._persistAutosave();
+ try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
+ }
}
return;
}
@@ -1028,7 +1036,39 @@ class App {
this._syncRailFades();
}
- _scrollRails() {
+ // A share link has just been loaded over an existing autosaved diagram. The
+ // shared one is already on screen; ask before it takes the saved slot, and put
+ // the reader's own diagram back if they decline. Without this, opening a link
+ // destroyed the reader's work with no prompt: a reload afterwards brought back
+ // the sender's diagram, not theirs, and nothing could undo it (a page load has
+ // no undo stack for state from before it).
+ async _adoptSharedDiagram(prior) {
+ const name = (this.diagram.meta && this.diagram.meta.name || '').trim();
+ const keep = await this._confirmGuard(
+ `Keep the shared diagram${name ? ` "${name}"` : ''}? It replaces the diagram saved in this browser.`,
+ 'Shared diagram');
+ if (keep) {
+ this._persistAutosave();
+ try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
+ return;
+ }
+ // Declined: restore what they had. The hash stays, so the link still works
+ // if they change their mind.
+ this.diagram.loadJSON(prior);
+ this._applyMeta();
+ this.engine.reset();
+ this.renderer.balls.clear();
+ this.renderer.flowFx.clear();
+ this._clearSparklines();
+ this.editor._select(null, null);
+ this.renderer.render();
+ this.renderer.fitView();
+ this._resetHistory();
+ this._renderProps();
+ this._toast('Kept your own diagram. The shared one was not saved.');
+ }
+
+_scrollRails() {
return ['palette', 'diagram-rail'].map(id => document.getElementById(id)).filter(Boolean);
}
diff --git a/js/codegen.js b/js/codegen.js
index 09f85af..a48d10d 100644
--- a/js/codegen.js
+++ b/js/codegen.js
@@ -16,9 +16,18 @@
/* exported buildEconomyModule */
function buildEconomyModule(json, modelSrc, engineSrc, opts = {}) {
- const globalName = opts.name || 'Economy';
- const econName = (json.meta && json.meta.name) || 'economy';
- const stamp = opts.generator || 'the simulations designer';
+ // Anything interpolated into the header comment must be unable to close it.
+ // meta.name comes from the diagram, which is untrusted input: a name
+ // containing */ ended the comment early and everything after it became
+ // top-level code in the emitted module, running on require().
+ const commentSafe = (v) => String(v == null ? '' : v)
+ .replace(/\*\//g, '* /').replace(/[\r\n]+/g, ' ');
+ // The global name reaches a code position (root. = factory()), so it is
+ // held to an identifier.
+ const rawGlobal = String(opts.name || 'Economy');
+ const globalName = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(rawGlobal) ? rawGlobal : 'Economy';
+ const econName = commentSafe((json.meta && json.meta.name) || 'economy');
+ const stamp = commentSafe(opts.generator || 'the simulations designer');
// Double-encode the diagram: the module keeps it as a JSON string and each
// createEconomy() call parses a fresh deep copy.
const diagramLiteral = JSON.stringify(JSON.stringify(json));
@@ -27,9 +36,9 @@ function buildEconomyModule(json, modelSrc, engineSrc, opts = {}) {
* ${econName} — generated economy module
* Built by ${stamp}. Self-contained: no dependencies, no DOM.
*
- * Formulas evaluate with math.js when a global \`math\` is present (optional:
- * require('mathjs') and set global.math before loading this file); without it
- * they fall back to a plain JS expression evaluator.
+ * Formulas evaluate with math.js, so a global \`math\` is required:
+ * require('mathjs') and set global.math before loading this file. Without it
+ * every formula evaluates to 0.
*
* The RNG (SimRandom) is shared module state: run one seeded economy at a
* time per process for bit-exact reproducibility.
diff --git a/js/model.js b/js/model.js
index 9ad5eee..05a2400 100644
--- a/js/model.js
+++ b/js/model.js
@@ -128,13 +128,43 @@ function _formulaRandomScope() {
// `Math` stand-in whose random() draws from SimRandom (everything else is
// inherited); shadows the global in the legacy Function-based evaluator.
-const _seededMath = Object.create(Math, { random: { value: () => SimRandom.random() } });
+// The `Math` object legacy formulas reach for, rebuilt as OWN properties:
+// math.js refuses to call a method it finds on a prototype, so Object.create
+// (Math) would give "No access to method random". `random` draws from SimRandom
+// so a seeded run stays reproducible on this path too.
+const _MATH_FNS = ['abs', 'ceil', 'floor', 'round', 'trunc', 'sign', 'min', 'max', 'pow',
+ 'sqrt', 'cbrt', 'exp', 'log', 'log2', 'log10', 'hypot',
+ 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2'];
+const _MATH_CONSTS = ['PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2'];
+function _seededMathScope() {
+ const m = { random: () => SimRandom.random() };
+ for (const k of _MATH_FNS) if (typeof Math[k] === 'function') m[k] = (...a) => Math[k](...a);
+ for (const k of _MATH_CONSTS) if (typeof Math[k] === 'number') m[k] = Math[k];
+ return m;
+}
+
+// Legacy JS operators that math.js spells differently. Applied ONLY to an
+// expression math.js has already refused to compile, so a well-formed math.js
+// expression is never rewritten.
+function _repairLegacyOps(expr) {
+ return expr
+ .replace(/!==/g, ' != ')
+ .replace(/===/g, ' == ')
+ .replace(/&&/g, ' and ')
+ .replace(/\|\|/g, ' or ')
+ .replace(/\*\*/g, '^')
+ .replace(/!(?=\s*[A-Za-z_(])/g, ' not ');
+}
function _evalMathJS(expr, vars) {
if (typeof math === 'undefined' || !math.compile) return undefined;
let code = _mathCompileCache.get(expr);
if (code === undefined) {
try { code = math.compile(expr); } catch { code = null; }
+ // Second chance for a formula written in the older JS-flavoured syntax.
+ if (!code) {
+ try { code = math.compile(_repairLegacyOps(expr)); } catch { code = null; }
+ }
if (_mathCompileCache.size > 500) _mathCompileCache.clear();
_mathCompileCache.set(expr, code);
}
@@ -143,8 +173,10 @@ function _evalMathJS(expr, vars) {
for (const [k, v] of Object.entries(vars || {})) {
if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v)) scope[k] = v;
}
- // Seeded randomness (scope functions take precedence over math.js built-ins).
+ // Seeded randomness (scope functions take precedence over math.js built-ins),
+ // plus the Math object older formulas were written against.
Object.assign(scope, _formulaRandomScope());
+ scope.Math = _seededMathScope();
try {
let r = code.evaluate(scope);
if (r && typeof r === 'object' && typeof r.toNumber === 'function') r = r.toNumber();
@@ -156,41 +188,28 @@ function _evalMathJS(expr, vars) {
function evalFormula(expr, vars = {}) {
if (!expr || typeof expr !== 'string' || !expr.trim()) return 0;
+ // math.js only. There used to be a `new Function(expr)` fallback here for
+ // formulas math.js could not parse, which made every formula-bearing field a
+ // remote code execution sink: a diagram is untrusted input, arriving as a
+ // shared #d= link, a downloaded .json or .econ, a library component or a
+ // cli.js argument, and reset() evaluates register formulas on load, so simply
+ // opening a link ran the author's JavaScript. validateFormula reported such a
+ // payload as valid, so nothing warned. Nothing in the app needs it: all 110
+ // distinct expressions across the 12 demos and 11 templates compile under
+ // math.js, and the JS-flavoured spellings older saved diagrams might use are
+ // handled by _seededMathScope and _repairLegacyOps above.
const viaMath = _evalMathJS(expr.trim(), vars);
- if (viaMath !== undefined) return viaMath;
- // Legacy fallback: plain JS expression over the same variables. The seeded
- // random helpers are passed in too, and `Math` is shadowed so Math.random()
- // also draws from SimRandom (seeded runs stay reproducible on this path).
- const rng = _formulaRandomScope();
- const keys = [], vals = [];
- for (const [k, v] of Object.entries(vars || {})) {
- if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v)
- && !(k in rng) && k !== 'Math') {
- keys.push(k); vals.push(v);
- }
- }
- for (const [k, fn] of Object.entries(rng)) { keys.push(k); vals.push(fn); }
- keys.push('Math'); vals.push(_seededMath);
- try {
- // eslint-disable-next-line no-new-func
- const fn = new Function(...keys, `"use strict"; return (${expr.trim()});`);
- const r = Number(fn(...vals));
- return isFinite(r) ? r : 0;
- } catch { return 0; }
+ return viaMath === undefined ? 0 : viaMath;
}
// True if the expression parses in at least one of the two evaluators
// (math.js syntax, or legacy JS syntax). Used for live input validation.
function validateFormula(expr) {
if (!expr || typeof expr !== 'string' || !expr.trim()) return false;
- if (typeof math !== 'undefined' && math.parse) {
- try { math.parse(expr.trim()); return true; } catch { /* try legacy */ }
- }
- try {
- // eslint-disable-next-line no-new-func
- new Function(`"use strict"; return (${expr.trim()});`);
- return true;
- } catch { return false; }
+ if (typeof math === 'undefined' || !math.parse) return false;
+ const t = expr.trim();
+ try { math.parse(t); return true; } catch { /* try the legacy spellings */ }
+ try { math.parse(_repairLegacyOps(t)); return true; } catch { return false; }
}
// Sample from a named statistical distribution. Returns a non-negative integer.
diff --git a/test/run.js b/test/run.js
index c96416a..58b8e57 100644
--- a/test/run.js
+++ b/test/run.js
@@ -13,7 +13,9 @@ const path = require('path');
// The browser loads math.js from vendor/math.min.js; headlessly we expose the
// npm package as the same `math` global so formulas take the math.js path.
-// Tests still pass without it (formulas fall back to the legacy JS evaluator).
+// math.js is REQUIRED: the legacy `new Function(expr)` evaluator was removed
+// because it made every formula field a code-execution sink, so without math.js
+// every formula evaluates to 0 and the formula tests fail.
try { global.math = require('mathjs'); } catch { /* optional */ }
function loadEngine() {
@@ -774,6 +776,65 @@ test('an at-rest diagram still writes no baseline fields', () => {
eq(JSON.stringify(d.toJSON()), before, 'reset() alone changes nothing on disk');
});
+test('a formula cannot execute arbitrary JavaScript', () => {
+ // evalFormula used to fall through to `new Function(expr)` whenever math.js
+ // could not parse the expression, and validateFormula reported such a payload
+ // as valid so nothing warned. A diagram is untrusted input: it arrives as a
+ // shared #d= link, a downloaded .json or .econ, a library component or a
+ // cli.js argument, and reset() evaluates register formulas on load. Opening a
+ // link therefore ran the author's JavaScript.
+ delete globalThis.__FORMULA_SIDE_EFFECT;
+ const payloads = [
+ "(function(){ globalThis.__FORMULA_SIDE_EFFECT = 1; return 7 })()",
+ "(() => { globalThis.__FORMULA_SIDE_EFFECT = 1; return 7 })()",
+ "[].constructor.constructor('globalThis.__FORMULA_SIDE_EFFECT = 1')()",
+ "this.constructor.constructor('globalThis.__FORMULA_SIDE_EFFECT = 1')()",
+ ];
+ for (const p of payloads) {
+ eq(evalFormula(p, {}), 0, `payload evaluates to 0: ${p.slice(0, 40)}`);
+ eq(validateFormula(p), false, `payload is reported invalid: ${p.slice(0, 40)}`);
+ }
+ eq(globalThis.__FORMULA_SIDE_EFFECT, undefined, 'no payload had any side effect');
+});
+
+test('formulas written in the older JS syntax still evaluate', () => {
+ // Removing the legacy evaluator must not silently break saved diagrams, so
+ // the Math object and the JS spellings of the operators are handled by the
+ // math.js path instead.
+ SimRandom.seed('legacy');
+ const vars = { gold: 9, a: 1, b: 2 };
+ const cases = [
+ ['Math.round(2.6)', 3], ['Math.floor(gold/3)', 3], ['Math.max(1, gold-2)', 7],
+ ['Math.min(a,b)', 1], ['Math.abs(0-4)', 4], ['Math.pow(2,3)', 8],
+ ['gold > 5 ? 2 : 1', 2], ['a && b', 1], ['!a', 0], ['2**3', 8],
+ ['gold % 4', 1], ['round(gold/2)', 5], ['a === 1', 1], ['a !== 1', 0],
+ ];
+ for (const [expr, want] of cases) {
+ eq(evalFormula(expr, vars), want, `legacy syntax still works: ${expr}`);
+ eq(validateFormula(expr), true, `and validates: ${expr}`);
+ }
+ // Math.random() must still draw from the seeded stream.
+ SimRandom.seed('s1');
+ const first = [0, 0, 0].map(() => evalFormula('Math.random()*1000', {}));
+ SimRandom.seed('s1');
+ const again = [0, 0, 0].map(() => evalFormula('Math.random()*1000', {}));
+ eq(JSON.stringify(first), JSON.stringify(again), 'Math.random stays reproducible under a seed');
+});
+
+test('a diagram name cannot break out of the generated module header comment', () => {
+ // meta.name is interpolated into a /* */ banner. A name containing the
+ // comment terminator ended it early and everything after became top-level
+ // code in the emitted module, running on require().
+ delete globalThis.__CODEGEN_SIDE_EFFECT;
+ const { d } = setup();
+ const p = node(d, NodeType.POOL); p.label = 'Gold';
+ const json = d.toJSON();
+ json.meta = { ...(json.meta || {}), name: 'Evil */ ; globalThis.__CODEGEN_SIDE_EFFECT = 1; /*' };
+ const Economy = buildTestModule(json);
+ eq(globalThis.__CODEGEN_SIDE_EFFECT, undefined, 'nothing escaped the banner');
+ assert(typeof Economy.createEconomy === 'function', 'the module still loads and works');
+});
+
test('a trader cannot hand resources to another trader either', () => {
// _acceptable already refuses a trader as a flow target ("a trader never
// holds resources"), but the trader's own canAccept rejected only sources and
diff --git a/test/smoke.js b/test/smoke.js
index bd42d8e..3a067d3 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -2588,6 +2588,114 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/';
ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`);
else fail('components: ' + JSON.stringify(comp));
+ // Security: opening a share link used to replace the reader's autosaved
+ // diagram silently, with nothing to undo it and a reload bringing back the
+ // sender's diagram rather than theirs.
+ const shareGuard = await (async () => {
+ const ctx = await browser.newContext();
+ const sp = await ctx.newPage();
+ await sp.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' }));
+ await sp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} });
+ await sp.goto(URL, { waitUntil: 'networkidle' });
+ const hash = await sp.evaluate(() => {
+ const app = window.app;
+ app._clearAll();
+ for (let i = 0; i < 3; i++) {
+ const n = new MNode(NodeType.POOL, 150 + i * 120, 200); n.label = 'Mine' + i;
+ app.diagram.addNode(n);
+ }
+ app.diagram.meta.name = 'MyWork';
+ app._commit();
+ const d = new Diagram();
+ const n = new MNode(NodeType.POOL, 100, 100); n.label = 'Theirs';
+ d.addNode(n); d.meta.name = 'Colleague';
+ return '#d=' + btoa(unescape(encodeURIComponent(JSON.stringify(d.toJSON()))));
+ });
+ const savedName = () => sp.evaluate(() => {
+ try { return (JSON.parse(localStorage.getItem('sim_autosave') || '{}').meta || {}).name; }
+ catch { return null; }
+ });
+ // A hash-only goto is a same-document navigation, so reload to re-init.
+ await sp.goto(URL + hash, { waitUntil: 'networkidle' });
+ await sp.reload({ waitUntil: 'networkidle' });
+ await sp.waitForSelector('#guard-overlay:not(.hidden)', { timeout: 3000 }).catch(() => {});
+ const asked = await sp.evaluate(() => !document.getElementById('guard-overlay').classList.contains('hidden'));
+ if (asked) await sp.click('#guard-cancel');
+ await sp.waitForTimeout(150);
+ const declined = { saved: await savedName(), canvas: await sp.evaluate(() => window.app.diagram.meta.name) };
+ await sp.goto(URL + hash, { waitUntil: 'networkidle' });
+ await sp.reload({ waitUntil: 'networkidle' });
+ await sp.waitForSelector('#guard-overlay:not(.hidden)', { timeout: 3000 }).catch(() => {});
+ const asked2 = await sp.evaluate(() => !document.getElementById('guard-overlay').classList.contains('hidden'));
+ if (asked2) await sp.click('#guard-confirm');
+ await sp.waitForTimeout(150);
+ const accepted = { saved: await savedName(), hash: await sp.evaluate(() => location.hash) };
+ await ctx.close();
+ return { asked, declined, accepted };
+ })();
+ if (shareGuard.asked && shareGuard.declined.saved === 'MyWork' && shareGuard.declined.canvas === 'MyWork'
+ && shareGuard.accepted.saved === 'Colleague' && shareGuard.accepted.hash === '')
+ ok('security: a share link asks before it replaces the reader\'s saved diagram');
+ else fail('share guard: ' + JSON.stringify(shareGuard));
+
+ // Security: a diagram is untrusted input. Its parameter names reach the sweep
+ // results table, which built its header row with innerHTML and no escaping.
+ const sweepXss = await page.evaluate(async () => {
+ const app = window.app;
+ app._clearAll(); app._closeFeature();
+ const p = new MNode(NodeType.POOL, 200, 200); p.label = 'Gold'; p.setCount(5);
+ app.diagram.addNode(p);
+ app.diagram.params = { 'rate">
window.app._hideModal('mc-overlay'));
+ if (!sweepXss.fired && sweepXss.imgs === 0 && sweepXss.headerText.includes('
{
+ const ctx = await browser.newContext();
+ const fp = await ctx.newPage();
+ const hits = [];
+ await fp.route('**://fonts.googleapis.com/**', r => { hits.push(r.request().url()); r.fulfill({ contentType: 'text/css', body: '' }); });
+ await fp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} });
+ await fp.goto(URL, { waitUntil: 'networkidle' });
+ const check = async (font) => {
+ hits.length = 0;
+ await fp.evaluate((f) => {
+ const app = window.app;
+ app._clearAll();
+ app.diagram.addNode(new MNode(NodeType.POOL, 200, 200));
+ app.diagram.meta.font = f;
+ app._applyMeta();
+ }, font);
+ await fp.waitForTimeout(250);
+ return { requests: hits.length, link: await fp.evaluate(() => !!document.getElementById('gfont-link')) };
+ };
+ const hostile = await check('Attacker-Beacon-12345');
+ const curated = await check('Lexend');
+ await ctx.close();
+ return { hostile, curated };
+ })();
+ if (fontGuard.hostile.requests === 0 && !fontGuard.hostile.link
+ && fontGuard.curated.requests === 1 && fontGuard.curated.link)
+ ok('security: an off-list display font fires no third-party request, a curated one still works');
+ else fail('font guard: ' + JSON.stringify(fontGuard));
+
// Accessibility: three controls whose accessible name has to track state.
const a11yNames = await (async () => {
await page.evaluate(() => {
From baaacd4e04cf0ca69262acd29cbf4e8617d3ee81 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 31 Aug 2026 15:13:19 +0000
Subject: [PATCH 2/4] Make the resource steppers move the starting amount, and
pin the deleted-node placeholder
Round-6 findings 6 and 7. Finding 6 is a regression from the previous round.
At step 0 the amount field is labelled Starting amount, but the +/- buttons
beside it went through addResources/takeResources, which touch only the live
count; setCount is what writes the reset baseline. So three clicks on "+"
changed the canvas, the properties readout, the undo stack and the autosaved
file, and then Reset or a page reload silently put the old number back. The
autosave recorded the contradiction directly: resources 13 next to
initialResources 10. A nudge at rest now moves the baseline with the count,
while still preserving a mixed holding's colours, which is why it does not just
call setCount.
The "(node deleted)" placeholder added last round was a real selectable option
with an empty value. Choosing it cleared the rule's target, and the next render
fell back to displaying the first interactive node with no warning at all,
which is the exact misleading state the placeholder was added to prevent. It is
disabled now: shown as the current value, impossible to pick.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
---
js/app-props.js | 16 ++++++++++++-
test/smoke.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/js/app-props.js b/js/app-props.js
index 5914520..acad431 100644
--- a/js/app-props.js
+++ b/js/app-props.js
@@ -820,8 +820,13 @@ class AppProps {
// node it has nothing to do with while the rule silently never fired.
const targetGone = !!rule.nodeId && !interactives.some(n => n.id === rule.nodeId);
if (targetGone) {
+ // Shown but not choosable. As a real option it could be picked, which
+ // cleared the rule's target to '' and made the next render fall back to
+ // displaying the first interactive node with no warning: exactly the
+ // misleading state this placeholder exists to prevent.
const o = document.createElement('option');
- o.value = ''; o.textContent = '(node deleted)'; o.selected = true;
+ o.value = ''; o.textContent = '(node deleted)';
+ o.selected = true; o.disabled = true;
ns.appendChild(o);
}
for (const n of interactives) {
@@ -1454,6 +1459,15 @@ class AppProps {
const delta = Math.max(0, target) - node.resources;
if (delta > 0) node.addResources(delta, color);
else if (delta < 0) node.takeResources(-delta);
+ // At rest this field IS the starting amount, so the nudge has to move the
+ // reset baseline with it. addResources/takeResources only touch the live
+ // count (setCount is what writes the baseline), so the +/- buttons changed
+ // the number on the canvas, in undo and in autosave, and then Reset or a
+ // reload put it straight back.
+ if (this.engine.step === 0) {
+ node._initialResources = node.resources;
+ node._initialColorMap = { ...node.colorMap };
+ }
}
_nodeProps(panel, node) {
diff --git a/test/smoke.js b/test/smoke.js
index 3a067d3..0c7613f 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -2588,6 +2588,68 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/';
ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`);
else fail('components: ' + JSON.stringify(comp));
+ // Properties: at step 0 the amount field is the Starting amount, so the +/-
+ // buttons have to move the reset baseline. They only moved the live count, so
+ // the canvas, undo and autosave all showed the new number and Reset or a
+ // reload silently put the old one back.
+ const stepperBaseline = await page.evaluate(() => {
+ const app = window.app;
+ app._clearAll(); app._closeFeature();
+ const p = new MNode(NodeType.POOL, 300, 300); p.label = 'Gold';
+ p.setCount(10, '#9e9e9e');
+ app.diagram.addNode(p);
+ app.renderer.render(); app._commit();
+ app.editor._select(p.id, 'node');
+ const plus = [...document.querySelectorAll('#props-content button')]
+ .find(b => b.getAttribute('aria-label') === 'Add one resource');
+ if (!plus) return { error: 'no stepper' };
+ for (let i = 0; i < 3; i++) plus.click();
+ const afterClicks = p.resources;
+ let saved = null;
+ try {
+ const j = JSON.parse(localStorage.getItem('sim_autosave') || '{}');
+ const n = (j.nodes || []).find(x => x.label === 'Gold') || {};
+ saved = { resources: n.resources, initial: n.initialResources };
+ } catch { /* blocked */ }
+ app.engine.reset();
+ return { afterClicks, afterReset: p.resources, saved };
+ });
+ if (!stepperBaseline.error && stepperBaseline.afterClicks === 13
+ && stepperBaseline.afterReset === 13
+ && stepperBaseline.saved.resources === 13 && stepperBaseline.saved.initial === undefined)
+ ok('properties: the +/- steppers move the starting amount, and Reset keeps it');
+ else fail('stepper baseline: ' + JSON.stringify(stepperBaseline));
+
+ // Player: the "(node deleted)" placeholder must not be selectable.
+ const placeholder = await page.evaluate(() => {
+ const app = window.app;
+ app._clearAll(); app._closeFeature();
+ const a = new MNode(NodeType.POOL, 200, 200); a.label = 'Chest';
+ a.activation = ActivationMode.INTERACTIVE;
+ const b = new MNode(NodeType.POOL, 400, 200); b.label = 'Shop';
+ b.activation = ActivationMode.INTERACTIVE;
+ app.diagram.addNode(a); app.diagram.addNode(b);
+ app.renderer.render(); app._commit();
+ document.querySelector('#diagram-rail .rail-btn[data-feature="player"]').click();
+ const ai = app.diagram.aiPlayer;
+ ai.rules.push({ nodeId: b.id, mode: 'interval', every: 2 });
+ ai.enabled = true;
+ app.diagram.removeNode(b.id);
+ app.renderer.render();
+ app._renderProps();
+ const opt = [...document.querySelectorAll('#props-content .ai-rule option')]
+ .find(o => /deleted/i.test(o.textContent));
+ return {
+ present: !!opt,
+ disabled: !!(opt && opt.disabled),
+ selected: !!(opt && opt.selected),
+ stillWarns: /never fires/i.test(document.getElementById('props-content').textContent),
+ };
+ });
+ if (placeholder.present && placeholder.disabled && placeholder.selected && placeholder.stillWarns)
+ ok('player: the "(node deleted)" placeholder is shown but cannot be chosen');
+ else fail('deleted placeholder: ' + JSON.stringify(placeholder));
+
// Security: opening a share link used to replace the reader's autosaved
// diagram silently, with nothing to undo it and a reload bringing back the
// sender's diagram rather than theirs.
From d720cf89ccbabe137354746ef7244ac86c29b325 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 31 Aug 2026 15:23:41 +0000
Subject: [PATCH 3/4] Stop one tab's edits erasing another tab's saved work
Round-6 findings 13 and 14.
Library Delete, Duplicate and Rename, and the components Delete, all wrote back
the array the list had been rendered from rather than what storage held at the
moment of the edit. So a diagram another tab saved in between was erased along
with the edit: measured through the real Delete menu item, a library holding one
entry from each of two tabs ended up empty. Those edits now re-read storage
first and locate their target by a stable per-entry id, falling back to a
content match for rows saved before ids existed. The save paths already read
fresh, and now stamp an id.
sim_autosave is a single slot shared by every tab on the origin, so the tab that
saves last silently becomes the saved copy while the other carries on believing
its work is safe. Nothing can merge two diagrams, but the superseded tab can be
told, once, while its work is still on screen and can be exported. A storage
listener does that; the event fires only in other tabs, so a write never warns
the tab that made it, and embeds stay silent.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
---
js/app-library.js | 56 ++++++++++++++++++++++++++--------
js/app.js | 17 +++++++++++
test/smoke.js | 76 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 137 insertions(+), 12 deletions(-)
diff --git a/js/app-library.js b/js/app-library.js
index a7e8e92..dce73f7 100644
--- a/js/app-library.js
+++ b/js/app-library.js
@@ -38,8 +38,9 @@ class AppLibrary {
// Capture a small canvas snapshot so the row is recognisable at a glance
// (15b). Falls back to a blank thumb when rasterizing is unavailable.
this._captureThumbnail((thumb) => {
+ // Fresh read, so a save here does not drop what another tab added.
const lib = this._getLibrary();
- const entry = { name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size };
+ const entry = { id: this._entryKey(), name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size };
if (thumb) entry.thumb = thumb;
lib.push(entry);
if (!this._saveLibrary(lib)) {
@@ -95,6 +96,37 @@ class AppLibrary {
try { return JSON.parse(localStorage.getItem('sim_library') || '[]'); } catch { return []; }
}
+ // A stable per-entry key so an edit can find its target in a list another tab
+ // may have changed since this one was rendered.
+ _entryKey() {
+ return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
+ }
+
+ // Apply an edit against what storage holds RIGHT NOW, not the array this list
+ // was rendered from. Delete, Duplicate and Rename each wrote back the snapshot
+ // taken when the list was drawn, so a diagram another tab had saved since was
+ // erased along with the edit. Entries are located by id, falling back to a
+ // content match for rows saved before ids existed.
+ _mutateStore(get, save, entry, index, fn, label) {
+ const fresh = get.call(this);
+ let i = -1;
+ if (entry && entry.id) i = fresh.findIndex(e => e && e.id === entry.id);
+ if (i < 0 && entry) {
+ i = fresh.findIndex(e => e && e.name === entry.name && e.date === entry.date && e.json === entry.json);
+ }
+ if (i < 0 && index != null && index >= 0 && index < fresh.length) i = index;
+ if (i < 0) {
+ this._toast('That entry is no longer there. It may have been changed in another tab.');
+ return false;
+ }
+ fn(fresh, i);
+ if (!save.call(this, fresh)) {
+ this._toast(`Could not update ${label}. Browser storage is full or blocked.`);
+ return false;
+ }
+ return true;
+ }
+
// Returns false when the write fails (storage full or blocked) so callers
// can tell the user instead of toasting a false "Saved".
_saveLibrary(lib) {
@@ -119,8 +151,9 @@ class AppLibrary {
const nodes = [...ids].map(id => this.diagram.nodes.get(id)).filter(Boolean).map(n => n.toJSON());
const conns = [...this.diagram.connections.values()]
.filter(c => ids.has(c.sourceId) && ids.has(c.targetId)).map(c => c.toJSON());
+ // Fresh read, so a save here does not drop what another tab added.
const list = this._getComponents();
- list.push({ name, date: new Date().toLocaleString(), nodes, conns });
+ list.push({ id: this._entryKey(), name, date: new Date().toLocaleString(), nodes, conns });
if (!this._saveComponents(list)) {
this._toast(`Could not save "${name}". Browser storage is full or blocked.`);
return;
@@ -183,8 +216,8 @@ class AppLibrary {
delBtn.setAttribute('aria-label', 'Delete component');
delBtn.className = 'btn';
delBtn.addEventListener('click', () => {
- list.splice(i, 1);
- if (!this._saveComponents(list)) this._toast('Could not update components. Browser storage is blocked.');
+ this._mutateStore(this._getComponents, this._saveComponents, comp, i,
+ (l, at) => l.splice(at, 1), 'components');
this._renderComponentsList();
});
btns.appendChild(insertBtn);
@@ -298,9 +331,9 @@ class AppLibrary {
this._openMenu(r.left, r.bottom + 4, (add, sep) => {
add('Rename…', 'pen', () => this._renameLibraryEntry(row, entry, i));
add('Duplicate', 'clone', () => {
- const copy = { ...entry, name: `${entry.name} copy`, date: new Date().toLocaleString() };
- lib.splice(i + 1, 0, copy);
- if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
+ const copy = { ...entry, id: this._entryKey(), name: `${entry.name} copy`, date: new Date().toLocaleString() };
+ this._mutateStore(this._getLibrary, this._saveLibrary, entry, i,
+ (list, at) => list.splice(at + 1, 0, copy), 'the Library');
this._renderLibraryList();
});
add('Export as JSON', 'download', () => {
@@ -312,8 +345,8 @@ class AppLibrary {
});
sep();
add('Delete', 'trash-can', () => {
- lib.splice(i, 1);
- if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
+ this._mutateStore(this._getLibrary, this._saveLibrary, entry, i,
+ (list, at) => list.splice(at, 1), 'the Library');
this._renderLibraryList();
}, { danger: true });
});
@@ -374,9 +407,8 @@ class AppLibrary {
done = true;
const name = input.value.trim();
if (save && name && name !== entry.name) {
- const lib = this._getLibrary();
- if (lib[index]) { lib[index].name = name; }
- if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
+ this._mutateStore(this._getLibrary, this._saveLibrary, entry, index,
+ (list, at) => { list[at].name = name; }, 'the Library');
}
this._renderLibraryList();
};
diff --git a/js/app.js b/js/app.js
index 9dc1d24..96e414a 100644
--- a/js/app.js
+++ b/js/app.js
@@ -84,6 +84,7 @@ class App {
this.timeline.onInspect = (nodeId, index, cx, cy) => this._showWhyPopover(nodeId, index, cx, cy);
this._bindControls();
+ this._watchForeignAutosave();
this._initLibrary();
this._initMenus();
this._initPalette();
@@ -516,6 +517,22 @@ class App {
this._persistAutosave();
}
+ // localStorage is shared by every tab on this origin and sim_autosave is a
+ // single slot, so the tab that saves last silently becomes the saved copy and
+ // the other tab carries on believing its work is safe. Nothing can merge two
+ // diagrams, but the tab that has been superseded can at least be told, once,
+ // while its work is still on screen and can be exported. The storage event
+ // fires only in the OTHER tabs, so a write never warns the tab that made it.
+ _watchForeignAutosave() {
+ window.addEventListener('storage', (e) => {
+ if (e.key !== 'sim_autosave' || e.newValue == null) return;
+ if (document.body.classList.contains('embed')) return;
+ if (this._autosaveTakenOver) return;
+ this._autosaveTakenOver = true;
+ this._toast('Another tab just saved over this browser\'s autosave. Work in this tab is no longer the saved copy, so use File > Save as JSON to keep it.');
+ });
+ }
+
// Two snapshots describe the same diagram. The module-level id counter rides
// along in the JSON and loadJSON only ever raises it, so that ids handed out
// since cannot collide, which means restoring a snapshot never reproduces its
diff --git a/test/smoke.js b/test/smoke.js
index 0c7613f..d3592c8 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -2588,6 +2588,82 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/';
ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`);
else fail('components: ' + JSON.stringify(comp));
+ // Multi-tab: two tabs share one localStorage. A Library edit made in one must
+ // not erase what the other saved, and a tab whose autosave slot is taken over
+ // has to be told rather than carry on believing its work is safe.
+ const multiTab = await (async () => {
+ const ctx = await browser.newContext();
+ const mk = async () => {
+ const p2 = await ctx.newPage();
+ await p2.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' }));
+ await p2.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} });
+ await p2.goto(URL, { waitUntil: 'networkidle' });
+ return p2;
+ };
+ const tabA = await mk();
+ const tabB = await mk();
+ const saveEntry = (p2, label) => p2.evaluate((nm) => {
+ const app = window.app;
+ app._clearAll();
+ const n = new MNode(NodeType.POOL, 200, 200); n.label = nm;
+ app.diagram.addNode(n); app.renderer.render(); app._commit();
+ document.getElementById('btn-library').click();
+ document.getElementById('lib-name').value = nm;
+ document.getElementById('lib-save').click();
+ return null;
+ }, label);
+
+ // Spy on tab A's toasts before anything cross-tab happens: the takeover
+ // warning fires once per tab, so installing it later would miss it.
+ await tabA.evaluate(() => {
+ window.__toasts = [];
+ const t = window.app._toast.bind(window.app);
+ window.app._toast = (m) => { window.__toasts.push(m); return t(m); };
+ });
+ await saveEntry(tabA, 'FromA');
+ await tabA.waitForTimeout(400);
+ await tabB.reload({ waitUntil: 'networkidle' }); // B now sees A's entry
+ // A renders its Library list NOW, holding only FromA...
+ await tabA.evaluate(() => {
+ window.app._openLibrary();
+ window.app._setLibraryTab('mine');
+ });
+ // ...and only then does B save, so A's rendered list is stale from here on.
+ // That is the shape of the bug: the row's Delete wrote back the array the
+ // list was drawn from, taking FromB with it.
+ await saveEntry(tabB, 'FromB');
+ await tabB.waitForTimeout(400);
+ await tabA.click('button[aria-label="More actions for \\"FromA\\""]');
+ await tabA.waitForSelector('#ctx-menu .menu-item', { timeout: 2000 }).catch(() => {});
+ const clickedDelete = await tabA.evaluate(() => {
+ const item = [...document.querySelectorAll('#ctx-menu .menu-item')]
+ .find(b => /^\s*Delete\s*$/.test(b.textContent));
+ if (!item) return false;
+ item.click();
+ return true;
+ });
+ await tabA.waitForTimeout(200);
+ const deleted = await tabA.evaluate(() => {
+ try { return JSON.parse(localStorage.getItem('sim_library') || '[]').map(e => e.name); }
+ catch { return ['READ_FAILED']; }
+ });
+
+ // Autosave takeover warning: B's saves reach A's storage event.
+ await tabB.evaluate(() => {
+ const app = window.app;
+ const n = new MNode(NodeType.POOL, 500, 400); n.label = 'BEdit';
+ app.diagram.addNode(n); app.renderer.render(); app._commit();
+ });
+ await tabA.waitForTimeout(600);
+ const warned = await tabA.evaluate(() => (window.__toasts || []).some(m => /no longer the saved copy/i.test(m)));
+ await ctx.close();
+ return { clickedDelete, afterDelete: deleted, warned };
+ })();
+ if (multiTab.clickedDelete && multiTab.afterDelete.includes('FromB')
+ && !multiTab.afterDelete.includes('FromA') && multiTab.warned)
+ ok('multi-tab: a Library delete keeps the other tab\'s entry, and autosave takeover is announced');
+ else fail('multi-tab: ' + JSON.stringify(multiTab));
+
// Properties: at step 0 the amount field is the Starting amount, so the +/-
// buttons have to move the reset baseline. They only moved the live count, so
// the canvas, undo and autosave all showed the new number and Reset or a
From b3b60e887c5890e382008c8c395a45757976e10b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 31 Aug 2026 15:46:41 +0000
Subject: [PATCH 4/4] Keep painting out of the RNG, bound the animation layer,
and stop three panels lying
Round-6 findings 2, 5, 8, 9, 10, 11 and 12.
Drawing a gate's weight label mirrors the engine and evaluates the formula, and
a weight formula can draw random numbers. The canvas repaints on hover, pan,
zoom, selection and every step, so painting was consuming draws the run was
about to make: the same diagram under the same seed produced different results
depending on how much the user moved the mouse. Measured directly: a single
render moved the seeded stream position. Label evaluation now saves and restores
it, the way detectLoops already guards its probes.
The animation layer had no ceiling and no idea when it was off screen. Cleanup
runs from requestAnimationFrame, which a browser suspends in a hidden tab while
the setInterval driving the steps keeps firing, so a run left in another tab
produced dots that nothing consumed: measured growing to 2431 live elements.
Even in the foreground a heavy model at the top of the speed slider held 838 at
once, each costing a getPointAtLength per frame, which is what dragged the page
to about 17fps and made the run fall behind the speed the user asked for. There
is now a hard ceiling of 240 live dots, spawning is skipped entirely while the
document is hidden, and a visibilitychange drops what is in flight and repaints
on return.
Hiding the overflow menu in embeds last round left a narrow embed with no way to
reset a run at all: the small-screen rules hide Reset from the topbar on the
assumption that the menu still carries it. An embed offers Run, Step and Reset,
so Reset comes back at those widths.
Loading a starter template left the Simulation panel showing the empty canvas it
had been drawn against: a blank Name and a FILE block reading 0 nodes and 0
connections beside a canvas full of them. _installTemplate repaints the panel.
Food Web claimed "Ten species lock into coupled, bounded oscillations", but the
Foxes crash to 1 and stay there for the rest of the run while foxBirths sits at
0. This is not a mistuned constant: at three times the birth rate and a quarter
of the death rate they still peak at 167 and collapse to 1, because their
break-even prey population is near the rabbit peak and they eat the base out
from under themselves. That is a real dynamic of this coupling, so the mechanics
are untouched and the description now says what the model does. Retuning the
predator-prey coupling to make foxes persist is a design decision about the demo
rather than a defect fix, and the surrounding web is sensitive to it: pushing
fox efficiency collapsed the rabbit oscillation from a span of 93 to 17.
The smoke suite gained a standing check that every bundled template runs 120
steps with every value finite, since it previously only asserted that they load.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
---
css/style.css | 7 +++
js/app-demos.js | 12 +++-
js/app-library.js | 7 ++-
js/app.js | 16 ++++++
js/renderer.js | 29 +++++++++-
test/smoke.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 207 insertions(+), 6 deletions(-)
diff --git a/css/style.css b/css/style.css
index 0f4a1df..e008b1f 100644
--- a/css/style.css
+++ b/css/style.css
@@ -1550,6 +1550,13 @@ body.embed .analysis-controls,
above just removed straight back: the palette, the file controls, New
diagram, Open file, Save as JSON and the rest. */
body.embed #btn-mobile-menu { display: none; }
+/* ...which leaves an embed at these widths, and most iframes are at these
+ widths, with no way to reset a run at all: the small-screen rules hide Reset
+ from the topbar on the assumption that the overflow menu still carries it.
+ An embed offers Run, Step and Reset and nothing else, so Reset comes back. */
+@media (max-width: 768px) {
+ body.embed #btn-reset { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; }
+}
body.embed #topbar { min-height: 44px; }
.embed-open {
margin-left: auto; font-size: 11px; color: var(--text-faint); white-space: nowrap;
diff --git a/js/app-demos.js b/js/app-demos.js
index 1efe052..ac02446 100644
--- a/js/app-demos.js
+++ b/js/app-demos.js
@@ -857,7 +857,11 @@ class AppDemos {
// grazers and a detritivore eat the base; two carnivores hunt them; Hawks are
// the apex; a decomposer loop returns dead biomass to nutrients. Predation is
// Lotka-Volterra formula rates; growth/death are register+modifier pairs. With
- // no goal set, all ten populations settle into coupled, bounded oscillations.
+ // no goal set, the populations cycle against each other. Foxes are deliberately
+ // left over-exploiting: they boom, crash the rabbit base, and cannot recover,
+ // which is a real dynamic of this coupling rather than a mistuned constant.
+ // Measured: even at 3x the birth rate and a quarter of the death rate they
+ // still peak (167) and collapse to 1.
_demoFoodWeb() {
const b = this._demo();
b.d.resourceTypes = [
@@ -1071,8 +1075,10 @@ class AppDemos {
b.note(60, 1060, 540, 130,
'Predation uses Lotka-Volterra formula rates (coef·prey·pred); growth & death use ' +
'register+modifier pairs. Two periodic drivers (sunFactor, rainFactor) force the ' +
- 'producers. No goal is set, yet all ten populations lock into coupled, bounded ' +
- 'oscillations, predator peaks lagging prey. Press Run.');
+ 'producers. No goal is set, yet the populations cycle against each other with ' +
+ 'predator peaks lagging prey. Watch the Foxes: they boom on plentiful Rabbits, ' +
+ 'eat the prey base out from under themselves, and crash to a level their birth ' +
+ 'rate cannot climb back from. Press Run.');
this.renderer.render();
}
diff --git a/js/app-library.js b/js/app-library.js
index dce73f7..3d65de1 100644
--- a/js/app-library.js
+++ b/js/app-library.js
@@ -23,7 +23,7 @@ class AppLibrary {
{ name: 'Civilization Empire', desc: 'A 4X economy in one diagram: logistic population, five yields, building converters, and a Science-gated tech tree (irrigation, drama, banking, university).', load: () => this._demoCiv() },
{ name: 'Megafactory Line', desc: 'A 4-tier auto-factory: ore → smelting → components → widgets. A tiny circuit buffer and a slow assembly station back the line up. Watch the bottleneck.', load: () => this._demoFactory() },
{ name: 'Business Cycle', desc: 'A full circular-flow macroeconomy with households, firms, banks, government and a central bank. Countercyclical stimulus through a policy lag drives a boom-bust cycle.', load: () => this._demoBusinessCycle() },
- { name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Ten species lock into coupled, bounded oscillations.', load: () => this._demoFoodWeb() },
+ { name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Populations cycle against each other, and a predator that over-hunts its prey can collapse without recovering.', load: () => this._demoFoodWeb() },
{ name: 'Auction Economy', desc: 'A player-driven MMO economy: gather, refine and craft goods, then watch the auction house prices and stocks oscillate as supply meets price-elastic demand.', load: () => this._demoAuction() },
];
@@ -276,6 +276,11 @@ class AppLibrary {
this.engine.reset();
this._commitReplace(prev);
this.renderer.fitView();
+ // Repaint the panel. The Simulation rail panel reads the diagram's metadata
+ // when it is drawn, so after a template load it went on showing the empty
+ // canvas it had been drawn against: a blank Name and a FILE block reading
+ // 0 nodes, 0 connections, next to a canvas full of them.
+ this._renderProps();
}
async _loadTemplate(t) {
diff --git a/js/app.js b/js/app.js
index 96e414a..bb400de 100644
--- a/js/app.js
+++ b/js/app.js
@@ -85,6 +85,7 @@ class App {
this._bindControls();
this._watchForeignAutosave();
+ this._watchVisibility();
this._initLibrary();
this._initMenus();
this._initPalette();
@@ -523,6 +524,21 @@ class App {
// diagrams, but the tab that has been superseded can at least be told, once,
// while its work is still on screen and can be exported. The storage event
// fires only in the OTHER tabs, so a write never warns the tab that made it.
+ // A hidden tab suspends requestAnimationFrame but keeps the setInterval that
+ // drives the run, so the animation layers were produced into and never
+ // consumed. Drop what is in flight when the tab goes away, and repaint when it
+ // comes back so the canvas matches the model rather than a stale frame.
+ _watchVisibility() {
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ this.renderer.balls.clear();
+ this.renderer.flowFx.clear();
+ } else {
+ this.renderer.render();
+ }
+ });
+ }
+
_watchForeignAutosave() {
window.addEventListener('storage', (e) => {
if (e.key !== 'sim_autosave' || e.newValue == null) return;
diff --git a/js/renderer.js b/js/renderer.js
index ae5990c..67431fb 100644
--- a/js/renderer.js
+++ b/js/renderer.js
@@ -218,11 +218,26 @@ class BallSystem {
this._running = false;
}
+ // Ceiling on simultaneously animating dots (see spawn).
+ static get MAX_LIVE() { return 240; }
+
spawn(pathEl, amount, color, durationMs) {
if (!pathEl) return;
// Respect the user's motion preference: transfers still happen, the
// travelling-ball animation is simply skipped.
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ // Nothing to animate for, and nothing that would ever clear it. Cleanup runs
+ // from requestAnimationFrame, which a browser suspends in a hidden tab while
+ // it keeps the setInterval that drives the steps running (throttled). So a
+ // run left in a background tab kept producing dots that nothing consumed:
+ // measured climbing past 30,000 elements with the step cost going from 27ms
+ // to 485ms. Producing only while something can consume keeps the two in step.
+ if (typeof document !== 'undefined' && document.hidden) return;
+ // Hard ceiling on live dots. They are decoration: past this many the canvas
+ // is a solid mass of them anyway, and each one costs a getPointAtLength per
+ // frame. A big model at the top of the speed slider used to hold 1,500 at
+ // once and drag the page to about 17fps.
+ if (this._balls.length >= BallSystem.MAX_LIVE) return;
const capped = Math.min(amount, 12);
const pathLen = pathEl.getTotalLength();
if (pathLen < 1) return;
@@ -1446,9 +1461,19 @@ class Renderer {
if (fromGate) {
const gmode = src.gateMode === 'random' ? 'probabilistic' : src.gateMode;
// Mirror engine._connWeight so formula weights make the labels live.
+ // Drawing a label must not advance the shared RNG: a weight formula can
+ // draw random numbers, and the canvas repaints on hover, pan, zoom,
+ // selection and every step, so painting was eating draws the run was
+ // about to make. The same diagram under the same seed then produced
+ // different results depending on how much the user moved the mouse.
+ // detectLoops guards its own probes the same way.
const getW = c => {
- if (c.weightFormula) { const w = evalFormula(c.weightFormula, this.diagram.variables); return isFinite(w) && w >= 0 ? w : 0; }
- const w = Number(c.weight); return isFinite(w) && w >= 0 ? w : 1;
+ if (!c.weightFormula) { const w = Number(c.weight); return isFinite(w) && w >= 0 ? w : 1; }
+ const rng = SimRandom.getState();
+ try {
+ const w = evalFormula(c.weightFormula, this.diagram.variables);
+ return isFinite(w) && w >= 0 ? w : 0;
+ } finally { SimRandom.setState(rng); }
};
if (gmode === 'probabilistic') {
const allOuts = [...this.diagram.connections.values()]
diff --git a/test/smoke.js b/test/smoke.js
index d3592c8..c96aeb2 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -2588,6 +2588,148 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/';
ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`);
else fail('components: ' + JSON.stringify(comp));
+ // Determinism: painting the canvas must not advance the seeded RNG. A gate's
+ // weight formula can draw random numbers and the label mirrors it, so every
+ // repaint (hover, pan, zoom, selection, each step) was eating draws the run
+ // was about to make.
+ const paintRng = await page.evaluate(() => {
+ const app = window.app;
+ app._clearAll(); app._closeFeature();
+ app.diagram.seed = 'fixed-42';
+ const g = new MNode(NodeType.GATE, 300, 300); g.label = 'G'; g.setCount(200); g.gateMode = 'random';
+ const p1 = new MNode(NodeType.POOL, 600, 200); p1.label = 'A';
+ const p2 = new MNode(NodeType.POOL, 600, 400); p2.label = 'B';
+ [g, p1, p2].forEach(n => app.diagram.addNode(n));
+ const c1 = new MConnection(g.id, p1.id, ConnectionType.RESOURCE);
+ c1.weightFormula = 'randomInt(1,10)';
+ const c2 = new MConnection(g.id, p2.id, ConnectionType.RESOURCE); c2.weight = 1;
+ app.diagram.addConnection(c1); app.diagram.addConnection(c2);
+ app.engine.reset();
+ const before = SimRandom.getState();
+ app.renderer.render();
+ const afterPlain = SimRandom.getState();
+ app.editor._select(c1.id, 'conn');
+ const mid = SimRandom.getState();
+ app.renderer.render();
+ const afterSelected = SimRandom.getState();
+ return { seeded: before !== null, movedPlain: before !== afterPlain, movedSelected: mid !== afterSelected };
+ });
+ if (paintRng.seeded && !paintRng.movedPlain && !paintRng.movedSelected)
+ ok('determinism: repainting the canvas leaves the seeded RNG where it was');
+ else fail('paint rng: ' + JSON.stringify(paintRng));
+
+ // Embed at iframe widths must still offer Reset: the small-screen rules hide
+ // it on the assumption the overflow menu carries it, and an embed hides that.
+ const embedReset = await (async () => {
+ const ctx = await browser.newContext({ viewport: { width: 700, height: 600 } });
+ const ep = await ctx.newPage();
+ await ep.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' }));
+ await ep.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} });
+ await ep.goto(URL + '#embed', { waitUntil: 'networkidle' });
+ const r = await ep.evaluate(() => {
+ const vis = (id) => {
+ const e = document.getElementById(id);
+ if (!e) return false;
+ return getComputedStyle(e).display !== 'none' && e.getBoundingClientRect().width > 0;
+ };
+ return { reset: vis('btn-reset'), run: vis('btn-run'), step: vis('btn-step'), menu: vis('btn-mobile-menu') };
+ });
+ await ctx.close();
+ return r;
+ })();
+ if (embedReset.reset && embedReset.run && embedReset.step && !embedReset.menu)
+ ok('embed: a narrow embed still offers Run, Step and Reset');
+ else fail('embed reset: ' + JSON.stringify(embedReset));
+
+ // Animation layer: cleanup runs from requestAnimationFrame, which a browser
+ // suspends in a hidden tab while the setInterval driving the run keeps going,
+ // so the layer was produced into and never consumed. Two guarantees: a hard
+ // ceiling on live dots, and nothing produced at all while the tab is hidden.
+ // (Headless Chromium reports a backgrounded page as visible, so the hidden
+ // half is driven through the event rather than by switching tabs.)
+ const ballLayer = await (async () => {
+ const ctx = await browser.newContext({ reducedMotion: 'no-preference' });
+ const bp = await ctx.newPage();
+ await bp.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' }));
+ await bp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} });
+ await bp.goto(URL, { waitUntil: 'networkidle' });
+ // Heavy model at the top of the speed slider.
+ await bp.evaluate(() => {
+ const app = window.app;
+ app._clearAll();
+ app._templates.find(t => /business/i.test(t.name)) ? app._installTemplate(app._templates.find(t => /business/i.test(t.name))) : app._loadDemo();
+ app.engine.speed = 10;
+ app.engine.run();
+ });
+ await bp.waitForTimeout(2500);
+ const peak = await bp.evaluate(() => (window.app.renderer.balls._balls || []).length);
+ const cap = await bp.evaluate(() => BallSystem.MAX_LIVE);
+ // Now the hidden half, driven through the real handler.
+ const whenHidden = await bp.evaluate(() => {
+ const app = window.app;
+ const orig = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden');
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ const clearedTo = (app.renderer.balls._balls || []).length;
+ // Spawning must be refused outright while hidden.
+ for (let i = 0; i < 5; i++) app.engine.doStep();
+ const afterSteps = (app.renderer.balls._balls || []).length;
+ delete document.hidden;
+ if (orig) Object.defineProperty(Document.prototype, 'hidden', orig);
+ app.engine.stop();
+ return { clearedTo, afterSteps };
+ });
+ await ctx.close();
+ return { peak, cap, ...whenHidden };
+ })();
+ if (ballLayer.peak <= ballLayer.cap && ballLayer.clearedTo === 0 && ballLayer.afterSteps === 0)
+ ok(`animation: live dots stay under the ${ballLayer.cap} cap (peak ${ballLayer.peak}) and stop entirely while hidden`);
+ else fail('ball layer: ' + JSON.stringify(ballLayer));
+
+ // Library: loading a template must refresh the Simulation panel, which reads
+ // the diagram metadata when it is drawn.
+ const tmplPanel = await page.evaluate(() => {
+ const app = window.app;
+ app._clearAll(); app._closeFeature();
+ const t = app._templates[0];
+ app._installTemplate(t);
+ const txt = document.getElementById('props-content').textContent;
+ const nameInput = [...document.querySelectorAll('#props-content input')].find(i => i.type === 'text');
+ return {
+ tmplName: t.name, nodes: app.diagram.nodes.size,
+ panelName: nameInput ? nameInput.value : null,
+ panelNodes: Number((txt.match(/Nodes\s*(\d+)/) || [])[1]),
+ };
+ });
+ if (tmplPanel.panelName === tmplPanel.tmplName && tmplPanel.panelNodes === tmplPanel.nodes)
+ ok('library: loading a template refreshes the Simulation panel');
+ else fail('template panel: ' + JSON.stringify(tmplPanel));
+
+ // Demos: no bundled template may produce a non-finite value. Smoke only ever
+ // checked that they load, so a demo could go to NaN and CI would stay green.
+ const demoSanity = await page.evaluate(() => {
+ const app = window.app;
+ const bad = [];
+ for (const t of app._templates) {
+ app._clearAll();
+ app._installTemplate(t);
+ app.engine.reset();
+ for (let i = 0; i < 120; i++) app.engine.doStep();
+ for (const n of app.diagram.nodes.values()) {
+ const v = n.chartValue;
+ if (v != null && !Number.isFinite(v)) bad.push(`${t.name}:${n.label || n.type}=${v}`);
+ }
+ }
+ // Leave a neutral canvas behind: fitView left the zoom at whatever the last
+ // template needed, and later checks assume 100%.
+ app._clearAll();
+ app.renderer.resetView();
+ return { templates: app._templates.length, bad };
+ });
+ if (demoSanity.templates >= 10 && demoSanity.bad.length === 0)
+ ok(`demos: all ${demoSanity.templates} templates run 120 steps with every value finite`);
+ else fail('demo sanity: ' + JSON.stringify(demoSanity));
+
// Multi-tab: two tabs share one localStorage. A Library edit made in one must
// not erase what the other saved, and a tab whose autosave slot is taken over
// has to be told rather than carry on believing its work is safe.