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
11 changes: 11 additions & 0 deletions docs/CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ From `SimEngine._tick()`:
6. **Commit atomically** (`_applyCtx`). All accumulated movements apply at once.
7. **Apply modifiers**, then **refresh variables** and **evaluate registers**.

### Conditions are synchronous

Activator conditions and connection conditions are evaluated against the state as it
stood at the **start of the step**: the engine snapshots every node's value (including
registers, which keep their usual one‑step lag) before the fire phase, and every check
reads that snapshot. A pull node that empties a pool mid‑step therefore can't flip a
condition another node was about to test, so results never depend on the order nodes
happen to be stored in. **Trigger cascades are the exception.** A node fired by a
trigger or reverse trigger sees the current mid‑step state, because a cascade is a
causal reaction within the step and its order comes from the graph, not from storage.

### Fair allocation under contention

When several outgoing connections compete for one pool's resources, the engine uses
Expand Down
52 changes: 45 additions & 7 deletions js/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class SimEngine {
// Terminal state when a node's end/goal condition is met.
this.ended = null; // { nodeId, label, step, value } | null
this.onEnd = null; // Callback(ended)
// Tick-start snapshot of every node's state value, set for the duration
// of each doStep. Condition/activator checks read it via _condValueOf so
// decisions are synchronous (see that method). Null between steps.
this._tickSnap = null;
}

saveInitial() {
Expand Down Expand Up @@ -136,7 +140,16 @@ class SimEngine {
// rate formulas; per-play ones keep the value sampled when Run started.
this._sampleCustomVars('step');
this.step++;
// Snapshot every node's state value before the fire phase. Activator and
// connection-condition checks read this snapshot (via _condValueOf), so a
// decision reflects the state at the START of the step and cannot depend
// on the order nodes happen to be stored in (a pull node draining a pool
// mid-tick can't flip a condition another node was about to check).
// Movement itself (rates, allocation, capacity) still reads live state.
this._tickSnap = new Map();
for (const n of this.diagram.nodes.values()) this._tickSnap.set(n.id, this._stateValueOf(n));
const { fired, transfers } = this._tick();
this._tickSnap = null; // fireInteractive between steps reads live state
this._record();
if (this.onStep) this.onStep(this.step, fired, transfers);
this._checkEnd();
Expand Down Expand Up @@ -233,7 +246,9 @@ class SimEngine {
for (const c of d.outgoing(node.id)) {
if (c.type === ConnectionType.STATE && c.reverseTrigger && this._triggerPasses(c)) {
const tgt = d.nodes.get(c.targetId);
if (tgt) failQueue.push({ node: tgt, forced: true });
// live: like trigger cascades, reverse-triggered fires are causal
// reactions and see mid-step state (see _nodeEnabled).
if (tgt) failQueue.push({ node: tgt, forced: true, live: true });
}
}
}
Expand Down Expand Up @@ -280,27 +295,35 @@ class SimEngine {
const queue = [...initial];
let guard = 0;
while (queue.length && guard++ < 5000) {
const { node, forced } = queue.shift();
const { node, forced, live } = queue.shift();
if (!node || activated.has(node.id)) continue;
if (!this._nodeEnabled(node)) continue;
if (!this._nodeEnabled(node, live)) continue;
if (!this._fireNode(node, ctx, forced)) continue;
activated.add(node.id);
fired.push(node.id);
for (const t of d.outgoing(node.id)) {
if (t.type === ConnectionType.STATE && t.trigger && this._triggerPasses(t)) {
const tgt = d.nodes.get(t.targetId);
if (tgt) queue.push({ node: tgt, forced: true });
// live: a cascade-fired node's activators see mid-step state (see
// _nodeEnabled); the cascade's order is causal, not storage order.
if (tgt) queue.push({ node: tgt, forced: true, live: true });
}
}
}
}

// A node may fire only while every incoming activator's condition holds.
_nodeEnabled(node) {
// Main-phase fires (automatic/starting/artificial player) evaluate against
// the tick-start snapshot, so the result is independent of node storage
// order. Trigger and reverse-trigger cascades pass live=true and see the
// current mid-step state: a cascade is a causal reaction within the step
// whose order is defined by the graph, so live reads there stay
// deterministic and are intentional.
_nodeEnabled(node, live = false) {
for (const conn of this.diagram.incoming(node.id)) {
if (conn.type !== ConnectionType.STATE || !conn.activator) continue;
const src = this.diagram.nodes.get(conn.sourceId);
const val = this._stateValueOf(src);
const val = live ? this._stateValueOf(src) : this._condValueOf(src);
if (!this._evalCond(val, conn.actOperator, conn.actValue, conn.actValue2)) return false;
}
return true;
Expand Down Expand Up @@ -332,6 +355,18 @@ class SimEngine {
return node.resources;
}

// The value a condition/activator check sees for `node`. During a step this
// is the tick-start snapshot taken in doStep, so decisions are synchronous:
// they read the state as it stood when the step began, not whatever an
// earlier-stored node already moved this tick. Registers are in the
// snapshot too (their previous settled value, matching the one-step
// variable lag). Outside a step (fireInteractive) there is no snapshot and
// the read is live. Movement helpers keep using _stateValueOf directly.
_condValueOf(node) {
if (node && this._tickSnap && this._tickSnap.has(node.id)) return this._tickSnap.get(node.id);
return this._stateValueOf(node);
}

// Re-evaluate custom variables and publish them into the variable store.
// which: 'all' (reset), 'step' (each step), 'play' (each Run press).
// Math-kind variables read the store itself, so params are seeded first and
Expand Down Expand Up @@ -1218,9 +1253,12 @@ class SimEngine {
if (conn.interval > 1 && (this.step - 1) % conn.interval !== 0) return false;
if (conn.chance < 100 && SimRandom.random() * 100 >= conn.chance) return false;
if (conn.condEnabled) {
// Source-value conditions read the tick-start snapshot (variables
// already lag one step by design). Cascade-fired nodes never get here:
// their fires are forced, which bypasses _connFires entirely.
const val = (conn.condRefMode === 'variable' && conn.condVariable)
? (this.diagram.variables[conn.condVariable] ?? 0)
: this._stateValueOf(sourceNode);
: this._condValueOf(sourceNode);
if (!this._evalCond(val, conn.condOperator, conn.condValue, conn.condValue2)) return false;
}
return true;
Expand Down
117 changes: 117 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -2644,6 +2644,123 @@ test('restoreState restores structure removed after the checkpoint', () => {
eq(d.connections.size, 1, 'connection restored from checkpoint');
});

// ── Synchronous conditions (tick-start snapshot) ────────────────────────────
console.log('\nSynchronous conditions');

test('activator result does not depend on node insertion order', () => {
// A pull-mode drain empties P during the fire phase; a source gated by an
// activator "P >= 5" must see P's tick-start value regardless of whether
// the drain was inserted before or after it.
const build = (drainFirst) => {
const d = new Diagram();
d.seed = 'sync-a';
const p = node(d, NodeType.POOL); p.setCount(5);
let dr, out;
const mkDrain = () => { dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull'; conn(d, p, dr).rate = 5; };
const mkSource = () => {
const s = node(d, NodeType.SOURCE);
out = node(d, NodeType.POOL);
conn(d, s, out).rate = 1;
const a = conn(d, p, s, ConnectionType.STATE);
a.activator = true; a.actOperator = '>='; a.actValue = 5;
};
if (drainFirst) { mkDrain(); mkSource(); } else { mkSource(); mkDrain(); }
const e = new SimEngine(d);
e.reset();
const trace = [];
for (let i = 0; i < 3; i++) { e.doStep(); trace.push([p.resources, out.resources, dr.drained].join('/')); }
SimRandom.seed(null);
return trace.join(' ');
};
const first = build(true), second = build(false);
eq(first, second, 'both insertion orders produce the same trace');
// Tick-start semantics: on step 1 P started at 5, so the source DOES fire
// even though the drain empties P within that same tick.
eq(first, '0/1/5 0/1/5 0/1/5', 'source fired exactly on the step P started at 5');
});

test('trace is invariant under node-array permutation (property test)', () => {
// A nontrivial seeded diagram (source, pools, conditional gate feed, pull
// drain, register, activator, trigger). Rebuilding it from the same JSON
// with the nodes array permuted must yield a byte-identical trace.
const d = new Diagram();
d.seed = 'perm';
const s = node(d, NodeType.SOURCE);
const a = node(d, NodeType.POOL);
conn(d, s, a).rate = 2;
const g = node(d, NodeType.GATE);
const toGate = conn(d, a, g); toGate.rate = 3;
toGate.condEnabled = true; toGate.condOperator = '>='; toGate.condValue = 3;
const b = node(d, NodeType.POOL);
const c = node(d, NodeType.POOL);
conn(d, g, b).weight = 2;
conn(d, g, c).weight = 1;
const dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull';
conn(d, b, dr).rate = 2;
const r = node(d, NodeType.REGISTER); r.formula = 'cv * 2';
conn(d, c, r, ConnectionType.STATE).variableName = 'cv';
const s2 = node(d, NodeType.SOURCE);
const e2 = node(d, NodeType.POOL);
conn(d, s2, e2).rate = 1;
const act = conn(d, b, s2, ConnectionType.STATE);
act.activator = true; act.actOperator = '>='; act.actValue = 2;
const f = node(d, NodeType.POOL); f.setCount(20); f.activation = ActivationMode.PASSIVE;
conn(d, f, c).rate = 1;
conn(d, s2, f, ConnectionType.STATE).trigger = true;

const json = d.toJSON();
const runWith = (permute) => {
const j = JSON.parse(JSON.stringify(json));
j.nodes = permute(j.nodes);
const dg = new Diagram(); dg.loadJSON(j);
const eng = new SimEngine(dg);
eng.reset();
const trace = [];
for (let i = 0; i < 25; i++) {
eng.doStep();
trace.push([...dg.nodes.values()].sort((x, y) => x.id.localeCompare(y.id))
.map(n => `${n.id}=${n.chartValue}`).join(','));
}
SimRandom.seed(null);
return trace.join('\n');
};
// Deterministic permutations only (no Math.random in tests).
const identity = runWith(ns => ns);
const reversed = runWith(ns => ns.slice().reverse());
const rotated = runWith(ns => ns.slice(3).concat(ns.slice(0, 3)));
eq(reversed, identity, 'reversed node order gives an identical trace');
eq(rotated, identity, 'rotated node order gives an identical trace');
});

test('trigger cascade reads live mid-step state (causal exception)', () => {
// The drain empties P and triggers T within the same tick. T's activator
// "P == 0" must see the live, post-pull value (a cascade is a causal
// reaction inside the step), while an identical automatic node stored
// after the drain checks the tick-start snapshot and stays put.
const d = new Diagram();
d.seed = 'sync-c';
const p = node(d, NodeType.POOL); p.setCount(5);
const dr = node(d, NodeType.DRAIN); dr.flowMode = 'pull';
conn(d, p, dr).rate = 5;
const t = node(d, NodeType.SOURCE); t.activation = ActivationMode.PASSIVE;
const out1 = node(d, NodeType.POOL);
conn(d, t, out1).rate = 1;
const at = conn(d, p, t, ConnectionType.STATE);
at.activator = true; at.actOperator = '=='; at.actValue = 0;
conn(d, dr, t, ConnectionType.STATE).trigger = true;
const t2 = node(d, NodeType.SOURCE);
const out2 = node(d, NodeType.POOL);
conn(d, t2, out2).rate = 1;
const at2 = conn(d, p, t2, ConnectionType.STATE);
at2.activator = true; at2.actOperator = '=='; at2.actValue = 0;
const e = new SimEngine(d);
e.reset();
e.doStep();
SimRandom.seed(null);
eq(out1.resources, 1, 'cascade-fired node saw live P == 0 and fired');
eq(out2.resources, 0, 'automatic node saw tick-start P = 5 and did not fire');
});

// ── Async engine API (Monte Carlo runner) ────────────────────────────────────

testAsync('runMonteCarloAsync honours shouldCancel — resolves null, no results', async () => {
Expand Down
Loading