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

+20 -5
View File
@@ -24,6 +24,7 @@ import {
} from '../../core/model/instance';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { isGlobal } from '../../core/model/Scope';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -52,6 +53,17 @@ import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
/**
* The rows a placed object shows for what it was placed from: a behavior's declared variables, any
* other asset's schema minus the common switches. A global variable is none of them – it belongs to
* the run, so this object has no value of its own to show or override.
*/
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
return asset.type === 'behavior'
? asset.variables.filter((def) => !isGlobal(def))
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
}
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
@@ -186,14 +198,17 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
// Asset properties (or behavior variables) with overrides; the switches are shown above.
const values = resolveSceneObjectProps(host.state.project, asset, object);
const own =
asset.type === 'behavior'
? asset.variables
: schemaForObject(host.state.project, asset, object).filter((def) => !isCommonProp(def.key));
const own = objectRows(host.state.project, asset, object);
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
// A global variable is missing from the rows above but not from the behavior, so saying it
// declares none would be untrue – name it instead, and where it went.
const shared = asset.type === 'behavior' ? asset.variables.filter(isGlobal) : [];
if (own.length === 0 && shared.length === 0 && asset.type === 'behavior')
addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
if (shared.length > 0)
addNote(host, `Shared by the whole run: ${shared.map((def) => def.key).join(', ')}.`, 'small');
markObjectRows(host, object, asset, target);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);