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:
co-authored by
Claude Opus 4.7
parent
90702e74e2
commit
cf93c32183
@@ -65,6 +65,7 @@ export {
|
||||
} from './material-library'
|
||||
export * from './registry'
|
||||
export * from './schema'
|
||||
export * from './services'
|
||||
export {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type AttachError,
|
||||
type AttachResult,
|
||||
canAttach,
|
||||
clampYToHostTop,
|
||||
getSurface,
|
||||
getTopSurfaceHeight,
|
||||
MAX_HOST_DEPTH,
|
||||
pickHost,
|
||||
type Vec3,
|
||||
} from './hosting'
|
||||
Reference in New Issue
Block a user