import { beforeAll, describe, expect, it } from 'vitest'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph'; import { hasParentCycle, parentChain } from '../../src/core/model/walk'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers'; interface Node { id: string; parent: Node | null; } const parentOf = (node: Node) => node.parent; /** a → b → c, where c is parented back to a (only reachable through a broken document or a bug). */ function cycle(): { a: Node; b: Node; c: Node } { const a: Node = { id: 'a', parent: null }; const b: Node = { id: 'b', parent: a }; const c: Node = { id: 'c', parent: b }; a.parent = c; return { a, b, c }; } describe('parentChain', () => { it('yields the item and its ancestors, nearest first', () => { const a: Node = { id: 'a', parent: null }; const b: Node = { id: 'b', parent: a }; expect([...parentChain(b, parentOf)].map((n) => n.id)).toEqual(['b', 'a']); }); it('terminates on a cyclic parent chain, visiting every item once', () => { const { c } = cycle(); expect([...parentChain(c, parentOf)].map((n) => n.id)).toEqual(['c', 'b', 'a']); }); it('reports a cycle only when the parents never reach a root', () => { const { c } = cycle(); expect(hasParentCycle(c, parentOf)).toBe(true); expect(hasParentCycle({ id: 'lone', parent: null }, parentOf)).toBe(false); }); }); describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { beforeAll(async () => { await loadPhysics(); }); describe('cycle-safe walks in the runtime', () => { it('world position of a cyclic chain terminates and counts each ancestor once', () => { const brand = { [ENTITY_HANDLE]: true } as const; const a: EntityHandle = { ...brand, id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null, }; const b: EntityHandle = { ...brand, id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a, }; a.parent = b; expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 }); }); // Guards `travelsWith`: a parent walk without a seen-set hangs here. it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => { const f = makeSpriteScene(); const moverId = place(f, f.spriteId, { x: 0, y: 0 }); const wallId = place(f, f.spriteId, { x: 200, y: 0 }); const moverCollider = new AddColliderCommand(f.sceneId, moverId); const wallCollider = new AddColliderCommand(f.sceneId, wallId); f.stack.execute(moverCollider); f.stack.execute(wallCollider); const { world, collisions } = startWorld(f, { physics, collisions: true }); const mover = world.findByObjectId(moverId)!; const wall = world.findByObjectId(wallId)!; const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!; // Close the loop: the wall's collider is its child and the wall is parented to that collider. wall.parent = wallColliderEntity; expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false }); expect(mover.x).toBe(10); // And a whole tick, which walks every collider up to the body it belongs to. world.tick(1 / 60); expect(collisions.enters).toEqual([]); }, 2000); }); });