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

+23 -6
View File
@@ -20,6 +20,7 @@ import {
type VariableKind,
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { isGlobal, type Scope } from '../model/Scope';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts';
@@ -57,10 +58,14 @@ type Extras<K extends FlatKind> = K extends 'number'
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; scope?: Scope } & Extras<P>;
}[K];
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
/**
* The inspector is generated from these entries, so a new property is one line in a schema below.
* `scope` is only ever set on a behavior's own variables; the fixed schemas below leave it off, which
* reads as `local`.
*/
export type PropertyDef =
| FlatDef
/**
@@ -69,12 +74,19 @@ export type PropertyDef =
* item kind: a list is stored in the project file, and an object is a live handle that no reload
* survives.
*/
| { key: string; label: string; kind: 'list'; default: ListValue }
| { key: string; label: string; kind: 'list'; default: ListValue; scope?: Scope }
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`).
*/
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
| {
key: string;
label: string;
kind: 'struct';
default: StructValue;
fields: readonly StructFieldDef[];
scope?: Scope;
};
export interface PrimitiveFieldDef {
key: string;
@@ -299,10 +311,15 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
return schemaForBag(project, asset, asset.props);
}
/** A placed object's rows: an object overriding its shader has that shader's parameters. */
/**
* A placed object's rows: an object overriding its shader has that shader's parameters. A global
* variable is not among them – it belongs to the run, not to this object, so it shows no row here
* and never reaches `entity.props` (which is what lets the shared bag answer for it instead).
*/
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
const shader = object.overrides[SHADER_KEY];
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
return rows.some(isGlobal) ? rows.filter((def) => !isGlobal(def)) : rows;
}
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {