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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user