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
42 changes: 33 additions & 9 deletions cli.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env node
// Headless CLI runner — simulate a diagram without a browser.
// Headless CLI runner. Simulate a diagram without a browser.
//
// node cli.js <diagram.json | economy.econ> [options]
//
// Options:
// --steps N steps to simulate (default 200)
// --runs N Monte Carlo: run N isolated trials and print summary
// stats instead of a single-run trace (default 1)
// --seed S seed the RNG — same seed, same results
// --seed S seed the RNG (same seed, same results)
// --param name=val override a diagram parameter (repeatable)
// --csv with --runs>1: print raw per-run final values as CSV
// (one row per run) instead of the stats table
Expand Down Expand Up @@ -78,6 +78,18 @@ function fail(msg) {
process.exit(1);
}

// Strict numeric option parse. parseFloat/parseInt stop at the first character
// they cannot use, so `--param carrying=1,000` quietly ran with a carrying
// capacity of 1 and `--steps 30x` with 30, both exiting 0 on a different
// simulation than the one asked for. A malformed value is a usage error.
function num(raw, what) {
const v = String(raw ?? '').trim();
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(v)) {
fail(`${what} expects a number, got "${raw ?? ''}"`);
}
return parseFloat(v);
}

function parseArgs(argv) {
const opts = {
steps: 200, runs: 1, seed: null, params: {}, csv: false, file: null,
Expand All @@ -86,8 +98,8 @@ function parseArgs(argv) {
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--steps') opts.steps = parseInt(argv[++i], 10);
else if (a === '--runs') opts.runs = parseInt(argv[++i], 10);
if (a === '--steps') opts.steps = num(argv[++i], '--steps');
else if (a === '--runs') opts.runs = num(argv[++i], '--runs');
else if (a === '--seed') opts.seed = argv[++i];
else if (a === '--csv') opts.csv = true;
else if (a === '--assert') opts.asserts.push(argv[++i]);
Expand All @@ -102,7 +114,7 @@ function parseArgs(argv) {
}
}
else if (a === '--check') opts.check = true;
else if (a === '--pass-rate') opts.passRate = parseFloat(argv[++i]);
else if (a === '--pass-rate') opts.passRate = num(argv[++i], '--pass-rate');
else if (a === '--emit') opts.emit = argv[++i];
else if (a === '--to-dsl') opts.toDsl = true;
else if (a === '--to-json') opts.toJson = true;
Expand All @@ -111,10 +123,17 @@ function parseArgs(argv) {
else if (a === '--param') {
const m = String(argv[++i] || '').match(/^([^=]+)=(.+)$/);
if (!m) fail(`--param expects name=value, got "${argv[i]}"`);
opts.params[m[1]] = parseFloat(m[2]);
opts.params[m[1]] = num(m[2], `--param ${m[1]}`);
} else if (a === '--help' || a === '-h') {
process.stdout.write(fs.readFileSync(__filename, 'utf8').split('\n')
.filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n') + '\n');
// The header block only. Filtering the whole file for `//` lines swept up
// implementation notes from the middle of the source and printed them as
// help ("Same loading trick as test/run.js", section rules, and so on).
const help = [];
for (const l of fs.readFileSync(__filename, 'utf8').split('\n').slice(1)) {
if (!l.startsWith('//')) break;
help.push(l.slice(3));
}
process.stdout.write(help.join('\n') + '\n');
process.exit(0);
} else if (!a.startsWith('-') && !opts.file) opts.file = a;
else fail(`Unknown option: ${a}`);
Expand Down Expand Up @@ -167,7 +186,12 @@ if (opts.toJson) {
}
if (opts.loops) {
const { loops, truncated } = detectLoops(diagram);
if (!loops.length) process.stdout.write('No feedback loops found.\n');
// A truncated search that found nothing has not shown there is nothing.
if (!loops.length) {
process.stdout.write(truncated
? 'No feedback loops found within the search limit (the graph is large, so a longer loop could still exist).\n'
: 'No feedback loops found.\n');
}
for (const l of loops) {
const chain = [...l.labels, l.labels[0]].join(' -> ');
const linkStr = l.links.map(x => `${x.sign > 0 ? '+' : (x.sign < 0 ? '-' : '?')}${[...x.kinds].join('/')}`).join(', ');
Expand Down
15 changes: 12 additions & 3 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1483,9 +1483,18 @@ input[type="range"] {
so the whole footer (Replay, the slider, the step label, Back to live) sat
below the fold with between zero and three usable pixels. html and body do
not scroll and #tl-resize is mousedown-only, so there was no way to reach
it. Fit the content instead of cropping it, and let the drawer scroll if a
long legend still overflows. */
#timeline { height: auto; min-height: 150px; max-height: 46vh; overflow-y: auto; }
it.
Sizing to content instead overshot the other way: #timeline-canvas is a
<canvas>, so its intrinsic height (whatever it last rendered at) drives the
drawer's content height however small its flex basis is, and the drawer
took 324px of a 1024px screen with a 230px chart in it. Zero the canvas's
CSS height so the flex layout, not the bitmap, decides how tall it is; the
90px min-height on the base rule is still its floor. That holds the drawer
at a steady 190px from 320px wide up to 768px, with the scrub row inside it
even at the legend's two-row cap, and dragging #tl-resize still grows the
chart rather than opening a gap under it. */
#timeline-canvas { height: 0; }
#timeline { height: 190px; min-height: 0; max-height: 40vh; overflow-y: auto; }
/* The scrub row is the reason the drawer exists on a phone; never let it
shrink away under a tall legend. */
#tl-scrub { flex-shrink: 0; }
Expand Down
3 changes: 3 additions & 0 deletions docs/ECONOMY_AS_CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,8 @@ Notes:
seeded economy at a time per process for bit-exact reproducibility.
- `createEconomy()` parses a fresh copy of the embedded diagram each call, so
instances never share mutable state.
- `set()` keeps the node's own resource type: topping a pool up adds units of
whatever it already holds (or of the type it was authored with, when empty),
so colour-filtered connections and converter recipes still accept them.
- The module also exports `Diagram`, `SimEngine`, `SimRandom` and `NodeType`
for power users who want to go under the hood.
29 changes: 24 additions & 5 deletions js/app-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ class AppAnalysis {
sel.disabled = false;
document.getElementById('mc-sweep-run').disabled = false;
for (const n of names) sel.appendChild(new Option(n, n));
// Seed the range around the parameter's current value.
const cur = this.diagram.params[names[0]];
document.getElementById('mc-sweep-from').value = Math.round(cur * 0.5 * 100) / 100;
document.getElementById('mc-sweep-to').value = Math.round(cur * 1.5 * 100) / 100;
this._seedSweepRange(names[0]);
}
// Sensitivity needs at least one parameter with a non-zero value (a percent
// perturbation of 0 is a no-op).
Expand All @@ -44,6 +41,23 @@ class AppAnalysis {
this._showModal('mc-overlay');
}

// Seed the sweep's from/to around a parameter's current value. This ran once,
// for the first parameter in the list, and nothing re-ran it when the user
// picked a different one: sweeping any other parameter silently used the
// first one's range, which for a rate of 3 against a capacity of 500 meant
// sweeping 1.5 to 4.5.
_seedSweepRange(name) {
const cur = this.diagram.params[name];
if (!isFinite(cur)) return;
const round = v => Math.round(v * 100) / 100;
// A parameter sitting at 0 has no scale to spread around, so offer a small
// absolute range rather than 0 to 0.
const from = cur === 0 ? 0 : round(cur * 0.5);
const to = cur === 0 ? 1 : round(cur * 1.5);
document.getElementById('mc-sweep-from').value = from;
document.getElementById('mc-sweep-to').value = to;
}

_mcSeed() {
return document.getElementById('mc-seed').value.trim();
}
Expand Down Expand Up @@ -647,7 +661,12 @@ class AppAnalysis {
if (!loops.length) {
const p = document.createElement('p');
p.className = 'props-empty';
p.textContent = 'No feedback loops found. Loops appear when influence returns to where it started, for example a pool feeding a converter whose output flows back, or a register that modifies the pool it reads.';
// A truncated search that found nothing has not shown there is nothing:
// saying so flatly told users of one long ring that their diagram had no
// loops at all.
p.textContent = truncated
? 'No feedback loops found within the search limit. This graph is large enough that the search stopped early, so a longer or more tangled loop could still exist. Try splitting the diagram into smaller pieces to check.'
: 'No feedback loops found. Loops appear when influence returns to where it started, for example a pool feeding a converter whose output flows back, or a register that modifies the pool it reads.';
panel.appendChild(p);
return;
}
Expand Down
34 changes: 29 additions & 5 deletions js/app-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ class AppExport {
a.click();
}

// Largest raster scale that still fits inside the browser's canvas limits.
// Browsers cap both a canvas's longest side and its total area; past either,
// drawImage silently does nothing and toDataURL hands back a blank image, so
// a big diagram used to download a 0-byte PNG with no error anywhere.
// 2x (crisp on high-DPI screens) whenever it fits, less when it does not.
_pngScale(w, h) {
const MAX_DIM = 16384; // conservative across browsers
const MAX_AREA = 2.0e8; // Chromium's ceiling is 268,435,456 px
if (!(w > 0) || !(h > 0)) return 1;
return Math.max(0.02, Math.min(2, MAX_DIM / w, MAX_DIM / h, Math.sqrt(MAX_AREA / (w * h))));
}

_exportPNG() {
const built = this._buildExportSVG();
if (!built) return;
Expand All @@ -118,19 +130,31 @@ class AppExport {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
// 2x the diagram's natural size, for crispness on high-DPI screens.
const scale = this._pngScale(w, h);
const canvas = document.createElement('canvas');
canvas.width = w * 2; canvas.height = h * 2;
canvas.width = Math.max(1, Math.floor(w * scale));
canvas.height = Math.max(1, Math.floor(h * scale));
const ctx = canvas.getContext('2d');
ctx.scale(2, 2);
ctx.scale(scale, scale);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
let href = '';
try { href = canvas.toDataURL('image/png'); } catch { /* tainted or oversized */ }
URL.revokeObjectURL(url);
// A canvas the browser refused to rasterize returns a stub or nothing at
// all. Better to say so than to hand over an empty file.
if (!href || href.length < 128) {
this._toast('This diagram is too large to export as PNG. Export SVG instead, which has no size limit.');
return;
}
const a = Object.assign(document.createElement('a'), {
download: this._exportFilename('png'), href: canvas.toDataURL('image/png'),
download: this._exportFilename('png'), href,
});
a.click();
URL.revokeObjectURL(url);
if (scale < 2) {
this._toast(`This diagram is large, so the PNG was rendered at ${Math.round(scale * 100)}% to stay inside the browser's canvas limit. SVG exports at full size.`);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
Expand Down
26 changes: 23 additions & 3 deletions js/app-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,27 @@ class AppProps {
panel.appendChild(delRow);
}

// Apply an amount edit from the properties panel. Mid-run a Delay's or a
// Queue's count is not free-standing state: it is the total of the in-flight
// pipeline (`_queue`, or `_fifo` plus the units in service), which the model
// primitives know nothing about. Editing the count alone leaves the pipeline
// owing the old amount, so it releases resources the node no longer has, or
// strands ones nothing will ever release. At rest the count is the starting
// baseline and the engine seeds the pipeline from it when the run begins, so
// the plain primitives are right there.
// `nudge` keeps a mixed holding's colours intact for the +/- steppers; the
// typed field deliberately retypes the whole amount to a single colour.
_setLiveAmount(node, target, color, nudge = false) {
if (this.engine.step > 0 && (node.type === NodeType.DELAY || node.type === NodeType.QUEUE)) {
this.engine.setLiveCount(node, target, color);
return;
}
if (!nudge) { node.setCount(Math.max(0, target), color); return; }
const delta = Math.max(0, target) - node.resources;
if (delta > 0) node.addResources(delta, color);
else if (delta < 0) node.takeResources(-delta);
}

_nodeProps(panel, node) {
const typeColor = (typeof NODE_STROKE !== 'undefined' && NODE_STROKE[node.type]) || 'var(--accent)';
this._titleTyped(panel, `${node.type} node`, node.label || '(unnamed)', typeColor, `node-${node.type}`);
Expand Down Expand Up @@ -1460,7 +1481,7 @@ class AppProps {
// came back to a number typed mid-run rather than the run's start.
const baseCount = node._initialResources;
const baseMap = { ...node._initialColorMap };
node.setCount(Math.max(0, parseInt(v) || 0), color);
this._setLiveAmount(node, Math.max(0, parseInt(v) || 0), color);
node._initialResources = baseCount;
node._initialColorMap = baseMap;
} else {
Expand Down Expand Up @@ -1489,8 +1510,7 @@ class AppProps {
b.addEventListener('click', () => {
if (delta < 0 && node.resources <= 0) return;
if (delta > 0 && node.capacity !== Infinity && node.resources >= node.capacity) return;
if (delta < 0) node.takeResources(1);
else node.addResources(1, node.displayColor || DEFAULT_COLOR);
this._setLiveAmount(node, node.resources + delta, node.displayColor || DEFAULT_COLOR, true);
this.renderer.render();
this._refreshResourceCount();
this._refreshTypeReadouts();
Expand Down
36 changes: 31 additions & 5 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,15 @@ class App {
this._redoStack = [];
this._lastState = snap;
this._updateUndoButtons();
try { localStorage.setItem('sim_autosave', this._lastState); } catch {}
this._persistAutosave();
}

// Mirror the current state into the autosave slot. Every path that changes
// what is on the canvas has to call this: undo and redo moved _lastState
// without it, so an undo looked repaired on screen and was thrown away on the
// next reload, taking the mistake it had just undone with it.
_persistAutosave() {
try { localStorage.setItem('sim_autosave', this._lastState); } catch { /* blocked storage */ }
}

// Record that the diagram changed (push the previous state onto the stack).
Expand All @@ -534,7 +542,7 @@ class App {
this._redoStack = [];
this._lastState = snap;
this._updateUndoButtons();
try { localStorage.setItem('sim_autosave', this._lastState); } catch {}
this._persistAutosave();
// Mark any open MC results as potentially stale since the diagram changed.
this._markMCStale();
// A structural edit may satisfy the current tour step (placed a node / drew
Expand All @@ -556,6 +564,7 @@ class App {
this._redoStack.push(this._lastState);
this._lastState = this._undoStack.pop();
this._restoreState(this._lastState);
this._persistAutosave();
this._updateUndoButtons();
}

Expand All @@ -564,6 +573,7 @@ class App {
this._undoStack.push(this._lastState);
this._lastState = this._redoStack.pop();
this._restoreState(this._lastState);
this._persistAutosave();
this._updateUndoButtons();
}

Expand Down Expand Up @@ -789,7 +799,7 @@ 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')) {
try { localStorage.setItem('sim_autosave', this._lastState); } catch { /* blocked storage */ }
this._persistAutosave();
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
}
return;
Expand Down Expand Up @@ -1391,7 +1401,7 @@ class App {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
reader.onload = async ev => {
// Parse + validate on a throwaway Diagram BEFORE touching the current
// one: loadJSON clears everything first, so a corrupt file would
// otherwise wreck the diagram (and the next autosave persists that).
Expand All @@ -1405,6 +1415,19 @@ class App {
this._toast(`Invalid file: ${err.message}. Your current diagram is unchanged.`);
return;
}
// Opening a file replaces everything on the canvas, exactly like New,
// Load template and Load from library. It asked for none of their
// confirmation and, by resetting the history instead of committing
// the swap, left no way back: Ctrl+Z did nothing and the work was
// gone. Ask first, and keep the previous diagram one undo away.
// The guard only runs once the file has parsed, so a mistyped or
// cancelled pick never nags.
if (this.diagram.nodes.size || this.diagram.notes.size) {
if (!await this._confirmGuard(
`Open "${file.name}"? Your current diagram will be replaced (Ctrl+Z to undo).`,
'Open file')) return;
}
const prev = this._snapshot();
this.diagram.loadJSON(data);
this._applyMeta();
this.engine.reset();
Expand All @@ -1414,7 +1437,7 @@ class App {
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._commitReplace(prev);
};
reader.readAsText(file);
};
Expand Down Expand Up @@ -1547,6 +1570,9 @@ class App {
this._modalize('mc-overlay');
document.getElementById('mc-run').addEventListener('click', () => this._runMonteCarlo());
document.getElementById('mc-sweep-run').addEventListener('click', () => this._runSweep());
// Re-seed the sweep range when the parameter changes, not just on open.
document.getElementById('mc-sweep-param')
.addEventListener('change', (e) => this._seedSweepRange(e.target.value));
document.getElementById('mc-sens-run').addEventListener('click', () => this._runSensitivity());

// Touch layout ☰ overflow: the controls the collapsed topbar hides
Expand Down
14 changes: 12 additions & 2 deletions js/codegen.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,18 @@ function createEconomy(opts = {}) {
if (!n) throw new Error('set(): no node labeled ' + JSON.stringify(name));
if (n.type === NodeType.REGISTER) { n.value = Number(v) || 0; return api; }
if (n.type === NodeType.POOL || (n.type === NodeType.SOURCE && n.limited)) {
n.resources = Math.max(0, Number(v) || 0);
n.reconcile();
// Keep the node's own resource type. Writing \`resources\` and letting
// reconcile() backfill made every added unit untyped grey, so a
// colour-filtered connection or a converter recipe stopped accepting
// the contents of a pool the host game had just topped up.
const want = Math.max(0, Number(v) || 0);
const delta = want - n.resources;
if (delta > 0) {
n.addResources(delta, dominantColor(n.colorMap)
|| dominantColor(n._initialColorMap || {}) || n.resourceColor || DEFAULT_COLOR);
} else if (delta < 0) {
n.takeResources(-delta);
}
return api;
}
throw new Error('set() supports pools, limited sources and registers');
Expand Down
Loading
Loading