diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index ee5fc49b..6648f7cf 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,4 +1,11 @@ export { loadPlugin, nodeRegistry, registerNode } from './registry' +export { + type CascadeContext, + type ChildQuery, + cascadeDirty, + collectDescendants, + type SpatialQuery, +} from './relations-resolver' export { createSceneApi, type SceneStoreLike } from './scene-api' export type { Affordance, diff --git a/packages/core/src/registry/relations-resolver.test.ts b/packages/core/src/registry/relations-resolver.test.ts new file mode 100644 index 00000000..2784c1d1 --- /dev/null +++ b/packages/core/src/registry/relations-resolver.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { nodeRegistry, registerNode } from './registry' +import { cascadeDirty, collectDescendants, type SpatialQuery } from './relations-resolver' +import type { AnyNodeDefinition, Relations, SceneApi } from './types' + +const id = (s: string) => s as AnyNodeId + +function makeDef( + kind: string, + relations?: Relations, + overrides: Partial = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: {}, + relations, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } +} + +function makeNode(kind: string, idStr: string, extra: Partial = {}): AnyNode { + return { + id: id(idStr), + type: kind, + parentId: null, + visible: true, + ...extra, + } as unknown as AnyNode +} + +function makeFakeScene(nodes: Record): SceneApi { + return { + get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'], + update: () => {}, + upsert: () => id(''), + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + } +} + +describe('cascadeDirty', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('starting node alone when registry is empty', () => { + const scene = makeFakeScene({ a: makeNode('unknown', 'a') }) + const dirty = cascadeDirty(id('a'), { scene }) + expect(Array.from(dirty)).toEqual([id('a')]) + }) + + test('starting node alone when no relations declared', () => { + registerNode(makeDef('thing')) + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const dirty = cascadeDirty(id('a'), { scene }) + expect(Array.from(dirty)).toEqual([id('a')]) + }) + + test('hosts cascade marks matching children dirty', () => { + registerNode(makeDef('wall', { hosts: ['door', 'window'] })) + registerNode(makeDef('door')) + registerNode(makeDef('window')) + registerNode(makeDef('lamp')) + + const wall = makeNode('wall', 'w1', { + children: [id('d1'), id('w2'), id('l1')], + } as Partial) + const scene = makeFakeScene({ + w1: wall, + d1: makeNode('door', 'd1', { parentId: id('w1') }), + w2: makeNode('window', 'w2', { parentId: id('w1') }), + l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list + }) + + const dirty = cascadeDirty(id('w1'), { scene }) + const ids = Array.from(dirty).sort() + expect(ids).toEqual([id('d1'), id('w1'), id('w2')]) // l1 excluded + }) + + test('hosts cascade is recursive but bounded by maxDepth', () => { + registerNode(makeDef('a', { hosts: ['a'] })) // a hosts more a + const nodes: Record = {} + for (let i = 0; i < 25; i++) { + const childId = i < 24 ? id(`a${i + 1}`) : undefined + nodes[`a${i}`] = makeNode('a', `a${i}`, { + children: childId ? [childId] : [], + } as Partial) + } + const scene = makeFakeScene(nodes) + const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 }) + expect(dirty.size).toBe(6) // a0 + 5 descendants + }) + + test('affectsSpatial cascade uses spatialQuery to find neighbors', () => { + registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] })) + registerNode(makeDef('slab')) + registerNode(makeDef('zone')) + + const scene = makeFakeScene({ + w1: makeNode('wall', 'w1'), + s1: makeNode('slab', 's1'), + z1: makeNode('zone', 'z1'), + unrelated: makeNode('door', 'unrelated'), + }) + + const spatialQuery: SpatialQuery = (node, kinds) => { + if (node.id !== id('w1')) return [] + const matches: AnyNodeId[] = [] + if (kinds.includes('slab')) matches.push(id('s1')) + if (kinds.includes('zone')) matches.push(id('z1')) + return matches + } + + const dirty = cascadeDirty(id('w1'), { scene, spatialQuery }) + expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')]) + }) + + test('affectsSpatial is a no-op when no spatialQuery is provided', () => { + registerNode(makeDef('wall', { affectsSpatial: ['slab'] })) + const scene = makeFakeScene({ w1: makeNode('wall', 'w1') }) + const dirty = cascadeDirty(id('w1'), { scene }) + expect(Array.from(dirty)).toEqual([id('w1')]) // spatial branch silently skipped + }) + + test('cycle in hosts cascade does not loop forever', () => { + registerNode(makeDef('a', { hosts: ['a'] })) + const nodes: Record = { + a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial), + a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial), // cycle + } + const scene = makeFakeScene(nodes) + const dirty = cascadeDirty(id('a1'), { scene }) + expect(dirty.size).toBe(2) + expect(dirty.has(id('a1'))).toBe(true) + expect(dirty.has(id('a2'))).toBe(true) + }) + + test('custom childQuery overrides the default node.children lookup', () => { + registerNode(makeDef('wall', { hosts: ['door'] })) + registerNode(makeDef('door')) + const scene = makeFakeScene({ + w1: makeNode('wall', 'w1'), // no children field + d1: makeNode('door', 'd1', { parentId: id('w1') }), + }) + + // childQuery iterates the scene to find parentId matches — what you would + // do for kinds that don't carry an explicit children array. + const childQuery = (node: AnyNode) => { + const result: AnyNodeId[] = [] + for (const candidate of [id('d1')]) { + const c = scene.get(candidate) + if (c && c.parentId === node.id) result.push(c.id) + } + return result + } + + const dirty = cascadeDirty(id('w1'), { scene, childQuery }) + expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')]) + }) +}) + +describe('collectDescendants', () => { + beforeEach(() => { + nodeRegistry._reset() + }) + + test('returns just the start when no children', () => { + const scene = makeFakeScene({ a: makeNode('thing', 'a') }) + const result = collectDescendants(id('a'), { scene }) + expect(Array.from(result)).toEqual([id('a')]) + }) + + test('returns full subtree regardless of relations declarations', () => { + // No def registered — descendants still found via the children array. + const scene = makeFakeScene({ + root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] } as Partial), + c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial), + c2: makeNode('thing', 'c2'), + g1: makeNode('thing', 'g1'), + }) + + const result = collectDescendants(id('root'), { scene }) + expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')]) + }) + + test('respects maxDepth', () => { + const scene = makeFakeScene({ + a: makeNode('thing', 'a', { children: [id('b')] } as Partial), + b: makeNode('thing', 'b', { children: [id('c')] } as Partial), + c: makeNode('thing', 'c', { children: [id('d')] } as Partial), + d: makeNode('thing', 'd'), + }) + const result = collectDescendants(id('a'), { scene, maxDepth: 2 }) + expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]) // d truncated + }) +}) diff --git a/packages/core/src/registry/relations-resolver.ts b/packages/core/src/registry/relations-resolver.ts new file mode 100644 index 00000000..e406928f --- /dev/null +++ b/packages/core/src/registry/relations-resolver.ts @@ -0,0 +1,124 @@ +import type { AnyNode, AnyNodeId } from '../schema/types' +import { nodeRegistry } from './registry' +import type { SceneApi } from './types' + +/** + * Spatial neighbor query — given a node and a set of kinds, returns IDs of + * neighboring nodes of those kinds. The runtime provides this from + * `spatialGridManager`; tests can pass a stub. + */ +export type SpatialQuery = (node: AnyNode, kinds: readonly string[]) => Iterable + +/** + * Returns the IDs of nodes that share `node` as a parent. The runtime can + * pass an optimized index; the default fallback iterates the scene. + */ +export type ChildQuery = (node: AnyNode) => Iterable + +export type CascadeContext = { + scene: SceneApi + /** Optional: bounded spatial neighbor lookup. Required for `affectsSpatial`. */ + spatialQuery?: SpatialQuery + /** Optional: children-by-parent lookup. Defaults to iterating the scene. */ + childQuery?: ChildQuery + /** Safety cap on cascade depth — guards against bad data and pathological + * registry configurations. Default 16 (deeper than the maxHostDepth of 6). */ + maxDepth?: number +} + +const DEFAULT_MAX_DEPTH = 16 + +/** + * Walks the relations graph from one dirty node and returns the full set of + * IDs (including the starting one) that should be marked dirty. Pure — does + * NOT call `scene.markDirty`; callers iterate the result. + * + * Phase 1 implements: + * - `hosts`: marks children whose `type` matches the kind list + * - `affectsSpatial`: marks neighbors found via `spatialQuery` + * + * Phase 3 will add `linkedBy: 'endpoint-match'` for wall corner propagation. + */ +export function cascadeDirty(startId: AnyNodeId, ctx: CascadeContext): Set { + const result = new Set() + const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH + walk(startId, ctx, result, 0, maxDepth) + return result +} + +function walk( + id: AnyNodeId, + ctx: CascadeContext, + result: Set, + depth: number, + maxDepth: number, +): void { + if (result.has(id) || depth > maxDepth) return + result.add(id) + + const node = ctx.scene.get(id) + if (!node) return + + const def = nodeRegistry.get(node.type) + if (!def?.relations) return + + const { hosts, affectsSpatial } = def.relations + + if (hosts && hosts.length > 0) { + const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene) + for (const childId of childIds) { + const child = ctx.scene.get(childId) + if (child && (hosts as readonly string[]).includes(child.type)) { + walk(childId, ctx, result, depth + 1, maxDepth) + } + } + } + + if (affectsSpatial && affectsSpatial.length > 0 && ctx.spatialQuery) { + for (const neighborId of ctx.spatialQuery(node, affectsSpatial)) { + walk(neighborId, ctx, result, depth + 1, maxDepth) + } + } +} + +/** + * Fallback children lookup that reads the node's `children: AnyNodeId[]` + * field if present. Most parametric nodes carry one; nodes that don't will + * need a `childQuery` override on the context. + */ +function defaultChildIds(node: AnyNode, _scene: SceneApi): AnyNodeId[] { + const maybeChildren = (node as unknown as { children?: AnyNodeId[] }).children + return Array.isArray(maybeChildren) ? maybeChildren : [] +} + +/** + * Recursively collects every descendant of a node, plus the node itself. + * Used by `cascadeDelete: 'descendants'` and by tools that need to delete a + * subtree atomically. Independent of dirty-marking — pure traversal. + */ +export function collectDescendants( + startId: AnyNodeId, + ctx: Pick, +): Set { + const result = new Set() + const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH + walkDescendants(startId, ctx, result, 0, maxDepth) + return result +} + +function walkDescendants( + id: AnyNodeId, + ctx: Pick, + result: Set, + depth: number, + maxDepth: number, +): void { + if (result.has(id) || depth > maxDepth) return + result.add(id) + const node = ctx.scene.get(id) + if (!node) return + const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene) + for (const childId of childIds) { + walkDescendants(childId, ctx, result, depth + 1, maxDepth) + } +}