Add HostingService with cycle/depth/kind validation (Phase 1, 2/6)

First service in `core/src/services/`: pure, no React or R3F, takes
SceneApi + node data, returns results.

Exports:
- `canAttach(childId, hostId, scene)` — validates host attachment.
  Rejects self-host, cycles (host's ancestor chain contains child),
  chains past MAX_HOST_DEPTH (6), and host kinds outside the child
  def's `capabilities.hostable.parents` allowlist. Returns a typed
  AttachError discriminated union so callers can render specific
  messages.
- `getSurface(host)` / `getTopSurfaceHeight(host)` — reads
  `def.capabilities.surfaces` from the registry; resolves
  function-valued heights with the node.
- `clampYToHostTop(host, y)` — convenience for placement code.
- `pickHost({ point, candidates, placedKind, hitTest? })` — given
  spatially pre-filtered candidates, returns the first hostable.
  The runtime is responsible for spatial filtering; this function
  stays pure.

MAX_HOST_DEPTH = 6: the explore earlier found today's editor has no
cap on item-on-item nesting. Cap is bounded by hostable depth, not
total tree depth (sites/buildings/levels don't count).

17 tests cover all rejection paths + happy paths + function-valued
surface heights.

Re-exported from `@pascal-app/core` via a new `services/` barrel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 12:20:28 -04:00
co-authored by Claude Opus 4.7
parent 90702e74e2
commit cf93c32183
4 changed files with 394 additions and 0 deletions
+1
View File
@@ -65,6 +65,7 @@ export {
} from './material-library'
export * from './registry'
export * from './schema'
export * from './services'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
+233
View File
@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
canAttach,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
MAX_HOST_DEPTH,
pickHost,
} from './hosting'
const id = (s: string) => s as AnyNodeId
function makeDef(
kind: string,
capabilities: Capabilities = {},
overrides: Partial<AnyNodeDefinition> = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
}
}
function makeNode(kind: string, idStr: string, parentId: string | null = null): AnyNode {
return {
id: id(idStr),
type: kind,
parentId: parentId ? id(parentId) : null,
visible: true,
} as unknown as AnyNode
}
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: () => {},
upsert: () => id(''),
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
}
}
describe('canAttach', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('rejects self-host', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = canAttach(id('a'), id('a'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('self-host')
})
test('rejects missing host', () => {
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
const result = canAttach(id('a'), id('missing'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('host-missing')
})
test('detects cycle when host is a descendant of child', () => {
// child=a, host=b, b's parent chain leads back to a → cycle.
const scene = makeFakeScene({
a: makeNode('thing', 'a'),
b: makeNode('thing', 'b', 'c'),
c: makeNode('thing', 'c', 'a'), // c.parent = a, b.parent = c → cycle if a → b
})
const result = canAttach(id('a'), id('b'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('cycle')
})
test('rejects when chain would exceed MAX_HOST_DEPTH', () => {
const nodes: Record<string, AnyNode> = {}
for (let i = 0; i <= MAX_HOST_DEPTH; i++) {
nodes[`n${i}`] = makeNode('thing', `n${i}`, i === 0 ? null : `n${i - 1}`)
}
// n6 is already MAX_HOST_DEPTH deep — attaching n_new beneath it would
// push the child to MAX_HOST_DEPTH + 1.
nodes.candidate = makeNode('thing', 'candidate')
const scene = makeFakeScene(nodes)
const result = canAttach(id('candidate'), id(`n${MAX_HOST_DEPTH}`), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('depth-exceeded')
})
test('accepts attach when chain stays within MAX_HOST_DEPTH', () => {
const scene = makeFakeScene({
root: makeNode('thing', 'root'),
candidate: makeNode('thing', 'candidate'),
})
expect(canAttach(id('candidate'), id('root'), scene).ok).toBe(true)
})
test('rejects host kind not in child def.hostable.parents', () => {
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }))
const scene = makeFakeScene({
s: makeNode('shelf', 's'),
ceiling: makeNode('ceiling', 'ceiling'),
})
const result = canAttach(id('s'), id('ceiling'), scene)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe('kind-not-allowed')
})
test('accepts when host kind is in parents', () => {
registerNode(makeDef('shelf', { hostable: { parents: ['wall', 'slab'] } }))
const scene = makeFakeScene({
s: makeNode('shelf', 's'),
w: makeNode('wall', 'w'),
})
expect(canAttach(id('s'), id('w'), scene).ok).toBe(true)
})
test('no def or no hostable.parents = no kind restriction', () => {
// Some kinds (e.g. items via catalog) defer to runtime checks instead of
// declaring parents up front. canAttach should not block them.
const scene = makeFakeScene({
i: makeNode('item', 'i'),
w: makeNode('wall', 'w'),
})
expect(canAttach(id('i'), id('w'), scene).ok).toBe(true)
})
test('allows missing child (placement preview before commit)', () => {
const scene = makeFakeScene({ w: makeNode('wall', 'w') })
expect(canAttach(id('future-child'), id('w'), scene).ok).toBe(true)
})
})
describe('getSurface / getTopSurfaceHeight', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns null when host has no registered def', () => {
const node = makeNode('mystery', 'm')
expect(getSurface(node)).toBeNull()
expect(getTopSurfaceHeight(node)).toBeNull()
})
test('returns surface config when declared', () => {
registerNode(
makeDef('table', {
surfaces: { top: { height: 0.74 } },
}),
)
const t = makeNode('table', 't')
expect(getSurface(t)?.top?.height).toBe(0.74)
expect(getTopSurfaceHeight(t)).toBe(0.74)
})
test('evaluates function-valued height with the node', () => {
registerNode(
makeDef('shelf', {
surfaces: {
top: {
height: (n: any) => (n.id === id('high') ? 1.8 : 0.3),
},
},
}),
)
expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8)
expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3)
})
})
describe('clampYToHostTop', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('clamps to top when host has one', () => {
registerNode(makeDef('table', { surfaces: { top: { height: 0.74 } } }))
expect(clampYToHostTop(makeNode('table', 't'), 5)).toBe(0.74)
})
test('passes through when host has no top surface', () => {
registerNode(makeDef('plain'))
expect(clampYToHostTop(makeNode('plain', 'p'), 5)).toBe(5)
})
})
describe('pickHost', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('returns first candidate that has hostable capability', () => {
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }))
registerNode(makeDef('item')) // no hostable
const candidates = [makeNode('item', 'i'), makeNode('slab', 's')]
const picked = pickHost({
point: [0, 0, 0],
candidates,
placedKind: 'chair',
})
expect(picked?.id).toBe(id('s'))
})
test('returns null when nothing in candidates is hostable', () => {
registerNode(makeDef('item'))
const candidates = [makeNode('item', 'i')]
expect(pickHost({ point: [0, 0, 0], candidates, placedKind: 'chair' })).toBeNull()
})
test('hitTest can reject hostable candidates', () => {
registerNode(makeDef('slab', { hostable: { parents: ['*'] } }))
const candidates = [makeNode('slab', 's1'), makeNode('slab', 's2')]
const picked = pickHost({
point: [0, 0, 0],
candidates,
placedKind: 'chair',
hitTest: (host) => host.id === id('s2'),
})
expect(picked?.id).toBe(id('s2'))
})
})
+149
View File
@@ -0,0 +1,149 @@
import { nodeRegistry } from '../registry/registry'
import type { SceneApi, SurfacesConfig } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
/**
* Maximum depth a node tree can host. Guards items-on-items-on-items chains
* from growing pathological — pre-Phase-1 the editor had no cap. Set high
* enough to allow legitimate stacking (chair on platform on truck on floor)
* while preventing AI/plugin-generated runaway.
*/
export const MAX_HOST_DEPTH = 6
export type Vec3 = readonly [number, number, number]
export type AttachError =
| { kind: 'self-host'; nodeId: AnyNodeId }
| { kind: 'cycle'; nodeId: AnyNodeId; hostId: AnyNodeId }
| { kind: 'depth-exceeded'; depth: number; max: number }
| { kind: 'host-missing'; hostId: AnyNodeId }
| { kind: 'kind-not-allowed'; hostKind: string; allowed: readonly string[] }
export type AttachResult = { ok: true } | { ok: false; error: AttachError }
/**
* Validates that attaching `child` to `host` is safe and either returns an
* actionable error or signals OK. Does NOT mutate the scene — callers apply
* the patch after a successful check.
*
* Rules:
* - A node cannot host itself.
* - The hosting chain (child → host → host.parent → ...) must not contain
* `child` (cycle prevention).
* - The resulting chain must not exceed {@link MAX_HOST_DEPTH}.
* - If the child's NodeDefinition declares `capabilities.hostable.parents`,
* `host.type` must appear in that list.
*/
export function canAttach(childId: AnyNodeId, hostId: AnyNodeId, scene: SceneApi): AttachResult {
if (childId === hostId) {
return { ok: false, error: { kind: 'self-host', nodeId: childId } }
}
const host = scene.get(hostId)
if (!host) {
return { ok: false, error: { kind: 'host-missing', hostId } }
}
const child = scene.get(childId)
if (!child) {
// No child node yet — likely a placement preview. Allow attach to proceed;
// the caller is responsible for ensuring child exists before commit.
return checkDepth(hostId, scene)
}
const childDef = nodeRegistry.get(child.type)
const allowed = childDef?.capabilities.hostable?.parents
if (allowed && allowed.length > 0 && !(allowed as readonly string[]).includes(host.type)) {
return {
ok: false,
error: { kind: 'kind-not-allowed', hostKind: host.type, allowed },
}
}
// Cycle: walk host's ancestors and reject if we hit the child.
let cursor: AnyNode | undefined = host
while (cursor) {
if (cursor.id === childId) {
return { ok: false, error: { kind: 'cycle', nodeId: childId, hostId } }
}
cursor = cursor.parentId ? scene.get(cursor.parentId as AnyNodeId) : undefined
}
return checkDepth(hostId, scene)
}
function checkDepth(hostId: AnyNodeId, scene: SceneApi): AttachResult {
// Count host's own depth (root = 0); attaching adds 1 to the child's depth.
let depth = 0
let cursor: AnyNode | undefined = scene.get(hostId)
while (cursor?.parentId) {
cursor = scene.get(cursor.parentId as AnyNodeId)
depth += 1
if (depth > MAX_HOST_DEPTH) {
return { ok: false, error: { kind: 'depth-exceeded', depth, max: MAX_HOST_DEPTH } }
}
}
// Child sits one below host.
if (depth + 1 > MAX_HOST_DEPTH) {
return { ok: false, error: { kind: 'depth-exceeded', depth: depth + 1, max: MAX_HOST_DEPTH } }
}
return { ok: true }
}
/**
* Returns the surfaces declared by a host's NodeDefinition. Surfaces describe
* where other nodes can stack/mount — the `top` of a slab, the `sides` of a
* wall, or a custom callback. Returns null when the host's def declares no
* surfaces (or no def is registered).
*/
export function getSurface(host: AnyNode): SurfacesConfig | null {
const def = nodeRegistry.get(host.type)
return def?.capabilities.surfaces ?? null
}
/**
* Resolves the stackable top height of a host (e.g. table surface, slab top,
* stair landing). Returns `null` when the host has no `surfaces.top`.
*/
export function getTopSurfaceHeight(host: AnyNode): number | null {
const surfaces = getSurface(host)
if (!surfaces?.top) return null
const { height } = surfaces.top
return typeof height === 'function' ? height(host) : height
}
/**
* Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose
* `capabilities.hostable` lists `placedKind` AND whose surface contains the
* point. The runtime is responsible for providing pre-filtered candidates;
* this function does not perform spatial queries itself.
*/
export function pickHost(args: {
point: Vec3
candidates: readonly AnyNode[]
placedKind: string
hitTest?: (host: AnyNode, point: Vec3) => boolean
}): AnyNode | null {
for (const host of args.candidates) {
const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable
if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (args.hitTest && !args.hitTest(host, args.point)) continue
return host
}
return null
}
/**
* Convenience: clamps a Y coordinate to the top of a host surface, when one
* is declared. Returns the original Y if the host has no top surface.
*/
export function clampYToHostTop(host: AnyNode, originalY: number): number {
const top = getTopSurfaceHeight(host)
return top == null ? originalY : top
}
+11
View File
@@ -0,0 +1,11 @@
export {
type AttachError,
type AttachResult,
canAttach,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
MAX_HOST_DEPTH,
pickHost,
type Vec3,
} from './hosting'