import type { PropertyBag } from '../model/Properties'; import type { Project } from '../model/Project'; import { isPerScene, isShared, sharedScopeOf } from '../model/Scope'; import type { NodeContext, SharedState } from './NodeContext'; import { startStateOf } from './stateMachine'; export function emptySharedState(): SharedState { return { variables: {}, states: {} }; } /** What every Variables asset declares right now: the value set on the asset, else the default. */ export function declaredVariables(project: Project): Record { const declared: Record = {}; for (const asset of project.assets) { if (asset.type !== 'variables') continue; const bag: PropertyBag = {}; for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default; declared[asset.id] = bag; } return declared; } /** * Built once per run, from the declarations rather than from any instance: a Variables asset holds * its values whether or not a graph ever reads them, and nothing is written back when the run ends. */ export function buildSharedState(project: Project): SharedState { const shared: SharedState = { variables: declaredVariables(project), states: {} }; for (const asset of project.assets) if (asset.type === 'statemachine' && isShared(asset)) shared.states[asset.id] = startStateOf(asset.graph, asset.start); return shared; } /** * The state the next scene of the same run starts from: what is `global` travels as it stands, * what is `scene` is built again from the declarations. Fresh bags rather than the carried ones, * so the world before it cannot still be writing into what this one reads. */ export function carriedForward(project: Project, carried: Readonly): SharedState { const fresh = buildSharedState(project); const restarted = restartedAssets(project); const next: SharedState = { variables: {}, states: {} }; for (const [id, bag] of Object.entries(fresh.variables)) next.variables[id] = restarted.has(id) ? bag : (carried.variables[id] ?? bag); for (const [id, state] of Object.entries(fresh.states)) next.states[id] = restarted.has(id) ? state : (carried.states[id] ?? state); return next; } /** * The assets a scene swap starts over rather than hands on, by id: the `scene` Variables assets and * the `scene` machines. Also what tells the new world which machines to announce an entry for. */ export function restartedAssets(project: Project): Set { const ids = new Set(); for (const asset of project.assets) { if (asset.type === 'variables' && sharedScopeOf(asset) === 'scene') ids.add(asset.id); if (asset.type === 'statemachine' && isPerScene(asset)) ids.add(asset.id); } return ids; } /** * The start state of every machine that has a usable one, keyed by asset id. A machine without one * is absent, which is what keeps "no start" and "starts in a deleted state" the same thing. */ export function startStates(project: Project): Record { const starts: Record = {}; for (const asset of project.assets) { if (asset.type !== 'statemachine') continue; const start = startStateOf(asset.graph, asset.start); if (start) starts[asset.id] = start; } return starts; } /** * Moves edits made to the declarations during a run into the values it shares. A key is taken over * only where the document changed it since `seen`, which is what leaves a value a graph has moved * on alone – the panel can show the running value and set it without the two fighting. */ export function adoptDeclarations( live: Record, seen: Record, now: Record, ): void { for (const [assetId, declared] of Object.entries(now)) { const bag = live[assetId]; if (!bag) { live[assetId] = { ...declared }; continue; } const before = seen[assetId] ?? {}; for (const [key, value] of Object.entries(declared)) if (!(key in bag) || before[key] !== value) bag[key] = value; for (const key of Object.keys(bag)) if (!(key in declared)) delete bag[key]; } for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId]; } /** * The values a `Variables` node reads and writes: those of the asset it names, else the behavior's * own. A name that no asset answers to is a fresh bag, so a node left pointing at a deleted asset * writes nowhere instead of into the behavior it sits in. */ export function variableBag(ctx: NodeContext, source: string): PropertyBag { if (!source) return ctx.variables; return ctx.shared.variables[source] ?? {}; } /** Where a machine's current state is kept: shared unless the machine is `local`, else per instance. */ function stateBag(ctx: NodeContext, machineId: string): Record { return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; } /** The current state of a machine, `''` when it is in none. */ export function currentState(ctx: NodeContext, machineId: string): string { return (machineId && stateBag(ctx, machineId)[machineId]) || ''; } /** * The one way a machine moves: writes the new state and reports the move. Into the state it is * already in is no move – nothing is written, and nothing fires, which is what keeps a `Set state` * run every frame from firing `entered` every frame. `reenter` is the exception a transition drawn * back into its own state asks for: the state is left and entered again, both events included. */ export function setState(ctx: NodeContext, machineId: string, next: string, reenter = false): void { if (!machineId) return; const bag = stateBag(ctx, machineId); const previous = bag[machineId] ?? ''; if (previous === next && !reenter) return; bag[machineId] = next; ctx.stateChanged(machineId, previous, next); }