Files
feuerwerk/tests/core/world-sync.test.ts
T
adminandClaude Opus 5.5 483c34bb5c
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 9m41s
Let Destroy keep the children and hand them back as a list
Destroying a container used to take everything below it along. Now
Destroy can remove just the object itself and pass its direct children
on, so a behavior can keep working with what was inside.

- New input keep children (bool, default off). When on, only the
  object goes. Its direct children move up to the object's parent, or
  to the top level if there is none. Grandchildren stay where they are.
- New output children (object list) with the direct children that were
  kept. It is empty when keep children is off.
- The children keep their place on screen. carriedTransform folds the
  destroyed object's position, rotation and scale into their own, the
  same as reparenting in the editor.
- Behaviors and the rigidbody of the object are always destroyed with
  it and are not in the list. Moved up they would steer or simulate
  the parent.
- World remembers which children it reparented, and syncToModel writes
  their new parent into the scene. A reload of the model keeps it.
- Limit, read from the code only: in the editor run the contents of a
  placed container are not baked. Moved children there last for the
  run only.

Tests:
- ./check.sh: 170 files, 2478 tests, CHECK EXIT 0
- e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks
- world-sync: world matrix of child and grandchild unchanged under a
  rotated and scaled parent, parent survives syncFromModel, top level
  case, already destroyed object gives []
- destroy-keep-children: real graph Destroy(Owner) -> Log(children)
  logs Lid, Label without the grandchild Nail
- 3 counter-probes, all caught: no world transform (1 test fails), no
  parent in the scene (1), behaviors and rigidbody moved up (2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:45:27 +02:00

188 lines
8.5 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 { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
ClearOverrideCommand,
RemoveSceneObjectsCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldMatrixOf } from '../../src/core/graph';
import { findScene } from '../../src/core/model/Project';
import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Two boxes: a at the origin and b at (300,0). */
function setup() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
return { ...f, a, b };
}
describe('world ↔ model synchronisation (editing while playing)', () => {
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
const f = setup();
const { stack, sceneId, spriteId, a, b, object } = f;
const { world } = startWorld(f);
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
// Placed while playing → new entity, existing ones keep their identity.
const entityA = world.findByObjectId(a)!;
const c = place(f, spriteId, { x: 50, y: 50 });
world.syncFromModel();
expect(world.entities).toHaveLength(3);
expect(world.findByObjectId(a)).toBe(entityA);
// Property edits (override, asset value) and parenting are reflected; props bag keeps identity.
const propsA = entityA.props;
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 40));
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 70));
stack.execute(new SetParentCommand(sceneId, b, a));
world.syncFromModel();
expect(entityA.props).toBe(propsA);
expect(entityA.props.width).toBe(40);
expect(entityA.props.height).toBe(70);
expect(world.findByObjectId(b)!.parent).toBe(entityA);
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
// Removed while playing → entity gone.
stack.execute(new RemoveSceneObjectsCommand(sceneId, [c]));
world.syncFromModel();
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
});
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
const f = setup();
const { project, sceneId, a, b, object, sceneOf } = f;
const { world } = startWorld(f);
const entityA = world.findByObjectId(a)!;
entityA.x = 123;
entityA.props.width = 999; // what a Properties node would do
entityA.props.height = 100; // unchanged → no override
// No object appeared or vanished, but a position and an override were written – which is
// what tells the editor run that it has something to save.
expect(world.syncToModel()).toEqual({ structure: false, values: true });
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
expect(object(a).overrides).toEqual({ width: 999 });
// Round trip: the model now resolves to the runtime values, so a sync back changes nothing.
world.syncFromModel();
expect(entityA.x).toBe(123);
expect(entityA.props.width).toBe(999);
// Resetting the override in the editor returns the entity to the asset value.
new ClearOverrideCommand(sceneId, a, 'width').execute(project);
world.syncFromModel();
expect(entityA.props.width).toBe(100);
// Destroy (node) removes the entity; the sync drops its object – a structural change.
world.remove(world.findByObjectId(b)!);
expect(world.syncToModel().structure).toBe(true);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
});
it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => {
const f = setup();
const { sceneId, spriteId, a, sceneOf } = f;
const { world } = startWorld(f);
const entityA = world.findByObjectId(a)!;
entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back
const child = world.spawn(spriteId, { x: 10, y: 20, z: 1 }, entityA)!;
expect(child).not.toBeNull();
expect(child.parent).toBe(entityA);
expect(child.type).toBe('sprite');
expect(child.name).toBe('Box');
expect(child.props.width).toBe(100);
expect(entityA.x).toBe(77);
const object = sceneOf().objects.find((o) => o.id === child.objectId)!;
expect(object.parentId).toBe(a);
expect(object.position).toEqual({ x: 10, y: 20, z: 1 });
// The spawn is a structural change the panels must see – reported once.
expect(world.syncToModel()).toEqual({ structure: true, values: true });
expect(world.syncToModel()).toEqual({ structure: false, values: false });
// A full sync from the model keeps the spawned entity (identity included).
world.syncFromModel();
expect(world.findByObjectId(child.objectId)).toBe(child);
expect(child.parent).toBe(entityA);
// Destroying the spawned entity drops its object again.
world.remove(child);
expect(world.syncToModel().structure).toBe(true);
expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false);
// Unknown or unplaceable assets spawn nothing.
expect(world.spawn('nope', { x: 0, y: 0, z: 0 }, null)).toBeNull();
expect(world.spawn(sceneId, { x: 0, y: 0, z: 0 }, null)).toBeNull();
});
it('removeKeepingChildren moves the children up where they stand, behaviors and rigidbody gone', () => {
const f = makeSpriteScene();
const { stack, sceneId, spriteId, object } = f;
const behavior = new CreateAssetCommand('behavior', 'Brain');
const rigidbody = new CreateAssetCommand('rigidbody', 'Body');
stack.execute(behavior);
stack.execute(rigidbody);
const top = place(f, spriteId, { x: 100, y: 0 });
const crate = place(f, spriteId, { x: 110, y: 0 }, top);
stack.execute(new SetOverrideCommand(sceneId, crate, 'rotation', 90));
stack.execute(new SetOverrideCommand(sceneId, crate, 'scale', 2));
const lid = place(f, spriteId, { x: 110, y: 10 }, crate);
const nail = place(f, spriteId, { x: 110, y: 20 }, lid);
const brain = place(f, behavior.asset.id, { x: 110, y: 0 }, crate);
const body = place(f, rigidbody.asset.id, { x: 110, y: 0 }, crate);
const { world } = startWorld(f);
const entity = (id: string) => world.findByObjectId(id)!;
const worldOf = (id: string) => worldMatrixOf(entity(id));
const lidBefore = worldOf(lid);
const nailBefore = worldOf(nail);
const gone = entity(crate);
expect(world.removeKeepingChildren(gone)).toEqual([entity(lid)]);
expect(world.findByObjectId(crate)).toBeUndefined();
expect(world.findByObjectId(brain)).toBeUndefined();
expect(world.findByObjectId(body)).toBeUndefined();
// One level up, turned and sized so nothing moves on screen – the grandchild rides along.
expect(entity(lid).parent).toBe(entity(top));
expect(entity(nail).parent).toBe(entity(lid));
for (const [id, before] of [
[lid, lidBefore],
[nail, nailBefore],
] as const)
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
expect(worldOf(id)[key]).toBeCloseTo(before[key], 9);
expect(entity(lid).props.rotation).toBeCloseTo(90, 9);
expect(entity(lid).props.scale).toBeCloseTo(2, 9);
// The scene learns the new parent, so reading it back keeps the lid where it is.
expect(world.syncToModel().structure).toBe(true);
expect(object(lid).parentId).toBe(top);
world.syncFromModel();
expect(entity(lid).parent).toBe(entity(top));
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9);
// At the top level the children become top level; nothing left to answer for a gone object.
expect(world.removeKeepingChildren(entity(top))).toEqual([entity(lid)]);
expect(entity(lid).parent).toBeNull();
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9);
world.syncToModel();
expect(object(lid).parentId).toBe('');
expect(world.removeKeepingChildren(gone)).toEqual([]);
});
it('a play-mode copy leaves the original model untouched', () => {
const { project, sceneId, a, object, sceneOf } = setup();
const copy = roundTrip(project);
const world = World.instantiate(copy, sceneId);
world.findByObjectId(a)!.x = 500;
world.remove(world.findByObjectId(a)!);
world.syncToModel();
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
expect(object(a).position.x).toBe(0);
expect(sceneOf().objects).toHaveLength(2);
});
});