Files
feuerwerk/tests/helpers.ts
T
rootandClaude Opus 5 8143130ed2
game-maker / suite (push) Failing after 8m59s
A shared value belongs to no behavior, so it becomes an asset of its own (#91)
This reverses the design of the previous commit. `PropertyDef.scope` is gone
without replacement: a behavior variable is an object property again, without
exception, and sharing is something you point at rather than something you
declare twice.

A **Variables asset** is declarations and nothing else -- no graph, no position,
never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that
costs no line). A `Variables` node names one through its new `variablesAsset`
setting, empty meaning the behavior's own variables as before, and then reads
and writes one value per key for the whole run. Its property list follows the
chosen source, which was the actual request: a value another graph declared is
now offerable, where before every participant had to declare the name itself.

That deletes more than it adds (-72 lines net): `globalConflicts` and its
run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both
object-inspector filters and the "shared by the whole run" note on the placed
object. A typo is no longer a silent second variable -- naming an asset is
picking from a list. And two Variables assets may carry the same key without
meeting, because the asset is the key, not the name. The one shape it copies is
the one that already existed: `Set state` picks its machine the same way.

Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the
declarations and never written back, so playing in the editor cannot change the
project -- which is the whole reason a counter must not be an object property
(`World.syncToModel` would `commit()` it, and the coin counter would stand at 7
for good).

Full symmetry across that gap, without mirroring anything. Two named wires:
`Inspector.liveVariables` shows the values as they stand while a run is on and
falls back to the declarations when it ends, and `adoptDeclarations()` takes a
key into the run *only where the document changed it since the last sync*.
Without that condition any unrelated project edit would snap a running counter
back to its starting value; a counter-test bends exactly that and falls over.
Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches
nodes without a `read`, and the variable node has one. The wire was missing.

The state machine `Scope` row is now an ordinary `enum` row through
`createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and
began ~34px left of every value above it; the explanations beside `Local` and
`Global` are gone from the menu, the sentence below the row keeps saying it.
Machine scope is otherwise untouched -- `StateMachineAsset.scope`,
`SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59
stays closed as built and `Scope` as a type now carries machines alone.

Six counter-proofs, each biting at its own guard. One of them did not bite at
first: the suggestion list was computed twice, in `nodeSection` and
`portSection`, so only the browser test covered the request itself. It is one
named function now, `variableChoices`, with a unit test of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:05:36 +02:00

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CreateAssetCommand } from '../src/core/commands/assetCommands';
import { CommandStack } from '../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands';
import { AddPortsCommand } from '../src/core/commands/portCommands';
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
import { requireNodeDef } from '../src/core/commands/require';
import { AddVariableCommand } from '../src/core/commands/variableCommands';
import {
type DeclaredPort,
emptySharedState,
ENTITY_HANDLE,
type EntityHandle,
type GraphLookup,
type GraphNode,
NO_LOOKUP,
type NodeContext,
type PointerState,
type Value,
type RunResult,
portKindOf,
PROPERTIES,
VARIABLES,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
import { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform';
import { resolvedHolder } from '../src/core/model/instance';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
import { createEmptyProject, findScene, type Project } from '../src/core/model/Project';
import type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
import { AnimationSystem } from '../src/runtime/systems/AnimationSystem';
import { Input } from '../src/runtime/systems/Input';
import { World } from '../src/runtime/World';
export interface ProjectFixture {
project: Project;
stack: CommandStack;
}
export interface SceneFixture extends ProjectFixture {
sceneId: string;
spriteId: string;
/** The scene asset, looked up fresh (undo may replace it). */
sceneOf(): SceneAsset;
/** The scene with the content of placed containers resolved in – what the editor shows. */
viewOf(): SceneAsset;
object(id: string): SceneObject;
/** World position of a scene object, ancestors included. */
world(id: string): Position;
}
export function makeProject(): ProjectFixture {
const project = createEmptyProject('p', 'P');
return { project, stack: new CommandStack(project) };
}
/** A project with a scene "Level" and a 100×100 sprite (named "Box" unless told otherwise), plus lookups into the scene. */
export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
const fixture = makeProject();
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', spriteName);
fixture.stack.execute(scene);
fixture.stack.execute(sprite);
const sceneOf = () => findScene(fixture.project, scene.asset.id)!;
const viewOf = () => resolvedHolder(fixture.project, sceneOf()) as SceneAsset;
const object = (id: string) => findObject(viewOf(), id)!;
return {
...fixture,
sceneId: scene.asset.id,
spriteId: sprite.asset.id,
sceneOf,
viewOf,
object,
world: (id) => worldPosition(fixture.project, viewOf(), object(id)),
};
}
/** Places an asset into the fixture's scene at a world position (optionally below a parent) and returns the new object's id. */
export function place(
fixture: SceneFixture,
assetId: string,
position: Vec2 | Position,
parentId = '',
): string {
const command = new PlaceAssetCommand(fixture.sceneId, assetId, position, parentId);
fixture.stack.execute(command);
return command.objectId;
}
/** Adds a Variables asset declaring the given number variables – what a run shares. */
export function makeVariables(
fixture: ProjectFixture,
name: string,
keys: readonly string[],
): VariablesAsset {
const command = new CreateAssetCommand('variables', name);
fixture.stack.execute(command);
for (const key of keys) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number'));
return command.asset as VariablesAsset;
}
/** Adds a behavior asset declaring the given number variables. */
export function makeBehavior(
fixture: ProjectFixture,
name = 'B',
variables: readonly string[] = [],
): BehaviorAsset {
const command = new CreateAssetCommand('behavior', name);
fixture.stack.execute(command);
for (const key of variables) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number'));
return command.asset as BehaviorAsset;
}
export type Edge = readonly [from: GraphNode, fromPort: string, to: GraphNode, toPort: string];
export interface GraphEditor {
/** Adds a node with inline params (graph coordinates do not matter to tests). */
addNode(type: string, params?: PropertyBag): GraphNode;
/** A Variables node naming `keys`, typed by their declaration; `write` sets all of them or the listed. */
variables(
keys: readonly string[],
options?: { write?: boolean | readonly string[]; params?: PropertyBag },
): GraphNode;
/** A Properties node with `params` (its type) declaring `ports`. */
properties(ports: readonly DeclaredPort[], params?: PropertyBag): GraphNode;
connect(from: GraphNode, fromPort: string, to: GraphNode, toPort: string): void;
wire(edges: readonly Edge[]): void;
}
/** Command-driven editing of one behavior's graph, the way the editor does it. */
export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): GraphEditor {
const { stack } = fixture;
const connect: GraphEditor['connect'] = (from, fromPort, to, toPort) => {
stack.execute(
new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort }),
);
};
return {
addNode(type, params = {}) {
const command = new AddNodeCommand(behavior.id, type, 0, 0, params);
stack.execute(command);
return command.node;
},
variables(keys, { write = false, params = {} } = {}) {
const command = new AddNodeCommand(behavior.id, VARIABLES, 0, 0, params);
stack.execute(command);
const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key));
const ports = keys.map((key): DeclaredPort => {
const declared = behavior.variables.find((v) => v.key === key);
const kind = declared ? portKindOf(declared) : 'number';
return writes(key) ? { key, kind, write: true } : { key, kind };
});
if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
return command.node;
},
properties(ports, params = {}) {
const command = new AddNodeCommand(behavior.id, PROPERTIES, 0, 0, params);
stack.execute(command);
if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
return command.node;
},
connect,
wire(edges) {
for (const [from, fromPort, to, toPort] of edges) connect(from, fromPort, to, toPort);
},
};
}
export const NO_POINTER: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
};
/** A top-level entity handle named after itself, with empty props. */
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
return { [ENTITY_HANDLE]: true, id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
}
/** An entity handle "h" carrying the given props, optionally below a parent. */
export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
}
export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
const logs: string[] = [];
const ctx: FakeContext = {
self: fakeEntity('self'),
owner: fakeEntity('owner'),
variables: { speed: 100 },
states: {},
shared: emptySharedState(),
time: 0,
dt: 0.5,
logs,
stopped: false,
findByName: () => null,
isKeyDown: () => false,
pointer: { ...NO_POINTER },
hitTest: () => false,
isTouching: () => false,
moveAndSlide: (target, dx, dy) => {
target.x += dx;
target.y += dy;
return { blockedX: false, blockedY: false };
},
log: (message) => logs.push(message),
spawn: () => null,
destroy: () => {},
stop: () => {
ctx.stopped = true;
},
restart: () => {},
...overrides,
};
return ctx;
}
/** Runs a node on its registered definition: the shape it has while sitting in no graph. */
export function runDef(
type: string,
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag = {},
entry = 'exec',
lookup: GraphLookup = NO_LOOKUP,
): RunResult {
const def = requireNodeDef(type);
return def.run!(ctx, inputs, params, def, entry, lookup);
}
/** Runs one node definition in isolation and returns its data outputs. */
export function runNode(
type: string,
inputs: Record<string, Value>,
params: PropertyBag = {},
ctx: NodeContext = fakeContext(),
): Record<string, Value> {
return runDef(type, ctx, inputs, params).outputs ?? {};
}
export interface WorldOptions {
collisions?: boolean;
animations?: boolean;
behaviors?: boolean;
}
export interface StartedWorld {
world: World;
collisions: CollisionSystem | null;
behaviors: BehaviorSystem | null;
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
input: Input;
/** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */
frame(dt?: number): void;
}
/** Instantiates the fixture's scene, optionally with collisions, animations and a BehaviorSystem fed by both. */
export function startWorld(
fixture: SceneFixture,
options: WorldOptions & { collisions: true },
): StartedWorld & { collisions: CollisionSystem };
export function startWorld(fixture: SceneFixture, options?: WorldOptions): StartedWorld;
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): StartedWorld {
const world = World.instantiate(fixture.project, fixture.sceneId);
const collisions = options.collisions ? new CollisionSystem() : null;
const input = new Input();
const animations = options.animations ? new AnimationSystem() : null;
if (collisions) world.addSystem(collisions);
if (animations) world.addSystem(animations);
const behaviors = options.behaviors
? new BehaviorSystem(fixture.project, input, { collisions, animations })
: null;
if (behaviors) world.addSystem(behaviors);
return {
world,
collisions,
behaviors,
input,
frame(dt = 1 / 60) {
world.tick(dt);
input.endFrame();
},
};
}
/** The project as it comes back after being saved and loaded again. */
export function roundTrip(project: Project): Project {
return deserializeProject(serializeProject(project));
}