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

+3
View File
@@ -2,6 +2,7 @@ import type { Graph } from '../graph/Graph';
import type { PropertyDef } from '../properties/PropertySchema';
import type { AssetType } from './AssetTypes';
import type { PropertyBag } from './Properties';
import type { Scope } from './Scope';
import type { SceneObject } from './SceneObject';
export interface AssetBase {
@@ -85,10 +86,12 @@ export interface AnimationAsset extends AssetBase {
/**
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
* A `global` machine has one current state for the whole run instead of one per behavior instance.
*/
export interface StateMachineAsset extends AssetBase {
type: 'statemachine';
graph: Graph;
scope?: Scope;
}
/**
+29
View File
@@ -0,0 +1,29 @@
/**
* Whether a declaration keeps a value per behavior instance or shares one for the whole run.
* Everything was `local` before scopes existed, so a missing field means `local` and old projects
* load unchanged.
*/
export type Scope = 'local' | 'global';
export interface Scoped {
scope?: Scope;
}
export function scopeOf(owner: Scoped): Scope {
return owner.scope === 'global' ? 'global' : 'local';
}
export function isGlobal(owner: Scoped): boolean {
return owner.scope === 'global';
}
/** Reads a scope out of loaded data, where anything but the one known word means `local`. */
export function readScope(raw: unknown): Scope {
return raw === 'global' ? 'global' : 'local';
}
/** Keeps `local` out of the document: the field is present only when it says `global`. */
export function setScope(owner: Scoped, scope: Scope): void {
if (scope === 'global') owner.scope = 'global';
else delete owner.scope;
}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { GraphLookup } from '../graph/lookup';
import { withoutGlobals } from '../graph/sharedState';
import { statesOf, transitionsOf } from '../graph/stateMachine';
import { resolveAssetProps } from '../properties/resolve';
import { findAssetOf, type Project } from './Project';
@@ -12,7 +13,8 @@ export function graphLookup(project: Project): GraphLookup {
behavior: (assetId) => {
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return null;
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) };
const defaults = withoutGlobals(resolveAssetProps(project, asset), asset);
return { name: asset.name, graph: asset.graph, defaults };
},
machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine');