diff --git a/scripts/HP_SlotRegistry.lua b/scripts/HP_SlotRegistry.lua index db956db..f987144 100644 --- a/scripts/HP_SlotRegistry.lua +++ b/scripts/HP_SlotRegistry.lua @@ -190,6 +190,12 @@ end if HP_WorldActionUI == nil and source ~= nil then source((g_currentModDirectory or "") .. "scripts/HP_WorldActionUI.lua") end +if HP_WorldPedestrianProbe == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldPedestrianProbe.lua") +end +if HP_WorldSplinePath == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldSplinePath.lua") +end if HP_WorldMovementProbe == nil and source ~= nil then source((g_currentModDirectory or "") .. "scripts/HP_WorldMovementProbe.lua") end diff --git a/scripts/HP_WorldPedestrianProbe.lua b/scripts/HP_WorldPedestrianProbe.lua new file mode 100644 index 0000000..5f5212c --- /dev/null +++ b/scripts/HP_WorldPedestrianProbe.lua @@ -0,0 +1,345 @@ +-- HP_WorldPedestrianProbe.lua (FS25_HelperProfiles) +-- Alpha 6 research probe for GIANTS' native PedestrianSystem / spline runtime. +-- +-- This module is intentionally diagnostic-only. It does not alter world-worker +-- locomotion, graphics, obstacle handling, persistence or UI behaviour. +-- +-- Commands: +-- hpWorld pedprobe +-- hpWorld pedprobe system +-- hpWorld pedprobe globals +-- hpWorld pedprobe mission +-- hpWorld pedprobe spline +-- hpWorld pedprobe scene +-- hpWorld pedprobe all + +if HP_WorldWorkerManager == nil then return end +if HP_WorldPedestrianProbe ~= nil then return end + +HP_WorldPedestrianProbe = { + version = "2.2.0.0-alpha6-pedestrian-probe-1", + installed = false, + maxTableEntries = 180, + maxSceneNodes = 12000, + maxSceneDepth = 12 +} + +local Probe = HP_WorldPedestrianProbe +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/PedestrianProbe] " + +local function log(message, ...) + local ok, text = pcall(string.format, tostring(message), ...) + print(LOG .. (ok and text or tostring(message))) +end + +local function lower(value) + return string.lower(tostring(value or "")) +end + +local function containsPedestrian(value) + local text = lower(value) + return string.find(text, "pedestrian", 1, true) ~= nil + or string.find(text, "ped", 1, true) == 1 +end + +local function sortedKeys(tbl, predicate) + local keys = {} + if type(tbl) ~= "table" then return keys end + for key, value in pairs(tbl) do + if predicate == nil or predicate(key, value) then + keys[#keys + 1] = key + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + return keys +end + +local function functionInfo(fn) + if type(fn) ~= "function" then return "" end + if debug == nil or debug.getinfo == nil then return "function" end + local ok, info = pcall(debug.getinfo, fn, "Sln") + if not ok or info == nil then return "function" end + + local source = tostring(info.short_src or info.source or "?") + local line = tonumber(info.linedefined) or -1 + local name = tostring(info.name or "") + if name ~= "" then + return string.format("function name=%s source=%s:%d", name, source, line) + end + return string.format("function source=%s:%d", source, line) +end + +local function valueSummary(value) + local t = type(value) + if t == "function" then return functionInfo(value) end + if t == "table" then + local count = 0 + for _ in pairs(value) do count = count + 1 end + return string.format("table entries=%d tostring=%s", count, tostring(value)) + end + if t == "userdata" then return "userdata " .. tostring(value) end + if t == "string" then + local text = value + if #text > 160 then text = string.sub(text, 1, 157) .. "..." end + return string.format("string %q", text) + end + return t .. " " .. tostring(value) +end + +local function dumpTable(label, tbl, maxEntries) + log("TABLE %s type=%s value=%s", tostring(label), type(tbl), tostring(tbl)) + if type(tbl) ~= "table" then return end + + local keys = sortedKeys(tbl) + local limit = math.max(1, tonumber(maxEntries) or Probe.maxTableEntries) + local shown = 0 + for _, key in ipairs(keys) do + shown = shown + 1 + if shown > limit then + log("TABLE %s truncated after %d/%d entries", tostring(label), limit, #keys) + break + end + local ok, value = pcall(function() return tbl[key] end) + if ok then + log(" %s[%s] -> %s", tostring(label), tostring(key), valueSummary(value)) + else + log(" %s[%s] -> ", tostring(label), tostring(key), tostring(value)) + end + end +end + +local function probeLikelySystemObjects() + log("=== Pedestrian system candidates ===") + + local candidates = { + {"_G.PedestrianSystem", rawget(_G, "PedestrianSystem")}, + {"_G.g_pedestrianSystem", rawget(_G, "g_pedestrianSystem")}, + {"g_currentMission.pedestrianSystem", g_currentMission ~= nil and g_currentMission.pedestrianSystem or nil}, + {"g_currentMission.pedestrianSystemManager", g_currentMission ~= nil and g_currentMission.pedestrianSystemManager or nil}, + {"g_currentMission.pedestrianManager", g_currentMission ~= nil and g_currentMission.pedestrianManager or nil} + } + + for _, candidate in ipairs(candidates) do + local label, value = candidate[1], candidate[2] + log("CANDIDATE %s -> %s", label, valueSummary(value)) + if type(value) == "table" then dumpTable(label, value, Probe.maxTableEntries) end + end +end + +local function probeGlobals() + log("=== Global keys containing pedestrian/ped* ===") + local keys = sortedKeys(_G, function(key, _) + return containsPedestrian(key) + end) + if #keys == 0 then + log("No matching global keys found") + return + end + + for _, key in ipairs(keys) do + local value = rawget(_G, key) + log("GLOBAL %s -> %s", tostring(key), valueSummary(value)) + end +end + +local function probeMission() + log("=== Mission keys containing pedestrian/ped* ===") + if type(g_currentMission) ~= "table" then + log("g_currentMission unavailable or not a Lua table") + return + end + + local keys = sortedKeys(g_currentMission, function(key, _) + return containsPedestrian(key) + end) + if #keys == 0 then + log("No matching g_currentMission keys found") + return + end + + for _, key in ipairs(keys) do + local value = g_currentMission[key] + log("MISSION %s -> %s", tostring(key), valueSummary(value)) + if type(value) == "table" then dumpTable("mission." .. tostring(key), value, 100) end + end +end + +local function probeSplineRuntime() + log("=== Spline runtime availability ===") + + local names = { + "getSplineLength", + "getSplinePosition", + "getSplineDirection", + "getSplineTime", + "getSplineEP", + "getNumOfSplineEPs", + "getClosestSplinePosition", + "getClosestSplinePositionVector", + "createSplineFromEditPoints", + "setSplineEP", + "addSplineEP", + "SplineUtil" + } + + for _, name in ipairs(names) do + local value = rawget(_G, name) + log("SPLINE GLOBAL %s -> %s", name, valueSummary(value)) + if name == "SplineUtil" and type(value) == "table" then + dumpTable("SplineUtil", value, 100) + end + end +end + +local function safeNodeName(node) + if node == nil or node == 0 or getName == nil then return "" end + local ok, name = pcall(getName, node) + return ok and tostring(name or "") or "" +end + +local function safeChildCount(node) + if node == nil or node == 0 or getNumOfChildren == nil then return 0 end + local ok, count = pcall(getNumOfChildren, node) + return ok and math.max(0, math.floor(tonumber(count) or 0)) or 0 +end + +local function safeChildAt(node, index) + if getChildAt == nil then return nil end + local ok, child = pcall(getChildAt, node, index) + return ok and child or nil +end + +local function nodePath(node, parentPath) + local name = safeNodeName(node) + if name == "" then name = "" end + if parentPath == nil or parentPath == "" then return name end + return parentPath .. "/" .. name +end + +local function probeScene() + log("=== Scenegraph pedestrian node search ===") + if getRootNode == nil then + log("getRootNode unavailable") + return + end + + local okRoot, root = pcall(getRootNode) + if not okRoot or root == nil or root == 0 then + log("Unable to resolve scene root") + return + end + + local maxNodes = math.max(100, tonumber(Probe.maxSceneNodes) or 12000) + local maxDepth = math.max(1, tonumber(Probe.maxSceneDepth) or 12) + local stack = {{node = root, depth = 0, path = safeNodeName(root)}} + local visited, matched = 0, 0 + + while #stack > 0 and visited < maxNodes do + local item = table.remove(stack) + local node = item.node + local depth = item.depth + local path = item.path + visited = visited + 1 + + local name = safeNodeName(node) + if containsPedestrian(name) or containsPedestrian(path) then + matched = matched + 1 + log("SCENE MATCH node=%s depth=%d children=%d path=%s", + tostring(node), depth, safeChildCount(node), tostring(path)) + end + + if depth < maxDepth then + local count = safeChildCount(node) + -- reverse push keeps child order readable when popped + for i = count - 1, 0, -1 do + local child = safeChildAt(node, i) + if child ~= nil and child ~= 0 then + stack[#stack + 1] = { + node = child, + depth = depth + 1, + path = nodePath(child, path) + } + end + end + end + end + + log("SCENE SEARCH complete visited=%d matched=%d%s", + visited, matched, visited >= maxNodes and " (node limit reached)" or "") +end + +local function normalizeCommandArgs(...) + local args = {...} + local clean = {} + for _, value in ipairs(args) do + if value ~= nil and tostring(value) ~= "" and tostring(value) ~= "hpWorld" then + clean[#clean + 1] = tostring(value) + end + end + return clean[1], clean[2], clean[3], clean[4] +end + +function Probe:run(mode) + mode = lower(mode) + if mode == "" then mode = "summary" end + + log("RUN %s mode=%s", tostring(self.version), mode) + + if mode == "summary" then + probeLikelySystemObjects() + probeMission() + probeSplineRuntime() + elseif mode == "system" then + probeLikelySystemObjects() + elseif mode == "globals" then + probeGlobals() + elseif mode == "mission" then + probeMission() + elseif mode == "spline" then + probeSplineRuntime() + elseif mode == "scene" then + probeScene() + elseif mode == "all" then + probeLikelySystemObjects() + probeGlobals() + probeMission() + probeSplineRuntime() + probeScene() + else + log("Unknown mode '%s'. Use summary|system|globals|mission|spline|scene|all", mode) + return false + end + + log("DONE mode=%s", mode) + return true +end + +function Probe:install() + if self.installed then return true end + if Manager == nil or type(Manager.consoleCommandWorld) ~= "function" then return false end + + local originalConsole = Manager.consoleCommandWorld + function Manager:consoleCommandWorld(...) + local sub, mode = normalizeCommandArgs(...) + sub = lower(sub or "status") + + if sub == "pedprobe" or sub == "pedestrianprobe" then + Probe:run(mode) + return + elseif sub == "help" then + originalConsole(self, ...) + print("[HP] Alpha6 pedestrian research: hpWorld pedprobe [summary|system|globals|mission|spline|scene|all]") + print("[HP] Probe is diagnostic-only; it does not alter world-worker movement.") + return + end + + return originalConsole(self, ...) + end + + self.installed = true + log("Loaded %s (diagnostic-only native PedestrianSystem + spline runtime inspection)", tostring(self.version)) + return true +end + +Probe:install() diff --git a/scripts/HP_WorldSplinePath.lua b/scripts/HP_WorldSplinePath.lua new file mode 100644 index 0000000..75655ae --- /dev/null +++ b/scripts/HP_WorldSplinePath.lua @@ -0,0 +1,510 @@ +-- HP_WorldSplinePath.lua (FS25_HelperProfiles) +-- Alpha 6 experimental spline-guided path execution. +-- +-- This does NOT replace normal COME/FOLLOW. It creates a temporary GIANTS +-- cubic spline and continuously feeds the validated curved locomotion layer a +-- short look-ahead target on that spline. The experiment isolates whether a +-- spline/tangent-led path produces the smoother flow observed in pedestrians. +-- +-- Commands: +-- hpWorld smoothcome [slot] [standOffMetres] +-- hpWorld smoothgoto [slot] +-- hpWorld smoothstop [slot] + +if HP_WorldLocomotionPrototype == nil then return end +if HP_WorldLocomotionCurved == nil then return end +if HP_WorldTargetNavigation == nil then return end +if HP_WorldWorkerManager == nil then return end +if HP_WorldSplinePath ~= nil then return end + +HP_WorldSplinePath = { + version = "2.2.0.0-alpha6-spline-path-1", + paths = {}, + lookAheadDistance = 1.05, + finishDirectDistance = 0.90, + minimumPathDistance = 1.0, + maximumPathDistance = 100.0, + startTangentMin = 0.8, + startTangentMax = 3.2, + logIntervalMs = 1000, + installed = false +} + +local Path = HP_WorldSplinePath +local Loco = HP_WorldLocomotionPrototype +local Nav = HP_WorldTargetNavigation +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/WorldSplinePath] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function clamp(value, minimum, maximum) + value = tonumber(value) or 0 + if value < minimum then return minimum end + if value > maximum then return maximum end + return value +end + +local function resolveIndex(value) + if value ~= nil and tostring(value) ~= "" then + if HP_SlotRegistry ~= nil then + local index = HP_SlotRegistry:slotToIndex(value, HP_SlotRegistry.TARGET_COUNT or 20) + if index ~= nil then return index end + end + local numeric = math.floor(tonumber(value) or 0) + if numeric >= 1 and numeric <= (HP_SlotRegistry ~= nil and HP_SlotRegistry.TARGET_COUNT or 20) then return numeric end + return nil + end + + if HelperProfiles ~= nil and HelperProfiles.getSelectedHelper ~= nil and HelperProfiles.getStableIndexForHelper ~= nil then + local okHelper, helper = pcall(HelperProfiles.getSelectedHelper, HelperProfiles) + if okHelper and helper ~= nil then + local okIndex, index = pcall(HelperProfiles.getStableIndexForHelper, HelperProfiles, helper) + if okIndex and tonumber(index) ~= nil then return math.floor(tonumber(index)) end + end + end + return nil +end + +local function getCanonicalId(index) + if HP_SlotRegistry ~= nil then return HP_SlotRegistry:canonicalId(index) end + return string.format("helper%02d", math.floor(tonumber(index) or 0)) +end + +local function getSlot(index) + if HP_SlotRegistry ~= nil then return HP_SlotRegistry:indexToSlot(index) end + return tostring(index) +end + +local function normalizeAngle(value) + value = tonumber(value) or 0 + local twoPi = math.pi * 2 + while value > math.pi do value = value - twoPi end + while value < -math.pi do value = value + twoPi end + return value +end + +local function directionFromYaw(yaw) + if MathUtil ~= nil and MathUtil.getDirectionFromYRotation ~= nil then + local ok, dx, dz = pcall(MathUtil.getDirectionFromYRotation, yaw) + if ok and tonumber(dx) ~= nil and tonumber(dz) ~= nil then return tonumber(dx), tonumber(dz) end + end + return math.sin(yaw), math.cos(yaw) +end + +local function terrainY(x, fallbackY, z) + local terrainNode = rawget(_G, "g_terrainNode") + if terrainNode ~= nil and terrainNode ~= 0 and getTerrainHeightAtWorldPos ~= nil then + local ok, value = pcall(getTerrainHeightAtWorldPos, terrainNode, x, 0, z) + if ok and tonumber(value) ~= nil then return tonumber(value) end + end + return tonumber(fallbackY) or 0 +end + +local function findLocalPlayer() + if rawget(_G, "g_localPlayer") ~= nil and g_localPlayer ~= nil then return g_localPlayer end + local mission = g_currentMission + if mission == nil then return nil end + if mission.player ~= nil then return mission.player end + if mission.controlledPlayer ~= nil then return mission.controlledPlayer end + + local playerSystem = mission.playerSystem + if playerSystem ~= nil then + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil and (player.isOwner == true or player.isLocallyControlled == true) then return player end + end + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil then return player end + end + end + return nil +end + +local function getPlayerXZ() + local player = findLocalPlayer() + if player == nil then return nil, nil, "local-player-unavailable" end + + if player.getPosition ~= nil then + local ok, x, _, z = pcall(player.getPosition, player) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + if player.getMapPositionAndLookYaw ~= nil then + local ok, x, z = pcall(player.getMapPositionAndLookYaw, player) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + if player.rootNode ~= nil and player.rootNode ~= 0 and getWorldTranslation ~= nil then + local ok, x, _, z = pcall(getWorldTranslation, player.rootNode) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + return nil, nil, "player-position-unavailable" +end + +local function getWorkerPose(index) + local id = getCanonicalId(index) + local instance = id ~= nil and Manager.instancesByCanonicalId ~= nil and Manager.instancesByCanonicalId[id] or nil + if instance == nil or instance.loading == true or instance.graphics == nil then + return nil, "worker-not-visible" + end + + local placement = HP_WorldState ~= nil and HP_WorldState:getPlacement(index) or nil + if placement == nil then return nil, "worker-not-placed" end + + local x = tonumber(placement.x) + local y = tonumber(placement.y) + local z = tonumber(placement.z) + local yaw = tonumber(instance.presenceCurrentYaw) or tonumber(placement.yaw) or 0 + + local root = instance.graphics.graphicsRootNode + if root ~= nil and root ~= 0 and getWorldTranslation ~= nil then + local ok, px, py, pz = pcall(getWorldTranslation, root) + if ok and tonumber(px) ~= nil and tonumber(pz) ~= nil then + x, z = tonumber(px), tonumber(pz) + if tonumber(py) ~= nil then y = tonumber(py) end + end + end + + if x == nil or z == nil then return nil, "worker-pose-unavailable" end + y = terrainY(x, y or 0, z) + return {x=x, y=y, z=z, yaw=normalizeAngle(yaw), id=id, instance=instance}, nil +end + +local function splineApiAvailable() + return createSplineFromEditPoints ~= nil + and getSplineLength ~= nil + and getClosestSplinePosition ~= nil + and getSplinePositionWithDistance ~= nil + and getSplinePosition ~= nil +end + +local function deleteSpline(state) + if state == nil or state.spline == nil or state.spline == 0 then return end + if delete ~= nil then pcall(delete, state.spline) end + state.spline = nil +end + +local function buildSpline(startPose, targetX, targetZ) + if not splineApiAvailable() then return nil, "required-spline-api-unavailable" end + if getRootNode == nil then return nil, "scene-root-unavailable" end + + local dx = targetX - startPose.x + local dz = targetZ - startPose.z + local distance = math.sqrt(dx * dx + dz * dz) + if distance < Path.minimumPathDistance then return nil, "target-too-close" end + if distance > Path.maximumPathDistance then return nil, "target-too-far" end + + local directX, directZ = dx / distance, dz / distance + local faceX, faceZ = directionFromYaw(startPose.yaw) + local tangent = clamp(distance * 0.30, Path.startTangentMin, Path.startTangentMax) + + -- Four edit points give the cubic spline a meaningful start tangent while + -- still converging onto the destination direction. The first intermediate + -- point follows the worker's current facing; the second approaches the end + -- along the overall route. This is path shaping, not obstacle avoidance. + local p0x, p0z = startPose.x, startPose.z + local p1x, p1z = p0x + faceX * tangent, p0z + faceZ * tangent + local p3x, p3z = targetX, targetZ + local p2x, p2z = p3x - directX * tangent, p3z - directZ * tangent + + local p0y = terrainY(p0x, startPose.y, p0z) + local p1y = terrainY(p1x, p0y, p1z) + local p2y = terrainY(p2x, p1y, p2z) + local p3y = terrainY(p3x, p2y, p3z) + + local editPoints = { + p0x, p0y, p0z, + p1x, p1y, p1z, + p2x, p2y, p2z, + p3x, p3y, p3z + } + + local okRoot, root = pcall(getRootNode) + if not okRoot or root == nil or root == 0 then return nil, "scene-root-unavailable" end + + local okSpline, spline = pcall(createSplineFromEditPoints, root, editPoints, false, false) + if not okSpline or spline == nil or spline == 0 then + return nil, "createSplineFromEditPoints-failed: " .. tostring(spline) + end + + if setVisibility ~= nil then pcall(setVisibility, spline, false) end + + local okLength, length = pcall(getSplineLength, spline) + if not okLength or tonumber(length) == nil then + if delete ~= nil then pcall(delete, spline) end + return nil, "getSplineLength-failed" + end + + return { + spline = spline, + pathLength = tonumber(length), + finalX = p3x, + finalY = p3y, + finalZ = p3z, + startX = p0x, + startY = p0y, + startZ = p0z, + tangent = tangent, + editPoints = editPoints, + splineTime = 0, + logMs = 0, + finishing = false + }, nil +end + +local function updateLookAhead(state, motion) + if state == nil or motion == nil or state.spline == nil then return false, "path-state-invalid" end + + local dxEnd = state.finalX - motion.x + local dzEnd = state.finalZ - motion.z + local directEndDistance = math.sqrt(dxEnd * dxEnd + dzEnd * dzEnd) + + if directEndDistance <= Path.finishDirectDistance then + state.finishing = true + motion.targetX = state.finalX + motion.targetY = state.finalY + motion.targetZ = state.finalZ + return true, nil + end + + local okClosest, _, _, _, closestT = pcall( + getClosestSplinePosition, + state.spline, + motion.x, motion.y, motion.z, + 0.02 + ) + if not okClosest or tonumber(closestT) == nil then + return false, "getClosestSplinePosition-failed" + end + + state.splineTime = clamp(closestT, 0, 1) + local lookAhead = math.max(0.35, tonumber(Path.lookAheadDistance) or 1.05) + local okAhead, tx, ty, tz, aheadT = pcall( + getSplinePositionWithDistance, + state.spline, + state.splineTime, + lookAhead, + true, + 0.01 + ) + + if okAhead and tonumber(tx) ~= nil and tonumber(tz) ~= nil and tonumber(aheadT) ~= nil then + motion.targetX = tonumber(tx) + motion.targetY = terrainY(tonumber(tx), tonumber(ty) or motion.y, tonumber(tz)) + motion.targetZ = tonumber(tz) + state.lookAheadT = tonumber(aheadT) + else + -- At/near the end the distance query may not be able to find a point + -- a full look-ahead metre ahead. Fall back to the spline endpoint. + state.finishing = true + motion.targetX = state.finalX + motion.targetY = state.finalY + motion.targetZ = state.finalZ + end + + return true, nil +end + +function Path:startPoint(indexOrSlot, targetX, targetZ, kind) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + + targetX = tonumber(targetX) + targetZ = tonumber(targetZ) + if targetX == nil or targetZ == nil then return false, "invalid-target-coordinates" end + + local pose, poseErr = getWorkerPose(index) + if pose == nil then return false, poseErr end + if Loco.motions[pose.id] ~= nil then return false, "worker-already-walking" end + + local state, splineErr = buildSpline(pose, targetX, targetZ) + if state == nil then return false, splineErr end + + local okStart, startErr = Nav:startPoint(index, targetX, targetZ, "spline-path") + if not okStart then + deleteSpline(state) + return false, startErr + end + + local motion = Loco.motions[pose.id] + if motion == nil then + deleteSpline(state) + return false, "motion-initialization-failed" + end + + state.id = pose.id + state.index = index + state.kind = tostring(kind or "smoothgoto") + self.paths[pose.id] = state + motion.navigationKind = "spline-path" + + local okAhead, aheadErr = updateLookAhead(state, motion) + if not okAhead then + self.paths[pose.id] = nil + deleteSpline(state) + Loco:finishMotion(motion, "spline-lookahead-init-failed", true) + return false, aheadErr + end + + log("PATH START %s kind=%s length=%.2f tangent=%.2f from=(%.2f,%.2f) final=(%.2f,%.2f) firstTarget=(%.2f,%.2f)", + tostring(getSlot(index)), state.kind, state.pathLength, state.tangent, + state.startX, state.startZ, state.finalX, state.finalZ, + tonumber(motion.targetX) or 0, tonumber(motion.targetZ) or 0) + return true, nil +end + +function Path:startCome(indexOrSlot, standOff) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + + local pose, poseErr = getWorkerPose(index) + if pose == nil then return false, poseErr end + + local playerX, playerZ, playerErr = getPlayerXZ() + if playerX == nil or playerZ == nil then return false, playerErr end + + standOff = clamp(standOff or (Nav.defaultStandOff or 1.8), Nav.minimumStandOff or 0.75, Nav.maximumStandOff or 5.0) + local dx = playerX - pose.x + local dz = playerZ - pose.z + local distance = math.sqrt(dx * dx + dz * dz) + if distance <= standOff + 0.20 then return false, "already-near-player" end + + local targetX = playerX - (dx / distance) * standOff + local targetZ = playerZ - (dz / distance) * standOff + local ok, err = self:startPoint(index, targetX, targetZ, "smoothcome") + if ok then + log("SMOOTH COME %s player=(%.2f,%.2f) standOff=%.2f final=(%.2f,%.2f)", + tostring(getSlot(index)), playerX, playerZ, standOff, targetX, targetZ) + end + return ok, err +end + +function Path:stop(indexOrSlot) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + local id = getCanonicalId(index) + local state = id ~= nil and self.paths[id] or nil + local motion = id ~= nil and Loco.motions[id] or nil + if state == nil and (motion == nil or motion.navigationKind ~= "spline-path") then + return false, "worker-not-on-spline-path" + end + + if motion ~= nil then + Loco:finishMotion(motion, "spline-path-cancelled", true) + else + self.paths[id] = nil + deleteSpline(state) + end + return true, nil +end + +function Path:updateState(state, dt) + if state == nil or state.id == nil then return end + local motion = Loco.motions[state.id] + if motion == nil or motion.navigationKind ~= "spline-path" then + self.paths[state.id] = nil + deleteSpline(state) + return + end + + if not state.finishing then + local ok, err = updateLookAhead(state, motion) + if not ok then + log("PATH ERROR %s: %s", tostring(getSlot(state.index)), tostring(err)) + Loco:finishMotion(motion, "spline-path-update-failed", true) + return + end + end + + state.logMs = (tonumber(state.logMs) or 0) - math.max(0, tonumber(dt) or 0) + if state.logMs <= 0 then + state.logMs = tonumber(self.logIntervalMs) or 1000 + local dx = state.finalX - motion.x + local dz = state.finalZ - motion.z + log("PATH %s t=%.4f aheadT=%s finishing=%s speed=%.3f finalDistance=%.2f target=(%.2f,%.2f)", + tostring(getSlot(state.index)), tonumber(state.splineTime) or 0, + state.lookAheadT ~= nil and string.format("%.4f", state.lookAheadT) or "-", + tostring(state.finishing == true), tonumber(motion.speed) or 0, + math.sqrt(dx * dx + dz * dz), + tonumber(motion.targetX) or 0, tonumber(motion.targetZ) or 0) + end +end + +local function normalizeCommandArgs(...) + local args = {...} + local clean = {} + for _, value in ipairs(args) do + if value ~= nil and tostring(value) ~= "" and tostring(value) ~= "hpWorld" then + clean[#clean + 1] = tostring(value) + end + end + return clean[1], clean[2], clean[3], clean[4] +end + +function Path:install() + if self.installed then return true end + + -- Retarget spline-guided motions before the validated movement stack runs. + -- The curved locomotion controller remains the sole owner of worker pose, + -- speed, yaw and HumanGraphicsComponent state. + local originalManagerUpdate = Manager.update + function Manager:update(dt, ...) + local snapshot = {} + for _, state in pairs(Path.paths) do snapshot[#snapshot + 1] = state end + for _, state in ipairs(snapshot) do Path:updateState(state, dt) end + return originalManagerUpdate(self, dt, ...) + end + + -- Ensure temporary engine spline entities are removed on every normal, + -- blocked, cancelled or error completion path. + local originalFinishMotion = Loco.finishMotion + function Loco:finishMotion(motion, reason, persist, ...) + local state = motion ~= nil and Path.paths[motion.id] or nil + if state ~= nil then + Path.paths[motion.id] = nil + deleteSpline(state) + log("PATH STOP %s reason=%s", tostring(getSlot(state.index)), tostring(reason or "complete")) + end + return originalFinishMotion(self, motion, reason, persist, ...) + end + + local originalConsole = Manager.consoleCommandWorld + function Manager:consoleCommandWorld(...) + local sub, slot, value1, value2 = normalizeCommandArgs(...) + sub = string.lower(tostring(sub or "status")) + + if sub == "smoothcome" or sub == "splinecome" then + local ok, err = Path:startCome(slot, value1) + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothcome %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "smoothgoto" or sub == "splinegoto" then + local ok, err = Path:startPoint(slot, value1, value2, "smoothgoto") + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothgoto %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "smoothstop" or sub == "splinestop" then + local ok, err = Path:stop(slot) + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothstop %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "help" then + originalConsole(self, ...) + print("[HP] Alpha6 spline prototype: hpWorld smoothcome [slot] [standOffMetres]") + print("[HP] Alpha6 spline prototype: hpWorld smoothgoto [slot] | smoothstop [slot]") + print("[HP] Experimental only: normal COME/FOLLOW remain unchanged.") + return + end + + return originalConsole(self, ...) + end + + self.installed = true + log("Loaded %s (runtime cubic spline + moving look-ahead target; normal COME/FOLLOW unchanged)", tostring(self.version)) + return true +end + +Path:install()