Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/WORLD_WORKERS_ALPHA1.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions scripts/HP_SlotRegistry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
262 changes: 262 additions & 0 deletions scripts/HP_WorldState.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading