From ed7872c89334cc28bbe86691bfc0c559c2ce52e0 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 4 Oct 2026 18:38:06 +0200 Subject: [PATCH] Let a called behavior share the caller's local state machines A local state machine started over in every Run behavior call, so a state set in a sub-behavior was gone by the next call. A character controller split into sub-behaviors could therefore never remember that it was attacking. Scene or global scope would keep the state, but then every character shares it. - A call now runs on the states of the instance that runs it, however deep. A state one call sets is still set in the next one, and the call begins in the start state like the instance does. - A move made inside a call is reported like one the caller made. The caller's On state entered and On state exited hear it, the event nodes of the called graph stay silent as before. - Two objects running the same behavior still hold a state each. - This reverses the rule from #229 that local machines of a called behavior are not kept. Keep stays a switch for variables only. Tests: - Two unit tests that pinned the old rule now expect the shared state. - New world test: a sub-behavior closes a door per object, the caller's events hear it, and the next call finds it closed. - Counter-check: with the old call context the 3 tests fail. - Gate on melektaus-atx: 239 files, 3600 tests. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/GraphInterpreter.ts | 15 +++------- tests/core/keep-state.test.ts | 13 +++++---- tests/core/state-machine.test.ts | 44 ++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index efd4ce6a..dac9541c 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -240,17 +240,10 @@ export class GraphInterpreter { this.callStack.push(call.behaviorId); this.callSites.push(site); this.calledBehaviors.add(call.behaviorId); - // Fresh states every call, and nothing of the caller's variables or states. What it does to a - // local machine therefore dies with the call and is not reported; a `global` machine is the - // run's, so moving that one is a move like any other. - const inner: NodeContext = { - ...ctx, - variables, - states: {}, - stateChanged: (machineId, from, to) => { - if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); - }, - }; + // Nothing of the caller's variables, but its local machines: a local machine belongs to the + // running instance however deep the call, so what a call sets is still set on the next one, + // and the move is heard like one the caller made itself. + const inner: NodeContext = { ...ctx, variables }; try { for (const entry of entries) { this.trace?.onNode(entry.id); diff --git a/tests/core/keep-state.test.ts b/tests/core/keep-state.test.ts index ea01fdaf..8f3be77c 100644 --- a/tests/core/keep-state.test.ts +++ b/tests/core/keep-state.test.ts @@ -203,16 +203,17 @@ describe('Keep on a behavior variable (#229)', () => { expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); }); - it('starts a local state machine of the callee over every call, kept variables or not', () => { + it('leaves a local state machine with the caller, so a call finds what the last one set', () => { const fixture = makeProject(); const create = new CreateAssetCommand('statemachine', 'Doors'); fixture.stack.execute(create); const open = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); fixture.stack.execute(open); const machine = create.asset.id; - // No state yet → "first" and put it in Open; in Open → "open". The kept variable is only - // there to show that keeping one does not keep the machine along with it. + // No state yet → "first" and put it in Open; in Open → "open". The variable is not kept, which + // shows that the machine does not hang on the Keep switch. const callee = makeBehavior(fixture, 'Door', ['count']); + fixture.stack.execute(new SetVariableKeptCommand(callee.id, 'count', false)); const edit = graphEditor(fixture, callee); const start = edit.addNode(BEHAVIOR_START); const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine }); @@ -237,9 +238,9 @@ describe('Keep on a behavior variable (#229)', () => { const ctx = fakeContext(); fire(interpreter, 'OnStart', ctx); fire(interpreter, 'OnStart', ctx); - expect(ctx.logs).toEqual(['first', 'first']); - // A local machine of the callee is none of the caller's business either. - expect(ctx.states).toEqual({}); + expect(ctx.logs).toEqual(['first', 'open']); + // The state sits in the caller's bag, where the running instance keeps its local machines. + expect(ctx.states).toEqual({ [machine]: open.node.id }); }); it('survives saving and loading, and a name that is not declared is dropped', () => { diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index 01bff98f..b4c6eee1 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -275,7 +275,7 @@ describe('a running state machine', () => { expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']); }); - it('starts a called behavior in no state, whatever the caller set', () => { + it('lets a called behavior see the state its caller set', () => { const f = makeProject(); const machine = makeMachine(f); const inner = makeBehavior(f, 'Inner'); @@ -299,7 +299,8 @@ describe('a running state machine', () => { const ctx = fakeContext(); fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); - expect(ctx.logs).toEqual([]); + // A local machine belongs to the instance, not to the graph that happens to run. + expect(ctx.logs).toEqual(['inner saw open']); }); it('is driven by name where the machine arrives as a value, and branches through else', () => { @@ -501,6 +502,45 @@ describe('On state entered and On state exited', () => { expect(open).not.toBe(''); }); + it('hears a move made inside a called behavior, each object in its own state', () => { + const f = makeSpriteScene(); + const machine = makeMachine(f); + const [open, closed] = machine.ids as [string, string]; + f.stack.execute(new SetMachineStartCommand(machine.id, open)); + // Called every frame: closes the door it finds open, says so once it finds it closed. + const step = makeBehavior(f, 'Step'); + const stepEdit = graphEditor(f, step); + const branch = stepEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id }); + const set = stepEdit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + const said = stepEdit.addNode('Log', { message: 'Step: found closed' }); + stepEdit.wire([ + [stepEdit.addNode(BEHAVIOR_START), 'exec', branch, 'exec'], + [branch, open, set, closed], + [branch, closed, said, 'exec'], + ]); + const { behavior, edit } = watcher(f, machine.id, machine.ids); + const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: step.id }); + edit.connect(edit.addNode('OnUpdate'), 'exec', call, 'exec'); + place(f, behavior.id, { x: 0, y: 0 }); + place(f, behavior.id, { x: 10, y: 0 }); + const started = startWorld(f, { behaviors: true }); + started.frame(); + // The call starts in the instance's start state and its move reaches that instance's events. + expect([...started.world.log].sort()).toEqual([ + 'Watcher: entered closed', + 'Watcher: entered closed', + 'Watcher: entered open', + 'Watcher: entered open', + 'Watcher: exited open', + 'Watcher: exited open', + ]); + expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]); + started.world.log.length = 0; + started.frame(); + // The next call finds what the last one set, once per object. + expect(started.world.log).toEqual(['Step: found closed', 'Step: found closed']); + }); + it('reaches every instance when the machine is global, and enters its start once', () => { const f = makeSpriteScene(); const machine = makeMachine(f);