Wassim SAMAD cf93c32183 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>
2026-05-14 12:20:28 -04:00

150 lines
5.4 KiB
TypeScript

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
}