import type { MobVector3 } from "./mobPopulation"; export type DungeonWorldObjectKind = | "door" | "gate" | "bridge" | "switch" | "key" | "chest" | "resource" | "generic" | "chair" | "spell-focus" | "text" | "trap" | "transport" | "camera" | "ritual" | "mailbox" | "fishing-hole" | "aura-generator" | "spellcaster" | "destructible" | "npc" | "quest-object" | "trigger"; export type DungeonWorldObjectFidelity = | "source-exact" | "source-backed-adapted" | "healer-man-authored"; export interface DungeonWorldObjectSource { readonly fidelity: DungeonWorldObjectFidelity; readonly files: readonly string[]; readonly templateId?: number; readonly spawnId?: number; readonly gameObjectType?: number; readonly clientDisplayId?: number; readonly clientModelPath?: string; /** Unscaled client x/y/z display bounds retained for asset/collision review. */ readonly clientBounds?: { readonly min: MobVector3; readonly max: MobVector3; }; readonly note?: string; } export interface DungeonWorldObjectStateDefinition { readonly id: string; readonly label: string; readonly visible: boolean; readonly blocksMovement: boolean; readonly blocksSight?: boolean; readonly interactionEnabled?: boolean; } export interface DungeonWorldObjectInteraction { readonly prompt: string; readonly range: number; readonly signalId: string; readonly allowedStates?: readonly string[]; readonly oneShot?: boolean; readonly instructions?: string; } export interface DungeonWorldObjectVisual { /** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */ readonly assetUrl?: string; /** Source-adapted records stay hidden until their client display is reviewed. */ readonly hidden?: boolean; readonly color?: string; readonly emissive?: string; readonly scale?: MobVector3; } export interface DungeonWorldObjectDefinition { readonly id: string; readonly name: string; readonly kind: DungeonWorldObjectKind; readonly position: MobVector3; readonly yaw?: number; /** Half-extents of the interaction/collision box in metres. */ readonly halfExtents: MobVector3; readonly initialState: string; readonly states: readonly DungeonWorldObjectStateDefinition[]; readonly interaction?: DungeonWorldObjectInteraction; readonly visual?: DungeonWorldObjectVisual; readonly source?: DungeonWorldObjectSource; } export interface DungeonWorldInteractionCandidate { readonly object: DungeonWorldObjectDefinition; readonly state: DungeonWorldObjectStateDefinition; readonly distance: number; } function finiteDistance(left: MobVector3, right: MobVector3): number { if (!left.every(Number.isFinite) || !right.every(Number.isFinite)) return Number.POSITIVE_INFINITY; return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } export function dungeonWorldObjectState( object: DungeonWorldObjectDefinition, stateId: string | undefined, ): DungeonWorldObjectStateDefinition { const requested = stateId ?? object.initialState; return object.states.find((state) => state.id === requested) ?? object.states.find((state) => state.id === object.initialState) ?? { id: object.initialState, label: object.initialState, visible: true, blocksMovement: false, interactionEnabled: false, }; } export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition): void { if (!object.id.trim()) throw new Error("Dungeon world objects require a stable id."); if (!object.name.trim()) throw new Error(`Dungeon world object ${object.id} requires a name.`); if (!object.position.every(Number.isFinite) || !object.halfExtents.every((value) => Number.isFinite(value) && value > 0)) { throw new Error(`Dungeon world object ${object.id} has invalid geometry.`); } const stateIds = new Set(); for (const state of object.states) { if (!state.id.trim() || stateIds.has(state.id)) { throw new Error(`Dungeon world object ${object.id} has an invalid or duplicate state id.`); } stateIds.add(state.id); } if (!stateIds.has(object.initialState)) { throw new Error(`Dungeon world object ${object.id} does not define initial state ${object.initialState}.`); } if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) { throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`); } if (object.visual?.assetUrl && object.visual.hidden) { throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`); } if (object.source?.gameObjectType !== undefined && ( !Number.isInteger(object.source.gameObjectType) || object.source.gameObjectType < 0 || object.source.gameObjectType > 35 )) { throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`); } const clientBounds = object.source?.clientBounds; if (clientBounds && ( !clientBounds.min.every(Number.isFinite) || !clientBounds.max.every(Number.isFinite) || clientBounds.min.some((value, axis) => value >= clientBounds.max[axis]) )) { throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`); } } export function nearestDungeonWorldInteraction( objects: readonly DungeonWorldObjectDefinition[], states: Readonly>, origin: MobVector3, hasLineOfSight: (from: MobVector3, to: MobVector3) => boolean = () => true, ): DungeonWorldInteractionCandidate | null { let nearest: DungeonWorldInteractionCandidate | null = null; for (const object of objects) { const interaction = object.interaction; if (!interaction) continue; const state = dungeonWorldObjectState(object, states[object.id]); if (!state.visible) continue; if (state.interactionEnabled === false) continue; if (interaction.allowedStates?.length && !interaction.allowedStates.includes(state.id)) continue; const distance = finiteDistance(origin, object.position); if (distance > interaction.range || !hasLineOfSight(origin, object.position)) continue; if (!nearest || distance < nearest.distance || (distance === nearest.distance && object.id < nearest.object.id)) { nearest = { object, state, distance }; } } return nearest; }