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'
|
} from './material-library'
|
||||||
export * from './registry'
|
export * from './registry'
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
|
export * from './services'
|
||||||
export {
|
export {
|
||||||
getSceneHistoryPauseDepth,
|
getSceneHistoryPauseDepth,
|
||||||
pauseSceneHistory,
|
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