A shared value belongs to the run, so a declaration carries its scope (#91)

A variable or a state machine declared `global` keeps one value for the whole
run instead of one per behavior instance. The scope sits on the declaration --
`PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence
meaning `local` -- and `local` is never written, so every project made before
this saves back unchanged.

**The name is the key.** Two behaviors declaring a global `score` mean one
variable; a behavior declaring `score` locally keeps its own. Kinds that
disagree about one global name stop the run before it starts, with the two
behaviors named; starting values that disagree are a warning on the behavior
that declares it, and the first declaration wins (`globalConflicts`).

The runtime needs no redirection layer. A global variable falls out of
`schemaForObject`, so it never reaches `entity.props` -- and then the whole rule
is "whoever declares the name takes their own value" (`graph/sharedState.ts`).
The `Variables` node is untouched and stays transparent: switching a variable to
global breaks no existing graph, because `GraphInterpreter.readAll` builds a
fresh object per read anyway and only asks `port.key in all`.

That same cut answers the inspector. A shared value has no owner, so the placed
object shows no row for it and can hold no override -- `SetVariableScopeCommand`
takes the deviations that were there and undo gives them back.

Both places say it in words rather than leaving a glyph to be guessed: the
declaration explains the mark below the list, and the placed object names the
variable it no longer holds instead of claiming the behavior declares none --
which is what it said before, untruthfully. Screenshots of the e2e run turned
that up; no check had been looking at it.

`SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from
the *declarations* rather than from any instance (a global variable exists
before its behavior is placed), and is thrown away with the run: nothing is
written back, not even in the editor run. `GraphInterpreter.call` passes it on
through `...ctx` while nested calls keep getting fresh local variables, which is
why `program.defaults` is stripped of global keys.

Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or
the inspector does not rebuild after the switch -- and a state machine has no
rows at all, so its scope is appended to the signature by hand. And the object
inspector does not go through `schemaForObject` for behaviors, it reads
`asset.variables` directly; filtering in one place was not enough, and no unit
test covered that path. The e2e caught it (`global: true` where false belonged),
and the rule now lives in one named function, `objectRows`.

Scopes `object` and `scene` are deliberately not built. The object case already
has a route (`Object by name` -> `Properties`), and a run knows exactly one
scene, so `scene` would be indistinguishable from `global` until #39 exists.

Closes #59: a machine's scope is a word on the machine asset, not a setting per
`Set state` node, so the same machine cannot live in two places at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-14 16:22:53 +02:00
1 parent 9027a86edd
commit d1fcfe8b81
27 files changed
+857 -45

No files matched your search

+11
View File
@@ -69,6 +69,16 @@ export interface PointerState {
released: boolean;
}
/**
* What `global` declarations share for the length of one run: one value per variable name, one
* current state per machine. A key that is not in here is not shared – which is what lets a behavior
* declaring the same name for itself keep its own value (see `graph/sharedState.ts`).
*/
export interface SharedState {
variables: PropertyBag;
states: Record<string, string>;
}
/**
* Everything the graph does to the world goes through here, so the core stays free of runtime
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
@@ -83,6 +93,7 @@ export interface NodeContext {
* as the state node's id. A machine not in here is in no state: `State branch` follows nothing.
*/
states: Record<string, string>;
shared: SharedState;
/** Seconds since the world started and since the last tick. */
time: number;
dt: number;
+1
View File
@@ -8,6 +8,7 @@ export * from './nested';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './propertyPorts';
export * from './sharedState';
export * from './stateMachine';
export * from './values';
export * from './variablePorts';
+4 -2
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { stateBag } from '../../sharedState';
import { machineOf, machinePort, statePorts } from '../../stateMachine';
import { asString } from '../../values';
import { exec } from '../shared';
@@ -10,14 +11,15 @@ export const SetState: NodeDef = {
category: 'action',
description:
'Puts the machine into the state whose input the flow arrived through, then continues. Each ' +
'behavior instance keeps its own states.',
'behavior instance keeps its own states, unless the machine is global.',
inputs: [machinePort()],
outputs: [exec()],
params: [],
derivePorts: (source) => ({ inputs: statePorts(machineOf(source)) }),
run: (ctx, inputs, _params, def, entry) => {
const machine = asString(inputs.machine);
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) ctx.states[machine] = entry;
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry))
stateBag(ctx, machine)[machine] = entry;
return { next: 'exec' };
},
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry';
import { stateBag } from '../../sharedState';
import { machineOf, machinePort, statePorts } from '../../stateMachine';
import { asString } from '../../values';
import { exec } from '../shared';
@@ -16,7 +17,8 @@ export const StateBranch: NodeDef = {
params: [],
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
run: (ctx, inputs, _params, def) => {
const current = ctx.states[asString(inputs.machine)];
const machine = asString(inputs.machine);
const current = stateBag(ctx, machine)[machine];
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
},
};
+4 -2
View File
@@ -1,6 +1,7 @@
import { textOf } from '../../../model/Properties';
import { flatDef } from '../../../properties/PropertySchema';
import type { NodeDef } from '../../NodeRegistry';
import { stateBag } from '../../sharedState';
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
import { asString } from '../../values';
import { exec } from '../shared';
@@ -18,11 +19,12 @@ export const StateTransition: NodeDef = {
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
run: (ctx, inputs, params, _def, _entry, lookup) => {
const id = asString(inputs.machine);
const current = ctx.states[id] ?? '';
const states = stateBag(ctx, id);
const current = states[id] ?? '';
const machine = current ? lookup.machine(id) : null;
const name = textOf(params[TRANSITION_PARAM] ?? '');
const target = machine ? transitionTo(machine, current, name) : '';
if (target) ctx.states[id] = target;
if (target) states[id] = target;
return { next: 'exec' };
},
};
+3 -2
View File
@@ -1,5 +1,6 @@
import { collapsedTitle, portDefOf, VARIABLES } from '../../nested';
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { readableVariables, variableBag } from '../../sharedState';
import { writeTyped } from '../../values';
import { exec } from '../shared';
@@ -24,10 +25,10 @@ export const Variables: NodeDef = {
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
},
read: (ctx) => ctx.variables,
read: (ctx) => readableVariables(ctx),
run: (ctx, inputs, _params, def) => {
for (const port of def.inputs)
if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]);
if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]);
return { next: 'exec' };
},
};
+115
View File
@@ -0,0 +1,115 @@
import type { Asset } from '../model/Asset';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { isGlobal } from '../model/Scope';
import type { PropertyDef } from '../properties/PropertySchema';
import type { NodeContext, SharedState } from './NodeContext';
export function emptySharedState(): SharedState {
return { variables: {}, states: {} };
}
/**
* Built once per run, from the declarations rather than from any instance: a global variable exists
* even before the behavior declaring it is placed. Two behaviors declaring the same name mean the
* same variable – the first declaration sets the starting value (see `globalConflicts`).
*/
export function buildSharedState(project: Project): SharedState {
const shared = emptySharedState();
for (const asset of project.assets) {
if (asset.type === 'behavior') {
for (const def of asset.variables)
if (isGlobal(def) && !(def.key in shared.variables))
shared.variables[def.key] = asset.props[def.key] ?? def.default;
} else if (asset.type === 'statemachine' && isGlobal(asset)) {
shared.states[asset.id] = '';
}
}
return shared;
}
/**
* Where two behaviors disagree about the same global name. A different kind is an error – one of the
* two would silently read a value of a type it never declared – while a different starting value is
* only a warning, since the first declaration decides and the run is well defined either way.
*/
export interface GlobalConflict {
key: string;
severity: 'error' | 'warning';
message: string;
}
export function globalConflicts(project: Project): GlobalConflict[] {
const first = new Map<string, { def: PropertyDef; asset: string; value: PropertyValue }>();
const conflicts: GlobalConflict[] = [];
const seen = new Set<string>();
for (const asset of project.assets) {
if (asset.type !== 'behavior') continue;
for (const def of asset.variables) {
if (!isGlobal(def)) continue;
const value = asset.props[def.key] ?? def.default;
const held = first.get(def.key);
if (!held) {
first.set(def.key, { def, asset: asset.name, value });
continue;
}
// One report per name: a third behavior disagreeing adds nothing the first line does not say.
if (seen.has(def.key)) continue;
if (held.def.kind !== def.kind) {
seen.add(def.key);
conflicts.push({
key: def.key,
severity: 'error',
message: `Global variable "${def.key}" is a ${held.def.kind} in ${held.asset} and a ${def.kind} in ${asset.name}.`,
});
} else if (!sameValue(held.value, value)) {
seen.add(def.key);
conflicts.push({
key: def.key,
severity: 'warning',
message: `Global variable "${def.key}" starts at ${display(held.value)} in ${held.asset} and at ${display(value)} in ${asset.name}; the first is used.`,
});
}
}
}
return conflicts;
}
/** Structs and lists compare by their stored shape, which is what the project file holds anyway. */
function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
return a === b || JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function display(value: PropertyValue | undefined): string {
return typeof value === 'string' ? `"${value}"` : JSON.stringify(value ?? null);
}
/** The bag a variable is written to: its own when the behavior declares it, else the shared one. */
export function variableBag(ctx: NodeContext, key: string): PropertyBag {
if (key in ctx.variables) return ctx.variables;
return key in ctx.shared.variables ? ctx.shared.variables : ctx.variables;
}
/** Everything the behavior can read under one name, its own declaration winning over a shared one. */
export function readableVariables(ctx: NodeContext): PropertyBag {
const shared = ctx.shared.variables;
return isEmpty(shared) ? ctx.variables : { ...shared, ...ctx.variables };
}
/** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
}
/** The defaults a behavior instance starts with: a shared variable is not one of them. */
export function withoutGlobals(defaults: PropertyBag, asset: Asset): PropertyBag {
if (asset.type !== 'behavior') return defaults;
const own = { ...defaults };
for (const def of asset.variables) if (isGlobal(def)) delete own[def.key];
return own;
}
function isEmpty(bag: PropertyBag): boolean {
for (const key in bag) if (Object.hasOwn(bag, key)) return false;
return true;
}