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:
1 parent
9027a86edd
commit
d1fcfe8b81
27 files changed
+857
-45
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Scope, scopeOf, setScope } from '../model/Scope';
|
||||
import type { VariableKind } from '../properties/kinds';
|
||||
import {
|
||||
emptyFieldValue,
|
||||
@@ -51,11 +52,45 @@ export class AddVariableCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** One placed object's own value for a variable, remembered so that undo can put it back. */
|
||||
interface TakenOverride {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
path: string;
|
||||
value: PropertyValue;
|
||||
}
|
||||
|
||||
function takeOverrides(project: Project, assetId: string, key: string): TakenOverride[] {
|
||||
const taken: TakenOverride[] = [];
|
||||
for (const site of overrideSites(project, (id) => id === assetId)) {
|
||||
const value = site.bag[key];
|
||||
if (value === undefined) continue;
|
||||
taken.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
||||
delete site.bag[key];
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
function restoreOverrides(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
key: string,
|
||||
taken: readonly TakenOverride[],
|
||||
): void {
|
||||
const sites = overrideSites(project, (id) => id === assetId);
|
||||
for (const o of taken) {
|
||||
const site = sites.find(
|
||||
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
||||
);
|
||||
if (site) site.bag[key] = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a variable together with its default and every instance override. */
|
||||
export class RemoveVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
|
||||
private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
|
||||
private overrides: TakenOverride[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -70,13 +105,7 @@ export class RemoveVariableCommand implements Command {
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.removed = { def, value: asset.props[this.key] };
|
||||
delete asset.props[this.key];
|
||||
this.overrides = [];
|
||||
for (const site of overrideSites(project, (id) => id === this.assetId)) {
|
||||
const value = site.bag[this.key];
|
||||
if (value === undefined) continue;
|
||||
this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
||||
delete site.bag[this.key];
|
||||
}
|
||||
this.overrides = takeOverrides(project, this.assetId, this.key);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
@@ -84,13 +113,40 @@ export class RemoveVariableCommand implements Command {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
restoreAt(asset.variables, [this.removed.def]);
|
||||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||
const sites = overrideSites(project, (id) => id === this.assetId);
|
||||
for (const o of this.overrides) {
|
||||
const site = sites.find(
|
||||
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
||||
);
|
||||
if (site) site.bag[this.key] = o.value;
|
||||
}
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared variable has no per-instance value, so going `global` takes the deviations placed objects
|
||||
* kept for it. Undo gives them back.
|
||||
*/
|
||||
export class SetVariableScopeCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: Scope = 'local';
|
||||
private overrides: TakenOverride[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly scope: Scope,
|
||||
) {
|
||||
this.label = `Make variable ${key} ${scope}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.previous = scopeOf(def);
|
||||
setScope(def, this.scope);
|
||||
this.overrides = this.scope === 'global' ? takeOverrides(project, this.assetId, this.key) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) return;
|
||||
setScope(def, this.previous);
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user