diff --git a/docs/WORLD_WORKERS_ALPHA1.md b/docs/WORLD_WORKERS_ALPHA1.md new file mode 100644 index 0000000..aa12589 --- /dev/null +++ b/docs/WORLD_WORKERS_ALPHA1.md @@ -0,0 +1,66 @@ +# HelperProfiles 2.2.0.0-alpha1 — World Workers test notes + +This development branch introduces the first standalone world-worker prototype. It deliberately does **not** create an AI job or a fake networked player. + +## Scope + +- Persist a world placement per permanent HelperProfiles A–T identity. +- Spawn the worker with GIANTS `HumanGraphicsComponent`. +- Use the worker's bound AvatarSwitcher appearance where available; otherwise clone the helper's native `PlayerStyle`. +- Keep the worker in a static NPC/idle animation state. +- Restore placed workers when the save reloads. +- Temporarily despawn a placed worker while that same helper is active on an AI job, then restore the world representation when the AI job ends. The saved world location is not changed. +- Keep world placement independent from the ON/OFF hiring roster state. + +No pathfinding, interactions, schedules, dialogue or GUI controls are included in alpha1. + +## Test command + +Open the developer console and use: + +```text +hpWorld help +hpWorld status +hpWorld place [slot] +hpWorld move [slot] +hpWorld remove [slot] +hpWorld refresh [slot] +``` + +`slot` accepts A–T, `helper01`–`helper20`, or 1–20. If no slot is given, HelperProfiles uses the currently selected worker. + +`place` and `move` position the worker about two metres in front of the local player and turn the worker back toward the player. + +## Initial test sequence + +1. Load a single-player save with HelperProfiles and no active AI helper tasks. +2. Select a worker with `;`, or choose a slot explicitly. +3. Run `hpWorld place A`. +4. Confirm the worker appears approximately two metres in front of the player, in the correct appearance, and idles rather than entering an AI task. +5. Run `hpWorld status` and confirm A reports `spawned=true`. +6. Save/exit/reload and confirm the worker is restored in the same position. +7. Run `hpWorld move A` from another location and verify the saved placement changes. +8. Hire A for a normal AI task. The static world representation should disappear without deleting the stored placement. +9. End the AI task. The worker should return to the stored world position within roughly half a second. +10. Run `hpWorld remove A`; save/reload and confirm A is no longer placed. + +## State file + +Per-save placements are stored at: + +```text +modSettings/FS25_HelperProfiles/saves/savegameX/worldWorkers.xml +``` + +The file stores only identity and transform data (`x`, `y`, `z`, `yaw`). Appearance remains owned by the existing HelperProfiles / AvatarSwitcher binding system. + +## Expected alpha risks + +This is intentionally an engine-lifecycle test build. The areas to validate first are: + +- whether `HumanGraphicsComponent` accepts helper/AvatarSwitcher `PlayerStyle` data cleanly on every tested map; +- whether the idle/NPC animation parameters produce a natural standing animation rather than a bind pose; +- whether terrain-aligned placement is correct on slopes and around placeable surfaces; +- whether async style loading cleans up safely during rapid place/remove/reload operations. + +Do not merge this branch into the 2.1.0.0 ModHub submission line until these tests pass. diff --git a/scripts/HP_SlotRegistry.lua b/scripts/HP_SlotRegistry.lua index abcf299..29e9feb 100644 --- a/scripts/HP_SlotRegistry.lua +++ b/scripts/HP_SlotRegistry.lua @@ -148,3 +148,17 @@ end if HP_TabbedManagement == nil and source ~= nil then source((g_currentModDirectory or "") .. "scripts/HP_TabbedManagement.lua") end + +-- Alpha world-worker services are deliberately sourced from the stable slot +-- registry so the permanent A-T identity model is available before they load. +-- The runtime manager waits until the helper roster and appearance bridge are +-- ready before it restores any persisted world representations. +if HP_WorldState == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldState.lua") +end +if HP_WorldWorkerManager == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldWorkerManager.lua") +end +if HP_WorldWorkerPlayerAccess == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldWorkerPlayerAccess.lua") +end diff --git a/scripts/HP_WorldState.lua b/scripts/HP_WorldState.lua new file mode 100644 index 0000000..424e730 --- /dev/null +++ b/scripts/HP_WorldState.lua @@ -0,0 +1,262 @@ +-- HP_WorldState.lua (FS25_HelperProfiles) +-- Per-save placement state for standalone HelperProfiles world workers. + +if HP_WorldState ~= nil then return end + +HP_WorldState = { + initialized = false, + savegameName = nil, + savegameDir = nil, + stateFile = nil, + placementsByCanonicalId = {}, + version = "1.0" +} + +local LOG = "[FS25_HelperProfiles/WorldState] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function normalizePathSlashes(path) + if path == nil then return nil end + return tostring(path):gsub("\\", "/") +end + +local function getPathBaseName(path) + path = normalizePathSlashes(path or "") or "" + path = path:gsub("/+$", "") + local base = path:match("([^/]+)$") + return base ~= nil and base ~= "" and base or nil +end + +local function detectSavegameName() + local missionInfo = g_currentMission ~= nil and g_currentMission.missionInfo or nil + if missionInfo ~= nil then + local candidates = { + missionInfo.savegameDirectory, + missionInfo.savegameDir, + missionInfo.savegamePath, + missionInfo.savegameXMLFilename, + missionInfo.savegameSavePath + } + for _, value in ipairs(candidates) do + if value ~= nil and tostring(value) ~= "" then + local path = normalizePathSlashes(value) + local match = path ~= nil and path:match("(savegame%d+)") or nil + if match ~= nil and match ~= "" then return match end + local base = getPathBaseName(path) + if base ~= nil then return base end + end + end + + local index = missionInfo.savegameIndex or missionInfo.savegameNumber + or missionInfo.saveGameIndex or missionInfo.saveGameNumber + if tonumber(index) ~= nil then + return "savegame" .. tostring(math.floor(tonumber(index))) + end + end + return "unknownSavegame" +end + +local function ensureFolder(path) + if path ~= nil and path ~= "" and not fileExists(path) then + createFolder(path) + end +end + +local function readXmlNumber(xmlFile, key, defaultValue) + if getXMLFloat ~= nil then + local value = getXMLFloat(xmlFile, key) + if value ~= nil then return tonumber(value) or defaultValue end + end + local text = getXMLString(xmlFile, key) + return tonumber(text) or defaultValue +end + +local function writeXmlNumber(xmlFile, key, value) + if setXMLFloat ~= nil then + setXMLFloat(xmlFile, key, tonumber(value) or 0) + else + setXMLString(xmlFile, key, tostring(tonumber(value) or 0)) + end +end + +function HP_WorldState:init() + if self.initialized then return end + self.initialized = true + + local profilePath = getUserProfileAppPath() + local modSettingsDir = profilePath .. "modSettings/FS25_HelperProfiles/" + local savesDir = modSettingsDir .. "saves/" + self.savegameName = detectSavegameName() + self.savegameDir = savesDir .. tostring(self.savegameName) .. "/" + self.stateFile = self.savegameDir .. "worldWorkers.xml" + + ensureFolder(modSettingsDir) + ensureFolder(savesDir) + ensureFolder(self.savegameDir) + self:load() +end + +function HP_WorldState:getCanonicalId(indexOrSlot) + if HP_SlotRegistry ~= nil then + local index = HP_SlotRegistry:slotToIndex(indexOrSlot, HP_SlotRegistry.TARGET_COUNT) + if index ~= nil then return HP_SlotRegistry:canonicalId(index), index end + end + + local numeric = math.floor(tonumber(indexOrSlot) or 0) + if numeric >= 1 and numeric <= 20 then + return string.format("helper%02d", numeric), numeric + end + return nil, nil +end + +function HP_WorldState:getPlacement(indexOrSlot) + if not self.initialized then self:init() end + local canonicalId = self:getCanonicalId(indexOrSlot) + if canonicalId == nil then return nil end + local placement = self.placementsByCanonicalId[canonicalId] + if placement == nil then return nil end + return { + id = canonicalId, + x = placement.x, + y = placement.y, + z = placement.z, + yaw = placement.yaw or 0 + } +end + +function HP_WorldState:getAllPlacements() + if not self.initialized then self:init() end + local result = {} + for canonicalId, placement in pairs(self.placementsByCanonicalId or {}) do + result[canonicalId] = { + id = canonicalId, + x = placement.x, + y = placement.y, + z = placement.z, + yaw = placement.yaw or 0 + } + end + return result +end + +function HP_WorldState:setPlacement(indexOrSlot, x, y, z, yaw) + if not self.initialized then self:init() end + local canonicalId, index = self:getCanonicalId(indexOrSlot) + if canonicalId == nil then return false, "invalid-helper" end + + x, y, z, yaw = tonumber(x), tonumber(y), tonumber(z), tonumber(yaw) or 0 + if x == nil or y == nil or z == nil then return false, "invalid-position" end + + self.placementsByCanonicalId[canonicalId] = { + id = canonicalId, + index = index, + x = x, + y = y, + z = z, + yaw = yaw + } + return self:write() +end + +function HP_WorldState:clearPlacement(indexOrSlot) + if not self.initialized then self:init() end + local canonicalId = self:getCanonicalId(indexOrSlot) + if canonicalId == nil then return false, "invalid-helper" end + self.placementsByCanonicalId[canonicalId] = nil + return self:write() +end + +function HP_WorldState:load() + self.placementsByCanonicalId = {} + if self.stateFile == nil or not fileExists(self.stateFile) then + log("No per-save world-worker file found; no workers are placed") + return true + end + + local xmlFile = loadXMLFile("hpWorldStateRead", self.stateFile) + if xmlFile == nil or xmlFile == 0 then + log("Could not read world-worker state: %s", tostring(self.stateFile)) + return false, "unreadable" + end + + local row = 0 + while true do + local key = string.format("helperProfilesWorld.workers.worker(%d)", row) + if not hasXMLProperty(xmlFile, key) then break end + + local canonicalId = getXMLString(xmlFile, key .. "#id") + local slot = getXMLString(xmlFile, key .. "#slot") + local resolvedId, index = self:getCanonicalId(canonicalId or slot) + if resolvedId ~= nil then + self.placementsByCanonicalId[resolvedId] = { + id = resolvedId, + index = index, + x = readXmlNumber(xmlFile, key .. "#x", 0), + y = readXmlNumber(xmlFile, key .. "#y", 0), + z = readXmlNumber(xmlFile, key .. "#z", 0), + yaw = readXmlNumber(xmlFile, key .. "#yaw", 0) + } + end + row = row + 1 + end + delete(xmlFile) + + local count = 0 + for _ in pairs(self.placementsByCanonicalId) do count = count + 1 end + log("Loaded per-save world-worker state: savegame=%s placed=%d file=%s", + tostring(self.savegameName), count, tostring(self.stateFile)) + return true +end + +function HP_WorldState:write() + if not self.initialized then self:init() end + ensureFolder(self.savegameDir) + + local xmlFile = createXMLFile("hpWorldStateWrite", self.stateFile, "helperProfilesWorld") + if xmlFile == nil or xmlFile == 0 then return false, "create-failed" end + + setXMLString(xmlFile, "helperProfilesWorld#version", tostring(self.version)) + setXMLString(xmlFile, "helperProfilesWorld#savegame", tostring(self.savegameName or "unknownSavegame")) + setXMLString(xmlFile, "helperProfilesWorld#note", "Static HelperProfiles world placements. Runtime world presence is independent of AI helper jobs.") + + local rows = {} + for canonicalId, placement in pairs(self.placementsByCanonicalId or {}) do + rows[#rows + 1] = { id = canonicalId, placement = placement } + end + table.sort(rows, function(a, b) return tostring(a.id) < tostring(b.id) end) + + for rowIndex, row in ipairs(rows) do + local key = string.format("helperProfilesWorld.workers.worker(%d)", rowIndex - 1) + local index = HP_SlotRegistry ~= nil and HP_SlotRegistry:slotToIndex(row.id, HP_SlotRegistry.TARGET_COUNT) + or row.placement.index + local slot = HP_SlotRegistry ~= nil and HP_SlotRegistry:indexToSlot(index) or tostring(index or "") + setXMLString(xmlFile, key .. "#id", tostring(row.id)) + setXMLString(xmlFile, key .. "#slot", tostring(slot or "")) + writeXmlNumber(xmlFile, key .. "#x", row.placement.x) + writeXmlNumber(xmlFile, key .. "#y", row.placement.y) + writeXmlNumber(xmlFile, key .. "#z", row.placement.z) + writeXmlNumber(xmlFile, key .. "#yaw", row.placement.yaw or 0) + end + + saveXMLFile(xmlFile) + delete(xmlFile) + return true +end + +function HP_WorldState:loadMap() + self.initialized = false + self:init() +end + +function HP_WorldState:deleteMap() + self.initialized = false + self.savegameName = nil + self.savegameDir = nil + self.stateFile = nil + self.placementsByCanonicalId = {} +end + +addModEventListener(HP_WorldState) diff --git a/scripts/HP_WorldWorkerManager.lua b/scripts/HP_WorldWorkerManager.lua new file mode 100644 index 0000000..947ca32 --- /dev/null +++ b/scripts/HP_WorldWorkerManager.lua @@ -0,0 +1,494 @@ +-- HP_WorldWorkerManager.lua (FS25_HelperProfiles) +-- Alpha 1: standalone static world workers, independent of AI worker jobs. + +if HP_WorldWorkerManager ~= nil then return end + +HP_WorldWorkerManager = { + instancesByCanonicalId = {}, + startupDelayMs = 1800, + retryMs = 1800, + initialized = false, + commandsRegistered = false, + placeDistance = 2.0, + version = "2.2.0.0-alpha1" +} + +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/WorldWorkers] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function getTargetCount() + return HP_SlotRegistry ~= nil and HP_SlotRegistry.TARGET_COUNT or 20 +end + +local function getHelperByIndex(index) + index = math.floor(tonumber(index) or 0) + if index < 1 or index > getTargetCount() or g_helperManager == nil then return nil end + if g_helperManager.getHelperByIndex ~= nil then + local ok, helper = pcall(g_helperManager.getHelperByIndex, g_helperManager, index) + if ok and helper ~= nil then return helper end + end + return g_helperManager.indexToHelper ~= nil and g_helperManager.indexToHelper[index] or nil +end + +local function getStableIndexForHelper(wanted) + if wanted == nil then return nil end + if HelperProfiles ~= nil and HelperProfiles.getStableIndexForHelper ~= nil then + local ok, index = pcall(HelperProfiles.getStableIndexForHelper, HelperProfiles, wanted) + if ok and tonumber(index) ~= nil then return math.floor(tonumber(index)) end + end + for index = 1, getTargetCount() do + if getHelperByIndex(index) == wanted then return index end + end + return nil +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) + if index ~= nil then return index end + end + local numeric = math.floor(tonumber(value) or 0) + if numeric >= 1 and numeric <= getTargetCount() then return numeric end + return nil + end + + if HelperProfiles ~= nil and HelperProfiles.getSelectedHelper ~= nil then + local ok, helper = pcall(HelperProfiles.getSelectedHelper, HelperProfiles) + if ok and helper ~= nil then return getStableIndexForHelper(helper) 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 getDisplayName(helper, index) + if HelperProfiles ~= nil and HelperProfiles.getDisplayNameForHelper ~= nil then + local ok, displayName = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, index) + if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName) end + end + return tostring(helper ~= nil and helper.name or ("Helper " .. tostring(getSlot(index)))) +end + +local function isHelperActive(helper) + if helper == nil then return false end + if HelperProfiles ~= nil and HelperProfiles.isHelperActive ~= nil then + local ok, active = pcall(HelperProfiles.isHelperActive, HelperProfiles, helper) + if ok then return active == true end + end + return helper.inUse == true +end + +local function clonePlayerStyle(sourceStyle) + if sourceStyle == nil then return nil, "missing-helper-style" end + if PlayerStyle == nil or PlayerStyle.new == nil then return nil, "playerstyle-class-unavailable" end + + if sourceStyle.loadConfigurationIfRequired ~= nil then + pcall(sourceStyle.loadConfigurationIfRequired, sourceStyle) + end + + local style = PlayerStyle.new() + if style.copyFrom ~= nil then + local ok, err = pcall(style.copyFrom, style, sourceStyle) + if ok then return style, nil end + return nil, "copy-style-failed: " .. tostring(err) + end + if style.copySelectionFrom ~= nil then + local ok, err = pcall(style.copySelectionFrom, style, sourceStyle) + if ok then return style, nil end + return nil, "copy-selection-failed: " .. tostring(err) + end + return nil, "style-copy-unavailable" +end + +local function createStyleForHelper(helper, index) + if HP_ASBridge ~= nil and HP_ASBridge.createPlayerStyleForHelper ~= nil then + local ok, style, err, preset = pcall(HP_ASBridge.createPlayerStyleForHelper, HP_ASBridge, helper, index) + if ok and style ~= nil then + return style, "avatarSwitcher", preset + end + if not ok then + log("AvatarSwitcher style build failed for slot %s: %s", tostring(getSlot(index)), tostring(style)) + elseif err ~= nil and err ~= "no-preset" and err ~= "no-presets-in-category" and err ~= "avatar-switcher-unavailable" then + log("AvatarSwitcher style unavailable for slot %s: %s", tostring(getSlot(index)), tostring(err)) + end + end + + local style, err = clonePlayerStyle(helper ~= nil and helper.playerStyle or nil) + return style, "helperStyle", err +end + +local function applyIdleNpcAnimation(graphics) + if graphics == nil or graphics.animationParameters == nil then return end + + local parameters = graphics.animationParameters + local values = { + absSpeed = 0, + relativeVelocityX = 0, + relativeVelocityY = 0, + relativeVelocityZ = 0, + rotationVelocity = 0, + movementDirX = 0, + movementDirZ = 0, + distanceToGround = 0, + isCloseToGround = true, + isIdling = true, + isWalking = false, + isRunning = false, + isCrouching = false, + isGrounded = true, + isInWater = false, + isSwimming = false, + isStrafeWalkMode = false, + isFirstPerson = false, + isCutting = false, + isVerticalCut = false, + isHoldingChainsaw = false, + isNPC = true + } + + for name, value in pairs(values) do + local parameter = parameters[name] + if parameter ~= nil then + if type(parameter) == "table" and parameter.setValue ~= nil then + pcall(parameter.setValue, parameter, value) + elseif graphics.animation ~= nil and graphics.animation.setParameter ~= nil then + pcall(graphics.animation.setParameter, graphics.animation, parameter, value) + end + end + end +end + +local function getLocalPlayer() + if rawget(_G, "g_localPlayer") ~= nil then return g_localPlayer end + if g_currentMission ~= nil then + return g_currentMission.player or g_currentMission.controlledPlayer + end + return nil +end + +function Manager:getPlacementInFrontOfPlayer() + local player = getLocalPlayer() + if player == nil or player.getPosition == nil then return nil, "local-player-unavailable" end + + local okPos, x, y, z = pcall(player.getPosition, player) + if not okPos or x == nil or z == nil then return nil, "player-position-unavailable" end + + local yaw = 0 + if player.getMapPositionAndLookYaw ~= nil then + local okLook, _, _, lookYaw = pcall(player.getMapPositionAndLookYaw, player) + if okLook and tonumber(lookYaw) ~= nil then yaw = tonumber(lookYaw) end + elseif player.getMovementYaw ~= nil then + local okYaw, value = pcall(player.getMovementYaw, player) + if okYaw and tonumber(value) ~= nil then yaw = tonumber(value) end + end + + local dirX, dirZ + if MathUtil ~= nil and MathUtil.getDirectionFromYRotation ~= nil then + dirX, dirZ = MathUtil.getDirectionFromYRotation(yaw) + else + dirX, dirZ = math.sin(yaw), math.cos(yaw) + end + + local distance = tonumber(self.placeDistance) or 2.0 + x = x + (tonumber(dirX) or 0) * distance + z = z + (tonumber(dirZ) or 1) * distance + + local terrainNode = rawget(_G, "g_terrainNode") + if terrainNode ~= nil and terrainNode ~= 0 and getTerrainHeightAtWorldPos ~= nil then + local okTerrain, terrainY = pcall(getTerrainHeightAtWorldPos, terrainNode, x, 0, z) + if okTerrain and tonumber(terrainY) ~= nil then y = tonumber(terrainY) end + end + + -- The worker is placed in front of the player and faces back towards them. + return { x = x, y = y, z = z, yaw = yaw + math.pi }, nil +end + +function Manager:destroyInstance(indexOrId, reason) + local canonicalId = indexOrId + if type(indexOrId) ~= "string" or not tostring(indexOrId):match("^helper%d+$") then + local index = resolveIndex(indexOrId) + canonicalId = index ~= nil and getCanonicalId(index) or nil + end + if canonicalId == nil then return false end + + local instance = self.instancesByCanonicalId[canonicalId] + if instance == nil then return false end + self.instancesByCanonicalId[canonicalId] = nil + + if instance.graphics ~= nil and instance.graphics.delete ~= nil then + pcall(instance.graphics.delete, instance.graphics) + end + log("Despawned world worker %s (%s)", tostring(instance.displayName or canonicalId), tostring(reason or "requested")) + return true +end + +function Manager:spawnFromPlacement(index, placement, reason) + index = resolveIndex(index) + if index == nil or placement == nil then return false, "invalid-placement" end + + local helper = getHelperByIndex(index) + if helper == nil then return false, "helper-not-ready" end + if isHelperActive(helper) then return false, "helper-active" end + if HumanGraphicsComponent == nil or HumanGraphicsComponent.new == nil then + return false, "human-graphics-unavailable" + end + + local canonicalId = getCanonicalId(index) + self:destroyInstance(canonicalId, "replace") + + local style, styleSource, styleDetail = createStyleForHelper(helper, index) + if style == nil then return false, styleDetail or "style-unavailable" end + + local graphics = HumanGraphicsComponent.new() + if graphics == nil then return false, "graphics-create-failed" end + + local okInit, initErr = pcall(graphics.initialize, graphics) + if not okInit or graphics.graphicsRootNode == nil then + pcall(graphics.delete, graphics) + return false, "graphics-initialize-failed: " .. tostring(initErr) + end + + setTranslation(graphics.graphicsRootNode, tonumber(placement.x) or 0, tonumber(placement.y) or 0, tonumber(placement.z) or 0) + setRotation(graphics.graphicsRootNode, 0, tonumber(placement.yaw) or 0, 0) + graphics.soundsEnabled = false + + local instance = { + id = canonicalId, + index = index, + helper = helper, + displayName = getDisplayName(helper, index), + graphics = graphics, + styleSource = styleSource, + loading = true + } + self.instancesByCanonicalId[canonicalId] = instance + + local function onStyleLoaded(manager, loadingState, loadedNewPlayerModel, callbackArgs) + local live = manager.instancesByCanonicalId[callbackArgs.id] + if live == nil or live.graphics ~= callbackArgs.graphics then return end + live.loading = false + live.loadingState = loadingState + applyIdleNpcAnimation(live.graphics) + if live.graphics.show ~= nil then pcall(live.graphics.show, live.graphics) end + log("Spawned world worker %s [slot=%s id=%s style=%s loadState=%s reason=%s]", + tostring(live.displayName), tostring(getSlot(live.index)), tostring(live.id), + tostring(live.styleSource), tostring(loadingState), tostring(callbackArgs.reason)) + end + + local okStyle, styleErr = pcall( + graphics.setStyleAsync, + graphics, + style, + onStyleLoaded, + self, + { id = canonicalId, graphics = graphics, reason = reason or "state" }, + false, + nil, + false + ) + if not okStyle then + self.instancesByCanonicalId[canonicalId] = nil + pcall(graphics.delete, graphics) + return false, "set-style-failed: " .. tostring(styleErr) + end + + applyIdleNpcAnimation(graphics) + return true, nil +end + +function Manager:placeAtPlayer(indexOrSlot) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + local helper = getHelperByIndex(index) + if helper == nil then return false, "helper-not-ready" end + if isHelperActive(helper) then return false, "helper-active" end + + local placement, err = self:getPlacementInFrontOfPlayer() + if placement == nil then return false, err end + + local wrote, writeErr = HP_WorldState:setPlacement(index, placement.x, placement.y, placement.z, placement.yaw) + if not wrote then return false, writeErr or "state-write-failed" end + return self:spawnFromPlacement(index, placement, "place-at-player") +end + +function Manager:removePlacement(indexOrSlot) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + self:destroyInstance(index, "placement-removed") + return HP_WorldState:clearPlacement(index) +end + +function Manager:refreshPlacement(indexOrSlot) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + local placement = HP_WorldState:getPlacement(index) + if placement == nil then return false, "not-placed" end + return self:spawnFromPlacement(index, placement, "refresh") +end + +function Manager:syncPersistentPlacements() + if HP_WorldState == nil then return end + local placements = HP_WorldState:getAllPlacements() + for canonicalId, placement in pairs(placements) do + local index = resolveIndex(canonicalId) + if index ~= nil then + local helper = getHelperByIndex(index) + local instance = self.instancesByCanonicalId[canonicalId] + if helper ~= nil and isHelperActive(helper) then + if instance ~= nil then self:destroyInstance(canonicalId, "AI-helper-active") end + elseif helper ~= nil and instance == nil then + local ok, err = self:spawnFromPlacement(index, placement, "persistent-state") + if not ok and err ~= "helper-not-ready" and err ~= "helper-active" then + log("Could not restore slot %s: %s", tostring(getSlot(index)), tostring(err)) + end + end + end + end + + -- Remove runtime instances whose persisted placement was deleted. + local stale = {} + for canonicalId in pairs(self.instancesByCanonicalId) do + if placements[canonicalId] == nil then stale[#stale + 1] = canonicalId end + end + for _, canonicalId in ipairs(stale) do self:destroyInstance(canonicalId, "state-cleared") end +end + +function Manager:getStatusRows() + local rows = {} + local placements = HP_WorldState ~= nil and HP_WorldState:getAllPlacements() or {} + for index = 1, getTargetCount() do + local id = getCanonicalId(index) + local placement = placements[id] + if placement ~= nil then + local helper = getHelperByIndex(index) + local instance = self.instancesByCanonicalId[id] + rows[#rows + 1] = { + index = index, + slot = getSlot(index), + id = id, + name = getDisplayName(helper, index), + placed = true, + spawned = instance ~= nil, + active = helper ~= nil and isHelperActive(helper) or false, + x = placement.x, + y = placement.y, + z = placement.z, + yaw = placement.yaw + } + end + end + return rows +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] +end + +function Manager:consoleCommandWorld(...) + local sub, slot = normalizeCommandArgs(...) + sub = string.lower(tostring(sub or "status")) + + if sub == "help" then + print("[HP] hpWorld status | place [slot] | move [slot] | remove [slot] | refresh [slot]") + print("[HP] With no slot, place/move/remove/refresh use the currently selected HelperProfiles worker.") + return + end + + if sub == "status" or sub == "list" then + local rows = self:getStatusRows() + print(string.format("[HP] World workers: placed=%d", #rows)) + for _, row in ipairs(rows) do + print(string.format("[HP] %s %-18s id=%s spawned=%s aiActive=%s pos=(%.2f, %.2f, %.2f) yaw=%.3f", + tostring(row.slot), tostring(row.name), tostring(row.id), tostring(row.spawned), tostring(row.active), + tonumber(row.x) or 0, tonumber(row.y) or 0, tonumber(row.z) or 0, tonumber(row.yaw) or 0)) + end + return + end + + local ok, err + if sub == "place" or sub == "move" then + ok, err = self:placeAtPlayer(slot) + elseif sub == "remove" or sub == "clear" then + ok, err = self:removePlacement(slot) + elseif sub == "refresh" then + ok, err = self:refreshPlacement(slot) + else + print("[HP] Unknown hpWorld subcommand '" .. tostring(sub) .. "' (try: hpWorld help)") + return + end + + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld %s %s -> %s%s", tostring(sub), tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) +end + +function Manager:registerConsoleCommand() + if self.commandsRegistered then return end + local ok = false + if g_console ~= nil and g_console.addCommand ~= nil then + g_console:addCommand("hpWorld", "Manage standalone HelperProfiles world workers", "consoleCommandWorld", self) + ok = true + elseif addConsoleCommand ~= nil then + addConsoleCommand("hpWorld", "Manage standalone HelperProfiles world workers", "consoleCommandWorld", self) + ok = true + end + _G.hpWorld = function(...) return Manager:consoleCommandWorld(...) end + self.commandsRegistered = ok +end + +function Manager:loadMap() + self.instancesByCanonicalId = {} + self.initialized = true + self.retryMs = tonumber(self.startupDelayMs) or 1800 + self:registerConsoleCommand() + log("Alpha 1 world-worker manager loaded; waiting for helper roster before restoring placements") +end + +function Manager:update(dt) + if not self.initialized or HP_WorldState == nil then return end + + self.retryMs = (tonumber(self.retryMs) or 0) - (tonumber(dt) or 0) + if self.retryMs <= 0 then + self.retryMs = 500 + self:syncPersistentPlacements() + end + + for _, instance in pairs(self.instancesByCanonicalId) do + if instance.graphics ~= nil and instance.loading ~= true then + applyIdleNpcAnimation(instance.graphics) + if instance.graphics.update ~= nil then pcall(instance.graphics.update, instance.graphics, dt) end + end + end +end + +function Manager:deleteMap() + local ids = {} + for canonicalId in pairs(self.instancesByCanonicalId) do ids[#ids + 1] = canonicalId end + for _, canonicalId in ipairs(ids) do self:destroyInstance(canonicalId, "map-delete") end + self.instancesByCanonicalId = {} + self.initialized = false + self.commandsRegistered = false + _G.hpWorld = nil +end + +addModEventListener(HP_WorldWorkerManager) diff --git a/scripts/HP_WorldWorkerPlayerAccess.lua b/scripts/HP_WorldWorkerPlayerAccess.lua new file mode 100644 index 0000000..6196755 --- /dev/null +++ b/scripts/HP_WorldWorkerPlayerAccess.lua @@ -0,0 +1,150 @@ +-- HP_WorldWorkerPlayerAccess.lua (FS25_HelperProfiles) +-- Alpha 1 hotfix: resolve the local FS25 Player through PlayerSystem and use +-- the supported Player map-position/camera-yaw API for world-worker placement. + +if HP_WorldWorkerManager == nil then return end + +HP_WorldWorkerPlayerAccess = HP_WorldWorkerPlayerAccess or { + version = "2.2.0.0-alpha1-player-access-2" +} + +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/WorldWorkers] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function findLocalPlayer() + local mission = g_currentMission + if mission == nil then return nil, "mission-unavailable" end + + -- Retain compatibility with environments/mods that expose either shortcut. + if rawget(_G, "g_localPlayer") ~= nil and g_localPlayer ~= nil then + return g_localPlayer, "g_localPlayer" + end + if mission.player ~= nil then + return mission.player, "mission.player" + end + + local playerSystem = mission.playerSystem + if playerSystem == nil then return nil, "player-system-unavailable" end + + -- FS25 PlayerSystem marks the locally-owned Player with isOwner when it is + -- added. Prefer that explicit identity over assuming player index 1. + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil and (player.isOwner == true or player.isLocallyControlled == true) then + return player, player.isOwner == true and "playerSystem.owner" or "playerSystem.local" + end + end + + -- Single-player fallback. This is intentionally last because index order is + -- less semantically strong than the owner/local flags above. + if playerSystem.getPlayerByIndex ~= nil then + local ok, player = pcall(playerSystem.getPlayerByIndex, playerSystem, 1) + if ok and player ~= nil then return player, "playerSystem.index1" end + end + + -- Final single-player fallback: use the first concrete Player object in the + -- PlayerSystem table even if owner/local flags are unavailable at runtime. + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil then return player, "playerSystem.first" end + end + + return nil, "local-player-not-found" +end + +local function getPlayerPositionAndYaw(player) + local x, y, z, yaw = nil, nil, nil, nil + + -- Supported FS25 Player API: current map X/Z plus current camera look yaw. + if player.getMapPositionAndLookYaw ~= nil then + local ok, px, pz, cameraYaw = pcall(player.getMapPositionAndLookYaw, player) + if ok and tonumber(px) ~= nil and tonumber(pz) ~= nil then + x, z = tonumber(px), tonumber(pz) + yaw = tonumber(cameraYaw) + end + end + + -- Recover full XYZ from the Player/Object position API when available. + if player.getPosition ~= nil then + local ok, px, py, pz = pcall(player.getPosition, player) + if ok then + if x == nil and tonumber(px) ~= nil then x = tonumber(px) end + if tonumber(py) ~= nil then y = tonumber(py) end + if z == nil and tonumber(pz) ~= nil then z = tonumber(pz) end + end + end + + -- Defensive fallbacks for live Player implementations/mod interactions. + if (x == nil or y == nil or z == nil) and player.capsuleController ~= nil + and player.capsuleController.getPosition ~= nil then + local ok, px, py, pz = pcall(player.capsuleController.getPosition, player.capsuleController) + if ok then + if x == nil and tonumber(px) ~= nil then x = tonumber(px) end + if y == nil and tonumber(py) ~= nil then y = tonumber(py) end + if z == nil and tonumber(pz) ~= nil then z = tonumber(pz) end + end + end + + if (x == nil or y == nil or z == nil) and player.rootNode ~= nil and player.rootNode ~= 0 + and getWorldTranslation ~= nil then + local ok, px, py, pz = pcall(getWorldTranslation, player.rootNode) + if ok then + if x == nil and tonumber(px) ~= nil then x = tonumber(px) end + if y == nil and tonumber(py) ~= nil then y = tonumber(py) end + if z == nil and tonumber(pz) ~= nil then z = tonumber(pz) end + end + end + + if yaw == nil and player.getMovementYaw ~= nil then + local ok, value = pcall(player.getMovementYaw, player) + if ok and tonumber(value) ~= nil then yaw = tonumber(value) end + end + + if x == nil or z == nil then return nil, "player-position-unavailable" end + y = tonumber(y) or 0 + yaw = tonumber(yaw) or 0 + return {x=x, y=y, z=z, yaw=yaw}, nil +end + +function Manager:getPlacementInFrontOfPlayer() + local player, playerSource = findLocalPlayer() + if player == nil then + log("Player access failed: %s", tostring(playerSource)) + return nil, "local-player-unavailable:" .. tostring(playerSource) + end + + local current, positionErr = getPlayerPositionAndYaw(player) + if current == nil then + log("Player position failed via %s: %s", tostring(playerSource), tostring(positionErr)) + return nil, positionErr or "player-position-unavailable" + end + + local dirX, dirZ + if MathUtil ~= nil and MathUtil.getDirectionFromYRotation ~= nil then + dirX, dirZ = MathUtil.getDirectionFromYRotation(current.yaw) + else + dirX, dirZ = math.sin(current.yaw), math.cos(current.yaw) + end + + local distance = tonumber(self.placeDistance) or 2.0 + local x = current.x + (tonumber(dirX) or 0) * distance + local z = current.z + (tonumber(dirZ) or 1) * distance + local y = current.y + + local terrainNode = rawget(_G, "g_terrainNode") + if terrainNode ~= nil and terrainNode ~= 0 and getTerrainHeightAtWorldPos ~= nil then + local okTerrain, terrainY = pcall(getTerrainHeightAtWorldPos, terrainNode, x, 0, z) + if okTerrain and tonumber(terrainY) ~= nil then y = tonumber(terrainY) end + end + + log("Placement source=%s player=(%.2f, %.2f, %.2f) target=(%.2f, %.2f, %.2f) yaw=%.3f", + tostring(playerSource), current.x, current.y, current.z, x, y, z, current.yaw) + + -- Place the worker in front of the camera direction and face them back + -- toward the player. + return {x=x, y=y, z=z, yaw=current.yaw + math.pi}, nil +end + +log("PlayerSystem access override installed (%s)", tostring(HP_WorldWorkerPlayerAccess.version))