diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..194e419
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.claude/settings.local.json
+.vscode/lua-format.yaml
+build.*
+CLAUDE.md
+feature.md
+roadmap.md
+skillsEmployee.md
diff --git a/docs b/docs
new file mode 160000
index 0000000..993763e
--- /dev/null
+++ b/docs
@@ -0,0 +1 @@
+Subproject commit 993763ec73ecf45deda9e8e3c3cdcca529d32759
diff --git a/l10n/l10n_de.xml b/l10n/l10n_de.xml
index bb6e1a9..fdbc63c 100644
--- a/l10n/l10n_de.xml
+++ b/l10n/l10n_de.xml
@@ -71,11 +71,17 @@
+
+
+
+
+
+
@@ -152,6 +158,9 @@
+
+
+
diff --git a/l10n/l10n_en.xml b/l10n/l10n_en.xml
index 5b926b9..5d450e9 100644
--- a/l10n/l10n_en.xml
+++ b/l10n/l10n_en.xml
@@ -71,11 +71,17 @@
+
+
+
+
+
+
@@ -90,6 +96,7 @@
+
@@ -152,6 +159,9 @@
+
+
+
diff --git a/l10n/l10n_es.xml b/l10n/l10n_es.xml
index 439ee7e..374df01 100644
--- a/l10n/l10n_es.xml
+++ b/l10n/l10n_es.xml
@@ -71,11 +71,17 @@
+
+
+
+
+
+
@@ -152,6 +158,9 @@
+
+
+
diff --git a/l10n/l10n_fr.xml b/l10n/l10n_fr.xml
index c05b378..5cd94ef 100644
--- a/l10n/l10n_fr.xml
+++ b/l10n/l10n_fr.xml
@@ -71,11 +71,17 @@
+
+
+
+
+
+
@@ -90,6 +96,7 @@
+
@@ -152,6 +159,9 @@
+
+
+
diff --git a/l10n/l10n_it.xml b/l10n/l10n_it.xml
index 9f2b086..ec2b5f3 100644
--- a/l10n/l10n_it.xml
+++ b/l10n/l10n_it.xml
@@ -74,11 +74,17 @@
+
+
+
+
+
+
@@ -155,6 +161,9 @@
+
+
+
diff --git a/l10n/l10n_pl.xml b/l10n/l10n_pl.xml
index f124b69..f7afcf2 100644
--- a/l10n/l10n_pl.xml
+++ b/l10n/l10n_pl.xml
@@ -71,11 +71,17 @@
+
+
+
+
+
+
@@ -152,6 +158,9 @@
+
+
+
diff --git a/modDesc.xml b/modDesc.xml
index 1bf0d8c..6fb17ee 100644
--- a/modDesc.xml
+++ b/modDesc.xml
@@ -1,7 +1,7 @@
LeGrizzly
- 0.0.6-build-4
+ 0.0.8-build-1
@@ -19,7 +19,7 @@
- FS25_DBAPI
+ FS25_SILODBimages/icon_employee.dds
diff --git a/scripts/ModGui.lua b/scripts/ModGui.lua
index 6310248..983ceb4 100644
--- a/scripts/ModGui.lua
+++ b/scripts/ModGui.lua
@@ -155,9 +155,21 @@ function ModGui:loadMenuFrame(class)
g_gui:loadGui(class.XML_FILENAME, class.CLASS_NAME, pageController, true)
- local iconPath = 'images/MenuIcon.dds'
+ local iconSliceToFile = {
+ EM_IconMenu = "images/MenuIcon.dds",
+ EM_IconEmployee = "images/EMEmployeeIcon.dds",
+ EM_IconWorkflow = "images/EMWorkflowIcon.dds",
+ EM_IconField = "images/EMFieldIcon.dds",
+ EM_IconVehicle = "images/EMVehicleIcon.dds",
+ }
+
+ local iconPath = "images/MenuIcon.dds"
local uvs = {0, 0, 1024, 1024}
+ if class.MENU_ICON_SLICE_ID ~= nil and iconSliceToFile[class.MENU_ICON_SLICE_ID] ~= nil then
+ iconPath = iconSliceToFile[class.MENU_ICON_SLICE_ID]
+ end
+
local position = "pageSettings"
local predicate = function() return true end
addIngameMenuPage(pageController, pageName, iconPath, uvs, position, predicate)
diff --git a/scripts/extensions/AIOverrideExtension.lua b/scripts/extensions/AIOverrideExtension.lua
index 82f9ab7..59df08f 100644
--- a/scripts/extensions/AIOverrideExtension.lua
+++ b/scripts/extensions/AIOverrideExtension.lua
@@ -38,16 +38,16 @@ function AIOverrideExtension.onToggleAI(vehicle, actionName, inputValue, callbac
if vehicle == nil then return end
local employee = g_employeeManager:getEmployeeByVehicle(vehicle)
-
+
if employee ~= nil then
if employee.currentJob ~= nil or vehicle:getIsAIActive() then
g_employeeManager:consoleStopJob(employee.id)
- g_currentMission:showBlinkingWarning(string.format("Employee %s stopped.", employee.name), 2000)
+ g_currentMission:showBlinkingWarning(string.format(g_i18n:getText("em_employee_stopped") or "Employee %s stopped.", employee.name), 2000)
else
g_gui:showGui("MenuEmployeeManager")
end
else
+ -- No employee assigned: open EM menu (vanilla AI is disabled)
g_gui:showGui("MenuEmployeeManager")
- g_currentMission:showBlinkingWarning("No employee assigned to this vehicle!", 2000)
end
end
diff --git a/scripts/gui/EMEmployeeFrame.lua b/scripts/gui/EMEmployeeFrame.lua
index a7cf30a..eac1156 100644
--- a/scripts/gui/EMEmployeeFrame.lua
+++ b/scripts/gui/EMEmployeeFrame.lua
@@ -7,6 +7,8 @@ EMEmployeeFrame.LIST_TYPE = {
HIRED = 2,
}
+EMEmployeeFrame.MENU_ICON_SLICE_ID = 'EM_IconEmployee'
+
function EMEmployeeFrame:new()
local self = TabbedMenuFrameElement.new(nil, EMEmployeeFrame_mt)
self.employees = {}
@@ -120,8 +122,15 @@ function EMEmployeeFrame:populateCellForItemInSection(list, section, index, cell
elseif emp.isOnBreak then
statusText = g_i18n:getText("em_status_on_break")
elseif emp.currentJob then
- if emp.currentJob.type == "RETURN_TO_PARKING" then
+ local jobType = emp.currentJob.type
+ if jobType == "RETURN_TO_PARKING" then
statusText = g_i18n:getText("em_status_returning")
+ elseif jobType == "DRIVING_TO_TOOL" then
+ statusText = g_i18n:getText("em_status_driving_to_tool")
+ elseif jobType == "APPROACHING_TOOL" or jobType == "ATTACHING_TOOL" then
+ statusText = g_i18n:getText("em_status_attaching_tool")
+ elseif jobType == "RETURNING_TOOL" then
+ statusText = g_i18n:getText("em_status_returning_tool")
else
statusText = emp.currentJob.workType or "Working"
end
@@ -373,8 +382,15 @@ function EMEmployeeFrame:displayWorkStats(employee)
if self.statCurrentJob then
if employee.currentJob then
local jobType = employee.currentJob.workType or employee.currentJob.type or "Unknown"
- if employee.currentJob.type == "RETURN_TO_PARKING" then
+ local jt = employee.currentJob.type
+ if jt == "RETURN_TO_PARKING" then
jobType = g_i18n:getText("em_status_returning")
+ elseif jt == "DRIVING_TO_TOOL" then
+ jobType = g_i18n:getText("em_status_driving_to_tool")
+ elseif jt == "APPROACHING_TOOL" or jt == "ATTACHING_TOOL" then
+ jobType = g_i18n:getText("em_status_attaching_tool")
+ elseif jt == "RETURNING_TOOL" then
+ jobType = g_i18n:getText("em_status_returning_tool")
end
local fieldId = employee.currentJob.fieldId
if fieldId then
diff --git a/scripts/gui/EMFieldFrame.lua b/scripts/gui/EMFieldFrame.lua
index 28ec62f..bb7e436 100644
--- a/scripts/gui/EMFieldFrame.lua
+++ b/scripts/gui/EMFieldFrame.lua
@@ -4,11 +4,14 @@ local EMFieldFrame_mt = Class(EMFieldFrame, TabbedMenuFrameElement)
function EMFieldFrame:new()
local self = TabbedMenuFrameElement.new(nil, EMFieldFrame_mt)
- self.fields = {}
- self.menuButtonInfo = {}
+ self.fields = {}
+ self.menuButtonInfo = {}
+ self.pendingTargetCrop = nil
return self
end
+EMFieldFrame.MENU_ICON_SLICE_ID = 'EM_IconField'
+
function EMFieldFrame:copyAttributes(src)
EMFieldFrame:superClass().copyAttributes(self, src)
end
@@ -16,6 +19,18 @@ end
function EMFieldFrame:initialize()
self.backButtonInfo = { inputAction = InputAction.MENU_BACK }
self.menuButtonInfo = { self.backButtonInfo }
+
+ self.cropNames = {}
+ if g_employeeManager and g_employeeManager.cropManager then
+ for name, _ in pairs(g_employeeManager.cropManager.crops) do
+ table.insert(self.cropNames, name)
+ end
+ table.sort(self.cropNames)
+ end
+
+ if self.targetCropSelector then
+ self.targetCropSelector:setTexts(self.cropNames)
+ end
end
function EMFieldFrame:onGuiSetupFinished()
@@ -161,6 +176,22 @@ function EMFieldFrame:displayFieldDetails(index)
self.txtGrowthState:setText(growthText)
end
+ if self.targetCropSelector then
+ local targetCrop = g_employeeManager:getFieldTargetCrop(fieldData.fieldId)
+ local state = 1
+ if targetCrop then
+ for i, name in ipairs(self.cropNames) do
+ if name == targetCrop then
+ state = i
+ break
+ end
+ end
+ end
+ self.targetCropSelector:setState(state, false)
+ end
+
+ self.pendingTargetCrop = nil
+
local conditionText = self:getFieldCondition(fieldData.fieldRef)
if self.txtFieldCondition then
self.txtFieldCondition:setText(conditionText)
@@ -263,8 +294,15 @@ function EMFieldFrame:displayAssignedEmployee(emp)
statusText = g_i18n:getText("em_status_on_break")
self.txtEmpStatus:setTextColor(1, 1, 0, 1)
elseif emp.currentJob then
- if emp.currentJob.type == "RETURN_TO_PARKING" then
+ local jobType = emp.currentJob.type
+ if jobType == "RETURN_TO_PARKING" then
statusText = g_i18n:getText("em_status_returning")
+ elseif jobType == "DRIVING_TO_TOOL" then
+ statusText = g_i18n:getText("em_status_driving_to_tool")
+ elseif jobType == "APPROACHING_TOOL" or jobType == "ATTACHING_TOOL" then
+ statusText = g_i18n:getText("em_status_attaching_tool")
+ elseif jobType == "RETURNING_TOOL" then
+ statusText = g_i18n:getText("em_status_returning_tool")
else
statusText = emp.currentJob.workType or g_i18n:getText("em_status_working")
end
@@ -337,8 +375,15 @@ function EMFieldFrame:displayAssignedEmployee(emp)
if self.txtEmpCurrentTask then
if emp.currentJob then
local jobType = emp.currentJob.workType or emp.currentJob.type or "Unknown"
- if emp.currentJob.type == "RETURN_TO_PARKING" then
+ local jt = emp.currentJob.type
+ if jt == "RETURN_TO_PARKING" then
jobType = g_i18n:getText("em_status_returning")
+ elseif jt == "DRIVING_TO_TOOL" then
+ jobType = g_i18n:getText("em_status_driving_to_tool")
+ elseif jt == "APPROACHING_TOOL" or jt == "ATTACHING_TOOL" then
+ jobType = g_i18n:getText("em_status_attaching_tool")
+ elseif jt == "RETURNING_TOOL" then
+ jobType = g_i18n:getText("em_status_returning_tool")
end
local fieldId = emp.currentJob.fieldId
if fieldId then
@@ -406,3 +451,20 @@ end
function EMFieldFrame:getMenuButtonInfo()
return self.menuButtonInfo
end
+
+function EMFieldFrame:onTargetCropChanged(state)
+ if self.cropNames[state] then
+ self.pendingTargetCrop = self.cropNames[state]
+ end
+end
+
+function EMFieldFrame:onSaveFieldConfig()
+ local index = self.fieldList:getSelectedIndex()
+ local fieldData = self.fields[index]
+ if fieldData == nil then return end
+
+ if self.pendingTargetCrop then
+ g_employeeManager:setFieldTargetCrop(fieldData.fieldId, self.pendingTargetCrop)
+ self.pendingTargetCrop = nil
+ end
+end
diff --git a/scripts/gui/EMGui.lua b/scripts/gui/EMGui.lua
index c8e4c64..9983448 100644
--- a/scripts/gui/EMGui.lua
+++ b/scripts/gui/EMGui.lua
@@ -28,16 +28,31 @@ end
function EMGui:setupPages(gui)
local pages = {
- { gui.pageEmployees, "ingameMenu/tab_character" },
- { gui.pageWorkflows, "ingameMenu/tab_contracts" },
- { gui.pageFields, "ingameMenu/tab_map" },
- { gui.pageVehicles, "ingameMenu/tab_vehicles" },
+ gui.pageEmployees,
+ gui.pageWorkflows,
+ gui.pageFields,
+ gui.pageVehicles,
}
- for idx, thisPage in ipairs(pages) do
- local page, sliceId = unpack(thisPage)
+ for idx, page in ipairs(pages) do
+ local iconSliceToFile = {
+ EM_IconMenu = "images/MenuIcon.dds",
+ EM_IconEmployee = "images/EMEmployeeIcon.dds",
+ EM_IconWorkflow = "images/EMWorkflowIcon.dds",
+ EM_IconField = "images/EMFieldIcon.dds",
+ EM_IconVehicle = "images/EMVehicleIcon.dds",
+ }
+
+ local iconPath = "images/MenuIcon.dds"
+ local uvs = {0, 0, 1024, 1024}
+
+ if page.MENU_ICON_SLICE_ID ~= nil and iconSliceToFile[page.MENU_ICON_SLICE_ID] ~= nil then
+ iconPath = iconSliceToFile[page.MENU_ICON_SLICE_ID]
+ end
+
+ local fullPath = g_modDirectory .. iconPath
gui:registerPage(page, idx)
- gui:addPageTab(page, nil, nil, sliceId)
+ gui:addPageTab(page, fullPath, GuiUtils.getUVs(uvs))
end
gui:rebuildTabList()
diff --git a/scripts/gui/EMVehicleFrame.lua b/scripts/gui/EMVehicleFrame.lua
index 22eb463..dfc189a 100644
--- a/scripts/gui/EMVehicleFrame.lua
+++ b/scripts/gui/EMVehicleFrame.lua
@@ -9,6 +9,8 @@ function EMVehicleFrame:new()
return self
end
+EMVehicleFrame.MENU_ICON_SLICE_ID = 'EM_IconVehicle'
+
function EMVehicleFrame:copyAttributes(src)
EMVehicleFrame:superClass().copyAttributes(self, src)
end
diff --git a/scripts/gui/EMWorkflowFrame.lua b/scripts/gui/EMWorkflowFrame.lua
index 28e2988..8b847c1 100644
--- a/scripts/gui/EMWorkflowFrame.lua
+++ b/scripts/gui/EMWorkflowFrame.lua
@@ -20,6 +20,8 @@ EMWorkflowFrame.TASK_REQUIREMENTS = {
MULCH_LEAVES = { skill = "driving", level = 1 },
}
+EMWorkflowFrame.MENU_ICON_SLICE_ID = 'EM_IconWorkflow'
+
function EMWorkflowFrame:new()
local self = TabbedMenuFrameElement.new(nil, EMWorkflowFrame_mt)
@@ -52,6 +54,12 @@ function EMWorkflowFrame:initialize()
text = g_i18n:getText("em_btn_save_start"),
callback = function() self:onSaveAndStart() end,
}
+ self.stopButtonInfo = {
+ profile = "buttonActivate",
+ inputAction = InputAction.MENU_EXTRA_2,
+ text = g_i18n:getText("em_btn_stop"),
+ callback = function() self:onStop() end,
+ }
local hourTexts = {}
for h = 0, 23 do
@@ -485,14 +493,26 @@ function EMWorkflowFrame:onSave()
local employee = self:getSelectedEmployee()
if not employee then return end
+ local hasFullConfig = employee.targetFieldId and employee.assignedVehicleId and #(employee.taskQueue or {}) > 0
+
+ if hasFullConfig then
+ employee.isAutonomous = true
+ employee.currentTaskIndex = employee.currentTaskIndex or 1
+ end
+
if self.txtStatusMessage then
- self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_saved"), employee.name))
+ if hasFullConfig then
+ self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_saved_hint"), employee.name))
+ else
+ self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_saved"), employee.name))
+ end
end
- CustomUtils:info("[EMWorkflowFrame] Saved workflow for %s: %d tasks, field=%s, vehicle=%s, shift=%d-%d",
+ CustomUtils:info("[EMWorkflowFrame] Saved workflow for %s: %d tasks, field=%s, vehicle=%s, shift=%d-%d, autonomous=%s",
employee.name, #(employee.taskQueue or {}),
tostring(employee.targetFieldId), tostring(employee.assignedVehicleId),
- employee.shiftStart or 6, employee.shiftEnd or 18
+ employee.shiftStart or 6, employee.shiftEnd or 18, tostring(employee.isAutonomous)
)
+ self:updateMenuButtons()
end
function EMWorkflowFrame:onSaveAndStart()
@@ -522,20 +542,54 @@ function EMWorkflowFrame:onSaveAndStart()
self:onSave()
local firstTask = queue[1]
+ employee.currentTaskIndex = 1
employee.isAutonomous = true
- if g_employeeManager.jobManager:startFieldWork(employee, employee.targetFieldId, firstTask) then
- if self.txtStatusMessage then
- self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_started"),
- employee.name, employee.targetFieldId, firstTask))
+ local currentHour = 0
+ if g_currentMission and g_currentMission.environment then
+ currentHour = g_currentMission.environment.currentHour or 0
+ end
+
+ if employee:isWithinShift(currentHour) then
+ if g_employeeManager.jobManager:startFieldWork(employee, employee.targetFieldId, firstTask) then
+ if self.txtStatusMessage then
+ self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_started"),
+ employee.name, employee.targetFieldId, firstTask))
+ end
+ else
+ if self.txtStatusMessage then
+ self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_start_failed"), employee.name))
+ end
end
else
if self.txtStatusMessage then
- self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_start_failed"), employee.name))
+ self.txtStatusMessage:setText(string.format(
+ g_i18n:getText("em_workflow_scheduled"), employee.name, employee.shiftStart or 6))
end
+ CustomUtils:info("[EMWorkflowFrame] %s scheduled: outside shift hours (%d:00, shift starts at %d:00)",
+ employee.name, currentHour, employee.shiftStart or 6)
end
end
+function EMWorkflowFrame:onStop()
+ local employee = self:getSelectedEmployee()
+ if not employee then return end
+
+ employee.isAutonomous = false
+
+ if employee.currentJob then
+ if g_employeeManager and g_employeeManager.jobManager then
+ g_employeeManager.jobManager:stopJob(employee)
+ end
+ end
+
+ if self.txtStatusMessage then
+ self.txtStatusMessage:setText(string.format(g_i18n:getText("em_workflow_stopped"), employee.name))
+ end
+ CustomUtils:info("[EMWorkflowFrame] Stopped autonomous mode for %s", employee.name)
+ self:updateMenuButtons()
+end
+
function EMWorkflowFrame:buildOwnedFieldsList()
local fields = {}
local farmId = g_currentMission:getFarmId()
@@ -618,12 +672,17 @@ function EMWorkflowFrame:buildOwnedVehiclesList()
end
function EMWorkflowFrame:updateMenuButtons()
- local hasSelection = self:getSelectedEmployee() ~= nil
+ local employee = self:getSelectedEmployee()
+ local hasSelection = employee ~= nil
self.menuButtonInfo = { self.backButtonInfo }
if hasSelection then
table.insert(self.menuButtonInfo, self.saveButtonInfo)
- table.insert(self.menuButtonInfo, self.saveStartButtonInfo)
+ if employee.isAutonomous then
+ table.insert(self.menuButtonInfo, self.stopButtonInfo)
+ else
+ table.insert(self.menuButtonInfo, self.saveStartButtonInfo)
+ end
end
self:setMenuButtonInfoDirty()
diff --git a/scripts/gui/MenuEmployeeManager.lua b/scripts/gui/MenuEmployeeManager.lua
index 2a4d954..f61719b 100644
--- a/scripts/gui/MenuEmployeeManager.lua
+++ b/scripts/gui/MenuEmployeeManager.lua
@@ -4,7 +4,7 @@ MenuEmployeeManager.CLASS_NAME = 'MenuEmployeeManager'
MenuEmployeeManager.MENU_PAGE_NAME = 'menuEmployeeManager'
MenuEmployeeManager.XML_FILENAME = g_modDirectory .. 'xml/gui/MenuEmployeeManager.xml'
-MenuEmployeeManager.MENU_ICON_SLICE_ID = 'MenuEmployeeManager.menuIcon'
+MenuEmployeeManager.MENU_ICON_SLICE_ID = 'EM_IconMenu'
MenuEmployeeManager._mt = Class(MenuEmployeeManager, TabbedMenuFrameElement)
diff --git a/scripts/gui/SimpleStatusHUD.lua b/scripts/gui/SimpleStatusHUD.lua
index a2a0791..3074032 100644
--- a/scripts/gui/SimpleStatusHUD.lua
+++ b/scripts/gui/SimpleStatusHUD.lua
@@ -64,9 +64,21 @@ function SimpleStatusHUD:draw()
color = {1, 1, 0, 1}
end
- if emp.currentJob and emp.currentJob.type == "RETURN_TO_PARKING" then
- status = g_i18n:getText("em_status_returning")
- color = {0.5, 0.8, 1, 1}
+ if emp.currentJob then
+ local jobType = emp.currentJob.type
+ if jobType == "RETURN_TO_PARKING" then
+ status = g_i18n:getText("em_status_returning")
+ color = {0.5, 0.8, 1, 1}
+ elseif jobType == "DRIVING_TO_TOOL" then
+ status = g_i18n:getText("em_status_driving_to_tool")
+ color = {1, 0.8, 0.2, 1}
+ elseif jobType == "APPROACHING_TOOL" or jobType == "ATTACHING_TOOL" then
+ status = g_i18n:getText("em_status_attaching_tool")
+ color = {1, 0.8, 0.2, 1}
+ elseif jobType == "RETURNING_TOOL" then
+ status = g_i18n:getText("em_status_returning_tool")
+ color = {0.5, 0.8, 1, 1}
+ end
end
setTextColor(unpack(color))
diff --git a/scripts/main.lua b/scripts/main.lua
index 88c0b33..336e2a3 100644
--- a/scripts/main.lua
+++ b/scripts/main.lua
@@ -7,7 +7,33 @@ MessageType.EMPLOYEE_ADDED = nextMessageTypeId()
MessageType.EMPLOYEE_REMOVED = nextMessageTypeId()
MessageType.EMPLOYEE_SKILL_LEVELUP = nextMessageTypeId()
+-- Icon globals (resolved by GuiOverlay.resolveFilename hook)
+g_EMIconMenu = Utils.getFilename("images/MenuIcon.dds", g_modDirectory)
+g_EMIconEmployee = Utils.getFilename("images/EMEmployeeIcon.dds", g_modDirectory)
+g_EMIconWorkflow = Utils.getFilename("images/EMWorkflowIcon.dds", g_modDirectory)
+g_EMIconField = Utils.getFilename("images/EMFieldIcon.dds", g_modDirectory)
+g_EMIconVehicle = Utils.getFilename("images/EMVehicleIcon.dds", g_modDirectory)
+
+local EM_ICON_GLOBALS = {
+ g_EMIconMenu = true,
+ g_EMIconEmployee = true,
+ g_EMIconWorkflow = true,
+ g_EMIconField = true,
+ g_EMIconVehicle = true,
+}
+
+local function emResolveFilename(self, superFunc)
+ local filename = superFunc(self)
+ if EM_ICON_GLOBALS[filename] then
+ return _G[filename]
+ end
+ return filename
+end
+
+GuiOverlay.resolveFilename = Utils.overwrittenFunction(GuiOverlay.resolveFilename, emResolveFilename)
+
source(g_modDirectory .. "scripts/utils/Utils.lua")
+
source(g_modDirectory .. "scripts/types/traitsystem.lua")
source(g_modDirectory .. "scripts/types/skillsystem.lua")
source(g_modDirectory .. "scripts/types/skilleffects.lua")
@@ -32,10 +58,11 @@ source(g_modDirectory .. "scripts/managers/cropmanager.lua")
source(g_modDirectory .. "scripts/managers/employeemanager.lua")
source(g_modDirectory .. "scripts/managers/jobmanager.lua")
source(g_modDirectory .. "scripts/managers/parkingmanager.lua")
+source(g_modDirectory .. "scripts/managers/VehicleSnapshotManager.lua")
source(g_modDirectory .. "scripts/managers/milestonesystem.lua")
source(g_modDirectory .. "scripts/persistence/PersistenceStrategy.lua")
source(g_modDirectory .. "scripts/persistence/XMLPersistence.lua")
-source(g_modDirectory .. "scripts/persistence/DBAPIPersistence.lua")
+source(g_modDirectory .. "scripts/persistence/SILODBPersistence.lua")
source(g_modDirectory .. "scripts/persistence/PersistenceManager.lua")
source(g_modDirectory .. "scripts/extensions/WearableExtension.lua")
source(g_modDirectory .. "scripts/extensions/HarvestExtension.lua")
diff --git a/scripts/managers/VehicleSnapshotManager.lua b/scripts/managers/VehicleSnapshotManager.lua
new file mode 100644
index 0000000..0db722f
--- /dev/null
+++ b/scripts/managers/VehicleSnapshotManager.lua
@@ -0,0 +1,151 @@
+VehicleSnapshotManager = {}
+
+local VehicleSnapshotManager_mt = Class(VehicleSnapshotManager)
+
+---@return VehicleSnapshotManager
+function VehicleSnapshotManager:new()
+ local self = setmetatable({}, VehicleSnapshotManager_mt)
+ self.snapshots = {}
+ CustomUtils:debug("[SnapshotManager] Initialized")
+ return self
+end
+
+---Captures a snapshot of the vehicle and all attached tools' positions
+---@param employee table
+---@param vehicle table
+function VehicleSnapshotManager:captureSnapshot(employee, vehicle)
+ if not employee or not vehicle or not vehicle.rootNode then
+ CustomUtils:warning("[SnapshotManager] Cannot capture snapshot: invalid employee or vehicle")
+ return
+ end
+
+ local vx, vy, vz = getWorldTranslation(vehicle.rootNode)
+ local dx, _, dz = localDirectionToWorld(vehicle.rootNode, 0, 0, 1)
+ local angle = MathUtil.getYRotationFromDirection(dx, dz)
+
+ local snapshot = {
+ employeeId = employee.id,
+ timestamp = g_currentMission.time,
+ vehicle = {
+ id = vehicle.id,
+ name = vehicle:getName(),
+ x = vx,
+ y = vy,
+ z = vz,
+ angle = angle
+ },
+ tools = {}
+ }
+
+ -- Capture all attached implements
+ if vehicle.getAttachedImplements then
+ local implements = vehicle:getAttachedImplements()
+ for _, implement in ipairs(implements) do
+ local obj = implement.object
+ if obj and obj.rootNode then
+ local tx, ty, tz = getWorldTranslation(obj.rootNode)
+ local tdx, _, tdz = localDirectionToWorld(obj.rootNode, 0, 0, 1)
+ local tAngle = MathUtil.getYRotationFromDirection(tdx, tdz)
+
+ table.insert(snapshot.tools, {
+ id = obj.id,
+ name = obj:getName(),
+ x = tx,
+ y = ty,
+ z = tz,
+ angle = tAngle
+ })
+ end
+ end
+ end
+
+ self.snapshots[employee.id] = snapshot
+ CustomUtils:info("[SnapshotManager] Captured snapshot for employee %s: vehicle '%s' at (%.1f, %.1f, %.1f), %d tools",
+ employee.name, snapshot.vehicle.name, vx, vy, vz, #snapshot.tools)
+end
+
+---Returns the snapshot for a given employee, or nil
+---@param employeeId number
+---@return table|nil
+function VehicleSnapshotManager:getSnapshot(employeeId)
+ return self.snapshots[employeeId]
+end
+
+---Clears the snapshot for a given employee
+---@param employeeId number
+function VehicleSnapshotManager:clearSnapshot(employeeId)
+ if self.snapshots[employeeId] then
+ CustomUtils:debug("[SnapshotManager] Cleared snapshot for employee %d", employeeId)
+ self.snapshots[employeeId] = nil
+ end
+end
+
+---Restores all tools from a snapshot to their original positions
+---@param snapshot table
+function VehicleSnapshotManager:restoreTools(snapshot)
+ if not snapshot or not snapshot.tools then return end
+
+ for _, toolData in ipairs(snapshot.tools) do
+ local tool = g_employeeManager:getVehicleById(toolData.id)
+ if tool and tool.rootNode then
+ -- Teleport tool to its original position
+ if tool.removeFromPhysics then
+ tool:removeFromPhysics()
+ end
+
+ local rx, ry, rz = 0, toolData.angle, 0
+ setWorldTranslation(tool.rootNode, toolData.x, toolData.y, toolData.z)
+ setRotation(tool.rootNode, rx, ry, rz)
+
+ if tool.addToPhysics then
+ tool:addToPhysics()
+ end
+
+ CustomUtils:info("[SnapshotManager] Restored tool '%s' to (%.1f, %.1f, %.1f)",
+ toolData.name, toolData.x, toolData.y, toolData.z)
+ else
+ CustomUtils:warning("[SnapshotManager] Tool '%s' (ID: %d) no longer exists, skipping restore",
+ toolData.name or "unknown", toolData.id or 0)
+ end
+ end
+end
+
+---Serializes all snapshots to a plain table for persistence
+---@return table
+function VehicleSnapshotManager:toTable()
+ local data = {}
+ for empId, snapshot in pairs(self.snapshots) do
+ data[tostring(empId)] = {
+ employeeId = snapshot.employeeId,
+ timestamp = snapshot.timestamp,
+ vehicle = snapshot.vehicle,
+ tools = snapshot.tools
+ }
+ end
+ return data
+end
+
+---Loads snapshots from a previously serialized table
+---@param data table
+function VehicleSnapshotManager:fromTable(data)
+ self.snapshots = {}
+ if not data then return end
+
+ for empIdStr, snapshot in pairs(data) do
+ local empId = tonumber(empIdStr)
+ if empId and snapshot.vehicle then
+ self.snapshots[empId] = {
+ employeeId = snapshot.employeeId or empId,
+ timestamp = snapshot.timestamp or 0,
+ vehicle = snapshot.vehicle,
+ tools = snapshot.tools or {}
+ }
+ end
+ end
+
+ local count = 0
+ for _ in pairs(self.snapshots) do count = count + 1 end
+ if count > 0 then
+ CustomUtils:info("[SnapshotManager] Loaded %d snapshots from persistence", count)
+ end
+end
diff --git a/scripts/managers/cropmanager.lua b/scripts/managers/cropmanager.lua
index bf634a6..2102471 100644
--- a/scripts/managers/cropmanager.lua
+++ b/scripts/managers/cropmanager.lua
@@ -55,8 +55,10 @@ function CropManager:getNextStep(field, targetCropName)
CustomUtils:debug("[CropManager] Field %d Analysis for %s:", field.fieldId, targetCropName)
CustomUtils:debug(" - Fruit: %d (Target: %d)", state.fruitTypeIndex, self:getFruitTypeIndex(targetCropName))
CustomUtils:debug(" - Growth: %d", state.growthState)
+ CustomUtils:debug(" - GroundType: %d (%s)", state.groundType, FieldGroundType.getName(state.groundType) or "UNKNOWN")
+ CustomUtils:debug(" - SprayType: %d | SprayLevel: %d", state.sprayType or 0, state.sprayLevel)
CustomUtils:debug(" - Plow: %d | Lime: %d | Stones: %d", state.plowLevel, state.limeLevel, state.stoneLevel)
- CustomUtils:debug(" - Stubble: %d | Weed: %d | Spray: %d", state.stubbleShredLevel, state.weedState, state.sprayLevel)
+ CustomUtils:debug(" - Stubble: %d | Weed: %d | Roller: %d", state.stubbleShredLevel, state.weedState, state.rollerLevel)
local targetFruitIndex = self:getFruitTypeIndex(targetCropName)
diff --git a/scripts/managers/employeemanager.lua b/scripts/managers/employeemanager.lua
index eb767b5..dae04c8 100644
--- a/scripts/managers/employeemanager.lua
+++ b/scripts/managers/employeemanager.lua
@@ -85,7 +85,8 @@ function EmployeeManager:update(dt)
CustomUtils:info("[EmployeeManager] %s stopped working (unpaid)", employee.name)
end
elseif employee.isHired and employee.currentJob ~= nil then
- local hoursWorked = employee:updateWorkTime(dt)
+ local effectiveDt = math.min(dt, 200) -- Cap at 200ms: prevent wage inflation during time acceleration
+ local hoursWorked = employee:updateWorkTime(effectiveDt)
if hoursWorked > 0 then
local fatigueMult = employee:getFatigueMultiplier()
local wage = employee:getHourlyWage() * marketMult * hoursWorked
@@ -133,6 +134,55 @@ function EmployeeManager:update(dt)
end
end
end
+
+ -- Task-queue-based autonomous restart (no targetCrop, uses taskQueue instead)
+ if employee.isHired and not employee.isUnpaid and employee.isAutonomous
+ and employee.currentJob == nil
+ and (employee.targetCrop == nil or employee.targetCrop == "")
+ and employee.taskQueue and #employee.taskQueue > 0
+ and employee.targetFieldId ~= nil then
+
+ if not employee:canWork() then
+ -- skip: on break or exhausted
+ elseif g_currentMission and g_currentMission.environment and not employee:isWithinShift(g_currentMission.environment.currentHour or 0) then
+ -- skip: outside shift
+ else
+ employee.decisionTimer = (employee.decisionTimer or 0) + dt
+ if employee.decisionTimer > 5000 then
+ employee.decisionTimer = 0
+ local idx = employee.currentTaskIndex or 1
+ if idx <= #employee.taskQueue then
+ local task = employee.taskQueue[idx]
+ CustomUtils:info("[EmployeeManager] %s resuming task %d/%d: %s",
+ employee.name, idx, #employee.taskQueue, task)
+ local success = self.jobManager:startFieldWork(employee, employee.targetFieldId, task)
+ if not success then
+ CustomUtils:warning("[EmployeeManager] %s auto-start FAILED for task %s on field %d",
+ employee.name, task, employee.targetFieldId)
+ end
+ end
+ end
+ end
+ end
+
+ -- Diagnostic: autonomous employees that SHOULD be working but aren't
+ if employee.isHired and employee.isAutonomous and employee.currentJob == nil
+ and employee.taskQueue and #employee.taskQueue > 0 then
+ employee.diagTimer = (employee.diagTimer or 0) + dt
+ if employee.diagTimer > 60000 then
+ employee.diagTimer = 0
+ local hour = (g_currentMission and g_currentMission.environment) and g_currentMission.environment.currentHour or 0
+ local reasons = {}
+ if employee.isUnpaid then table.insert(reasons, "UNPAID") end
+ if not employee:canWork() then table.insert(reasons, "CANNOT_WORK (break/exhausted)") end
+ if not employee:isWithinShift(hour) then table.insert(reasons, string.format("OUTSIDE_SHIFT (%d:00, shift %d-%d)", hour, employee.shiftStart or 6, employee.shiftEnd or 18)) end
+ if not employee.targetFieldId then table.insert(reasons, "NO_FIELD") end
+ if employee.targetCrop and employee.targetCrop ~= "" then table.insert(reasons, "HAS_TARGET_CROP (using crop-based path)") end
+ if #reasons > 0 then
+ CustomUtils:debug("[EmployeeManager] %s idle but autonomous: %s", employee.name, table.concat(reasons, ", "))
+ end
+ end
+ end
end
self:checkPoolRefresh()
@@ -245,9 +295,12 @@ function EmployeeManager:onHourChanged()
employee.breakEndTime = nil
CustomUtils:info("[EmployeeManager] %s break is over, ready to work", employee.name)
end
+ elseif employee.currentJob == nil and employee.isAutonomous and currentHour == (employee.shiftStart or 6) then
+ CustomUtils:info("[EmployeeManager] Shift start: %s is autonomous and idle at %d:00 (shift %d-%d), update() will auto-start",
+ employee.name, currentHour, employee.shiftStart or 6, employee.shiftEnd or 18)
elseif employee.currentJob ~= nil then
local jobType = employee.currentJob.type
- if jobType ~= "TRANSIT" and jobType ~= "RETURN_TO_PARKING" and jobType ~= "PREPARING" then
+ if jobType ~= "TRANSIT" and jobType ~= "RETURN_TO_PARKING" and jobType ~= "PREPARING" and jobType ~= "EQUIPMENT_READY" then
if not employee:isWithinShift(currentHour) then
self.jobManager:stopJob(employee)
CustomUtils:info("[EmployeeManager] %s stopped: outside shift hours (%d:00, shift %d-%d)",
@@ -268,6 +321,35 @@ function EmployeeManager:onHourChanged()
end
end
end
+
+ -- Hourly state dump for ALL hired employees
+ if employee.isHired then
+ local jobDesc = "NONE"
+ if employee.currentJob then
+ jobDesc = string.format("%s (field %s)", employee.currentJob.type or "?", tostring(employee.currentJob.fieldId or "?"))
+ end
+ local queueDesc = "empty"
+ if employee.taskQueue and #employee.taskQueue > 0 then
+ queueDesc = string.format("%d tasks, idx=%d", #employee.taskQueue, employee.currentTaskIndex or 1)
+ end
+ CustomUtils:info(
+ "[EmployeeManager] HOURLY[%d:00] %s | autonomous=%s | job=%s | field=%s | vehicle=%s | crop=%s | queue=%s | shift=%d-%d | inShift=%s | canWork=%s | unpaid=%s | hoursWorked=%.1f",
+ currentHour,
+ employee.name,
+ tostring(employee.isAutonomous),
+ jobDesc,
+ tostring(employee.targetFieldId),
+ tostring(employee.assignedVehicleId),
+ tostring(employee.targetCrop),
+ queueDesc,
+ employee.shiftStart or 6,
+ employee.shiftEnd or 18,
+ tostring(employee:isWithinShift(currentHour)),
+ tostring(employee:canWork()),
+ tostring(employee.isUnpaid),
+ employee.dailyHoursWorked or 0
+ )
+ end
end
end
@@ -439,16 +521,25 @@ function EmployeeManager:returnRentedEquipment(employee)
local tool = self:getVehicleById(toolId)
if tool then
- CustomUtils:info("[EmployeeManager] Returning rented equipment %s for employee %s", tool:getName(), employee.name)
-
+ CustomUtils:info("[EmployeeManager] Returning rented equipment %s (ID: %d) for employee %s", tool:getName(), toolId, employee.name)
+
local vehicle = self:getVehicleById(employee.assignedVehicleId)
if vehicle and vehicle.detachImplementByObject then
vehicle:detachImplementByObject(tool)
end
-
- tool:delete()
+
+ if g_currentMission.removeSaleableItem then
+ pcall(g_currentMission.removeSaleableItem, g_currentMission, tool)
+ end
+
+ local ok, err = pcall(tool.delete, tool)
+ if not ok then
+ CustomUtils:warning("[EmployeeManager] Error deleting rented tool ID %d: %s", toolId, tostring(err))
+ end
+ else
+ CustomUtils:warning("[EmployeeManager] Rented tool ID %d not found (already deleted?), clearing reference for %s", toolId, employee.name)
end
-
+
employee.temporaryRental = nil
employee.isRenting = false
end
@@ -661,6 +752,19 @@ function EmployeeManager:setFieldConfig(fieldId, cropName, assignments)
CustomUtils:info("[EmployeeManager] Configured workflow for Field %d: %s", fieldId, cropName)
end
+function EmployeeManager:setFieldTargetCrop(fieldId, cropName)
+ if not self.fieldConfigs[fieldId] then
+ self.fieldConfigs[fieldId] = {}
+ end
+ self.fieldConfigs[fieldId].cropName = cropName
+ CustomUtils:info("[EmployeeManager] Set target crop for Field %d to %s", fieldId, cropName)
+end
+
+function EmployeeManager:getFieldTargetCrop(fieldId)
+ local config = self.fieldConfigs[fieldId]
+ return config and config.cropName or nil
+end
+
function EmployeeManager:getAssignedEmployeeForStep(fieldId, stepName)
local config = self.fieldConfigs[fieldId]
if config and config.assignments then
@@ -867,9 +971,10 @@ function EmployeeManager:saveToXMLFile(xmlFile, key)
if e.targetCrop ~= nil then
setXMLString(xmlFile, base .. "#targetCrop", e.targetCrop)
- setXMLInt(xmlFile, base .. "#targetFieldId", e.targetFieldId or 0)
- setXMLBool(xmlFile, base .. "#isAutonomous", e.isAutonomous or false)
end
+ setXMLInt(xmlFile, base .. "#targetFieldId", e.targetFieldId or 0)
+ setXMLBool(xmlFile, base .. "#isAutonomous", e.isAutonomous or false)
+ setXMLInt(xmlFile, base .. "#currentTaskIndex", e.currentTaskIndex or 1)
setXMLInt(xmlFile, base .. "#shiftStart", e.shiftStart or 6)
setXMLInt(xmlFile, base .. "#shiftEnd", e.shiftEnd or 18)
@@ -1003,6 +1108,7 @@ function EmployeeManager:loadFromXMLFile(xmlFile, key)
table.insert(emp.taskQueue, taskName)
qi = qi + 1
end
+ emp.currentTaskIndex = Utils.getNoNil(getXMLInt(xmlFile, base .. "#currentTaskIndex"), 1)
table.insert(self.employees, emp)
i = i + 1
diff --git a/scripts/managers/jobmanager.lua b/scripts/managers/jobmanager.lua
index 6b4595d..217ff81 100644
--- a/scripts/managers/jobmanager.lua
+++ b/scripts/managers/jobmanager.lua
@@ -82,6 +82,11 @@ function JobManager:startFieldWork(employee, fieldId, workType)
workType = workType
}
+ -- Capture vehicle+tools origin snapshot (only on first task, not mid-workflow transitions)
+ if g_snapshotManager and not g_snapshotManager:getSnapshot(employee.id) then
+ g_snapshotManager:captureSnapshot(employee, vehicle)
+ end
+
CustomUtils:debug("[JobManager] Preparing vehicle %s (ID: %d) for job...", vehicle:getName(), vehicle.id)
if vehicle.startMotor and not vehicle:getIsMotorStarted() then
@@ -100,64 +105,27 @@ function JobManager:startFieldWork(employee, fieldId, workType)
vehicle:stopAIJob()
end
- self:ensureEquipment(vehicle, workType, function(success)
- if not success then
- CustomUtils:error("[JobManager] Could not ensure equipment for %s - Job Aborted", workType)
+ self:ensureEquipment(vehicle, workType, function(result, data)
+ if result == true then
+ -- Tier 1 (already attached) or close-proximity native attach succeeded
+ CustomUtils:info("[JobManager] Equipment ensured. Deferring start via EQUIPMENT_READY state...")
+ employee.currentJob = {
+ type = "EQUIPMENT_READY",
+ fieldId = fieldId,
+ workType = workType,
+ readyFrame = 0,
+ readyTime = g_currentMission.time + 1000
+ }
+ elseif result == "DRIVE_TO_TOOL" then
+ -- Tier 2: owned tool found but needs driving to
+ CustomUtils:info("[JobManager] Owned tool found. Starting drive-to-tool for %s...", employee.name)
+ self:startDriveToTool(employee, vehicle, data.tool, fieldId, workType)
+ else
+ local msg = string.format(g_i18n:getText("em_error_equipment_failed"), employee.name, workType)
+ CustomUtils:error("[JobManager] " .. msg)
+ g_currentMission:showBlinkingWarning(msg, 5000)
employee.currentJob = nil
- return
- end
-
- CustomUtils:debug("[JobManager] Equipment ensured. Check distance...")
-
- local x, z = field:getCenterOfFieldWorldPosition()
- local vx, _, vz = getWorldTranslation(vehicle.rootNode)
- local distance = MathUtil.vector2Length(vx - x, vz - z)
-
- CustomUtils:info("[JobManager] Distance to Field %d: %.1f m", fieldId, distance)
-
- if distance > 150 then
- CustomUtils:info("[JobManager] Field is far. Starting TRANSIT (GOTO) job first.")
-
- local aiJob = g_currentMission.aiJobTypeManager:createJob(AIJobType.GOTO)
- if aiJob then
- local farmId = g_currentMission:getFarmId()
- aiJob:applyCurrentState(vehicle, g_currentMission, farmId, false)
- aiJob.positionAngleParameter:setPosition(x, z)
-
- local dx, dz = x - vx, z - vz
- local angle = MathUtil.getYRotationFromDirection(dx, dz)
- aiJob.positionAngleParameter:setAngle(angle)
-
- aiJob:setValues()
-
- local validateSuccess, errorMessage = aiJob:validate(farmId)
- if validateSuccess then
- g_currentMission.aiSystem:startJob(aiJob, farmId)
-
- employee.currentJob = {
- aiJobId = aiJob.jobId,
- type = "TRANSIT",
- fieldId = fieldId,
- workType = workType,
- startTime = g_currentMission.time
- }
-
- employee.pendingJob = {
- fieldId = fieldId,
- workType = workType
- }
- CustomUtils:info("[JobManager] Employee %s is now in TRANSIT to field %d", employee.name, fieldId)
- return
- else
- CustomUtils:error("[JobManager] Transit GOTO job failed validation: %s", errorMessage)
- end
- else
- CustomUtils:error("[JobManager] Failed to create GOTO job")
- end
end
-
- CustomUtils:debug("[JobManager] Starting FIELDWORK immediately (Direct or Close Proximity).")
- self:startFieldWorkJob(employee, vehicle, fieldId, workType)
end)
return true
@@ -176,19 +144,37 @@ function JobManager:startFieldWorkJob(employee, vehicle, fieldId, workType)
local farmId = g_currentMission:getFarmId()
local x, z = field:getCenterOfFieldWorldPosition()
- local vx, _, vz = getWorldTranslation(vehicle.rootNode)
- local distance = MathUtil.vector2Length(vx - x, vz - z)
- aiJob.isDirectStart = distance < 50
- aiJob:applyCurrentState(vehicle, g_currentMission, farmId, aiJob.isDirectStart)
+ -- Field state snapshot for debugging
+ if field.fieldState == nil then
+ field.fieldState = FieldState.new()
+ end
+ field.fieldState:update(x, z)
+ local fs = field.fieldState
- if not aiJob.isDirectStart then
- aiJob.positionAngleParameter:setPosition(x, z)
- else
- local dirX, _, dirZ = localDirectionToWorld(vehicle.rootNode, 0, 0, 1)
- local angle = MathUtil.getYRotationFromDirection(dirX, dirZ)
- aiJob.positionAngleParameter:setAngle(angle)
+ local fruitName = "NONE"
+ if fs.fruitTypeIndex ~= FruitType.UNKNOWN then
+ local ft = g_fruitTypeManager:getFruitTypeByIndex(fs.fruitTypeIndex)
+ fruitName = ft and ft.name or "UNKNOWN"
end
+ local groundName = FieldGroundType.getName(fs.groundType) or "UNKNOWN"
+
+ CustomUtils:info("[JobManager] Field %d state before %s:", fieldId, workType)
+ CustomUtils:info(" Fruit: %s (%d) | Growth: %d | Ground: %s (%d)",
+ fruitName, fs.fruitTypeIndex, fs.growthState, groundName, fs.groundType)
+ CustomUtils:info(" Plow: %d | Lime: %d | Stones: %d | Spray: %d | SprayType: %d",
+ fs.plowLevel, fs.limeLevel, fs.stoneLevel, fs.sprayLevel, fs.sprayType or 0)
+ CustomUtils:info(" Stubble: %d | Weed: %d | Roller: %d",
+ fs.stubbleShredLevel, fs.weedState, fs.rollerLevel)
+
+ -- Never use isDirectStart for automated workflows.
+ -- isDirectStart=true skips the drive-to task and starts from the vehicle's
+ -- current edge position, which causes the AI to terminate instantly when it
+ -- can't generate valid work rows from there.
+ aiJob.isDirectStart = false
+
+ aiJob:applyCurrentState(vehicle, g_currentMission, farmId, false)
+ aiJob.positionAngleParameter:setPosition(x, z)
aiJob:setValues()
@@ -219,8 +205,34 @@ function JobManager:startFieldWorkJob(employee, vehicle, fieldId, workType)
end
end
----Checks if vehicle has required tool, otherwise rents one
+---Detaches all implements from a vehicle
+---@param vehicle table
+function JobManager:detachAllImplements(vehicle)
+ if not vehicle or not vehicle.getAttachedImplements then return end
+
+ local attachedImplements = vehicle:getAttachedImplements()
+ -- Iterate in reverse to avoid index shifting
+ for i = #attachedImplements, 1, -1 do
+ local implement = attachedImplements[i]
+ if implement and implement.object then
+ CustomUtils:info("[JobManager] Detaching %s from %s", implement.object:getName(), vehicle:getName())
+ if vehicle.detachImplementByObject then
+ vehicle:detachImplementByObject(implement.object)
+ end
+ end
+ end
+end
+
+---Checks if vehicle has required tool via three-tier search: attached → owned → rental
+---Callback receives (true) for ready, ("DRIVE_TO_TOOL", data) for owned tool needing drive, or (false) for failure
function JobManager:ensureEquipment(vehicle, workType, callback)
+ -- Defensive cleanup: return any lingering rental before equipping for new task
+ local employee = g_employeeManager:getEmployeeByVehicle(vehicle)
+ if employee and employee.temporaryRental then
+ CustomUtils:warning("[JobManager] ensureEquipment: cleaning up lingering rental for %s", employee.name)
+ g_employeeManager:returnRentedEquipment(employee)
+ end
+
local categoryName = JobManager.WORK_TYPE_TO_CATEGORY[workType]
if not categoryName then
callback(true)
@@ -228,33 +240,49 @@ function JobManager:ensureEquipment(vehicle, workType, callback)
end
local attachedImplements = vehicle:getAttachedImplements()
- local hasAttachedTool = #attachedImplements > 0
+ -- Tier 1: Check if the currently attached tool already supports this work type
for _, implement in ipairs(attachedImplements) do
local obj = implement.object
- if obj ~= nil and obj.getIsAIJobSupported ~= nil and obj:getIsAIJobSupported("AIJobFieldWork") then
- callback(true)
- return
+ if obj ~= nil then
+ local storeItem = g_storeManager:getItemByXMLFilename(obj.configFileName)
+ if storeItem and storeItem.categoryName == categoryName then
+ CustomUtils:info("[JobManager] Tier 1: Vehicle already has correct tool (%s) for %s", obj:getName(), workType)
+ callback(true)
+ return
+ end
end
end
- if hasAttachedTool then
- CustomUtils:warning("[JobManager] Vehicle already has an implement attached but it doesn't support %s. Cannot attach another.", workType)
- callback(false)
- return
+ -- Detach any existing implements before attaching a new one (1 tool at a time!)
+ if #attachedImplements > 0 then
+ CustomUtils:info("[JobManager] Detaching existing implements before attaching tool for %s", workType)
+ self:detachAllImplements(vehicle)
end
- local parkedTool = self:findToolInParking(categoryName, vehicle)
- if parkedTool then
- CustomUtils:info("[JobManager] Found owned tool %s in parking, attaching...", parkedTool:getName())
- if vehicle.attachImplement then
- vehicle:attachImplement(parkedTool, 1, 1)
+ -- Tier 2: Search ALL owned vehicles on the farm for a matching tool
+ local ownedTool = self:findOwnedTool(categoryName, vehicle)
+ if ownedTool then
+ local tx, _, tz = getWorldTranslation(ownedTool.rootNode)
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local dist = MathUtil.vector2Length(vx - tx, vz - tz)
+
+ if dist < 10 then
+ -- Tool is close — try native attach directly
+ CustomUtils:info("[JobManager] Tier 2: Owned tool %s is nearby (%.1fm). Attempting native attach...",
+ ownedTool:getName(), dist)
+ self:attemptNativeAttach(vehicle, ownedTool, callback)
+ else
+ -- Tool is far — employee needs to drive to it
+ CustomUtils:info("[JobManager] Tier 2: Found owned tool %s at %.1fm away. Employee must drive to it.",
+ ownedTool:getName(), dist)
+ callback("DRIVE_TO_TOOL", { tool = ownedTool, toolX = tx, toolZ = tz })
end
- callback(true)
return
end
- CustomUtils:info("[JobManager] No tool found for %s. Renting equipment...", workType)
+ -- Tier 3: Rent from store as last resort
+ CustomUtils:info("[JobManager] Tier 3: No owned tool found for %s. Renting equipment...", workType)
local storeItem = self:findSuitableTool(categoryName)
if storeItem then
self:rentAndAttach(vehicle, storeItem, callback)
@@ -264,6 +292,462 @@ function JobManager:ensureEquipment(vehicle, workType, callback)
end
end
+---Searches ALL farm vehicles for an unattached, unused tool matching the category
+---@param categoryName string
+---@param vehicle table The tractor that needs the tool
+---@return table|nil The found tool vehicle, or nil
+function JobManager:findOwnedTool(categoryName, vehicle)
+ if not g_currentMission or not g_currentMission.vehicleSystem then return nil end
+
+ local farmId = g_currentMission:getFarmId()
+ local vehicles = g_currentMission.vehicleSystem.vehicles
+
+ for _, v in ipairs(vehicles) do
+ if v ~= vehicle and v.ownerFarmId == farmId then
+ local storeItem = g_storeManager:getItemByXMLFilename(v.configFileName)
+ if storeItem and storeItem.categoryName == categoryName then
+ if not self:isAttachedToAnyVehicle(v) and not self:isToolInUseByEmployee(v.id) then
+ CustomUtils:debug("[JobManager] findOwnedTool: Match found — %s (ID: %d)", v:getName(), v.id)
+ return v
+ end
+ end
+ end
+ end
+
+ return nil
+end
+
+---Checks if a tool is currently attached to any vehicle
+---@param tool table
+---@return boolean
+function JobManager:isAttachedToAnyVehicle(tool)
+ if tool.getAttacherVehicle and tool:getAttacherVehicle() ~= nil then
+ return true
+ end
+ return false
+end
+
+---Checks if a tool is currently rented/in-use by another employee
+---@param toolId number
+---@return boolean
+function JobManager:isToolInUseByEmployee(toolId)
+ if not g_employeeManager then return false end
+
+ for _, employee in ipairs(g_employeeManager.employees) do
+ if employee.isHired and employee.temporaryRental == toolId then
+ return true
+ end
+ end
+ return false
+end
+
+---Teleports an owned tool to align with the vehicle's rear attacher joint
+---Positions the tool so its input attacher joint meets the vehicle's rear attacher joint
+---@param tool table
+---@param vehicle table
+function JobManager:positionToolNearVehicle(tool, vehicle)
+ local vx, vy, vz = getWorldTranslation(vehicle.rootNode)
+
+ -- Find the vehicle's rear attacher joint world position
+ local rearJointX, rearJointY, rearJointZ = vx, vy, vz
+ if vehicle.spec_attacherJoints then
+ local joints = vehicle:getAttacherJoints()
+ if joints then
+ for _, joint in ipairs(joints) do
+ if joint.jointTransform then
+ local _, _, lz = localToLocal(joint.jointTransform, vehicle.rootNode, 0, 0, 0)
+ if lz < -0.2 then
+ rearJointX, rearJointY, rearJointZ = getWorldTranslation(joint.jointTransform)
+ break
+ end
+ end
+ end
+ end
+ end
+
+ -- Calculate offset from tool's center to its input attacher joint
+ local toolJointOffsetX, toolJointOffsetZ = 0, 0
+ local inputJoints = tool.getInputAttacherJoints and tool:getInputAttacherJoints()
+ if inputJoints and #inputJoints > 0 then
+ local joint = inputJoints[1]
+ if joint.node then
+ local lx, _, lz = localToLocal(joint.node, tool.rootNode, 0, 0, 0)
+ toolJointOffsetX, toolJointOffsetZ = lx, lz
+ end
+ end
+
+ -- Get vehicle's backward direction to align tool behind it
+ local backDirX, _, backDirZ = localDirectionToWorld(vehicle.rootNode, 0, 0, -1)
+ local backRightX, _, backRightZ = localDirectionToWorld(vehicle.rootNode, 1, 0, 0)
+
+ -- Target position: rear joint position, offset by tool's joint-to-center distance
+ -- Tool needs to be placed so its input joint aligns with the vehicle's rear joint
+ local targetX = rearJointX - backRightX * toolJointOffsetX - backDirX * toolJointOffsetZ
+ local targetY = rearJointY + 0.3
+ local targetZ = rearJointZ - backRightZ * toolJointOffsetX - backDirZ * toolJointOffsetZ
+
+ -- Get vehicle's Y rotation so tool faces the same direction
+ local vDirX, _, vDirZ = localDirectionToWorld(vehicle.rootNode, 0, 0, 1)
+ local vehicleRotY = MathUtil.getYRotationFromDirection(vDirX, vDirZ)
+
+ if tool.setAbsolutePosition then
+ tool:setAbsolutePosition(targetX, targetY, targetZ, 0, vehicleRotY, 0)
+ CustomUtils:debug("[JobManager] Positioned %s at (%.1f, %.1f, %.1f) rot=%.1f° via setAbsolutePosition",
+ tool:getName(), targetX, targetY, targetZ, math.deg(vehicleRotY))
+ elseif tool.rootNode then
+ setWorldTranslation(tool.rootNode, targetX, targetY, targetZ)
+ setWorldRotation(tool.rootNode, 0, vehicleRotY, 0)
+ CustomUtils:debug("[JobManager] Positioned %s at (%.1f, %.1f, %.1f) rot=%.1f° via setWorldTranslation",
+ tool:getName(), targetX, targetY, targetZ, math.deg(vehicleRotY))
+ else
+ CustomUtils:warning("[JobManager] Cannot position tool %s — no positioning method available", tool:getName())
+ end
+end
+
+---Attempts native FS25 attach when tool is close to the vehicle
+---@param vehicle table
+---@param tool table
+---@param callback function
+function JobManager:attemptNativeAttach(vehicle, tool, callback)
+ -- Try scanning for attachable using base game system
+ if vehicle.spec_attacherJoints then
+ AttacherJoints.updateVehiclesInAttachRange(vehicle,
+ AttacherJoints.MAX_ATTACH_DISTANCE_SQ or 100,
+ AttacherJoints.MAX_ATTACH_ANGLE or 1.0, true)
+
+ local info = vehicle.spec_attacherJoints.attachableInfo
+ if info and info.attachable == tool then
+ CustomUtils:info("[JobManager] Native attach: Tool %s detected in range. Attaching via base game...", tool:getName())
+ vehicle:attachImplementFromInfo(info)
+ callback(true)
+ return
+ end
+ end
+
+ -- Fallback: position and direct attach (legacy method)
+ CustomUtils:info("[JobManager] Native attach scan missed. Falling back to position + attachImplement for %s", tool:getName())
+ self:positionToolNearVehicle(tool, vehicle)
+
+ local vJointIdx, tJointIdx = self:findCompatibleJoints(vehicle, tool)
+ if vehicle.attachImplement then
+ vehicle:attachImplement(tool, tJointIdx, vJointIdx)
+ end
+ callback(true)
+end
+
+---Calculates optimal approach position for a vehicle to reach a tool for attaching
+---Returns a point roughly 8m in front of the tool's input attacher joint direction
+---@param vehicle table
+---@param tool table
+---@return number approachX, number approachZ, number approachAngle
+function JobManager:calculateApproachPosition(vehicle, tool)
+ local tx, _, tz = getWorldTranslation(tool.rootNode)
+
+ -- Try to get orientation from the tool's input attacher joint
+ local inputJoints = tool.getInputAttacherJoints and tool:getInputAttacherJoints()
+ if inputJoints and #inputJoints > 0 then
+ local joint = inputJoints[1]
+ if joint.node then
+ -- Get the joint's world-space direction (forward = Z axis of the joint)
+ local jdx, _, jdz = localDirectionToWorld(joint.node, 0, 0, 1)
+ local jointLen = math.sqrt(jdx * jdx + jdz * jdz)
+ if jointLen > 0.001 then
+ jdx = jdx / jointLen
+ jdz = jdz / jointLen
+ -- Approach point: 8m in front of the joint along its forward direction
+ local approachX = tx + jdx * 8
+ local approachZ = tz + jdz * 8
+ -- Angle: vehicle should face TOWARD the tool (opposite direction)
+ local approachAngle = MathUtil.getYRotationFromDirection(-jdx, -jdz)
+ return approachX, approachZ, approachAngle
+ end
+ end
+ end
+
+ -- Fallback: approach from the vehicle's current direction
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local dx = tx - vx
+ local dz = tz - vz
+ local dist = math.sqrt(dx * dx + dz * dz)
+ if dist > 0.001 then
+ dx = dx / dist
+ dz = dz / dist
+ else
+ dx, dz = 0, 1
+ end
+ -- Position 8m before the tool, facing toward it
+ local approachX = tx - dx * 8
+ local approachZ = tz - dz * 8
+ local approachAngle = MathUtil.getYRotationFromDirection(dx, dz)
+ return approachX, approachZ, approachAngle
+end
+
+---Starts a GOTO AI job to drive the vehicle to a tool for attachment
+---@param employee table
+---@param vehicle table
+---@param tool table
+---@param fieldId number
+---@param workType string
+function JobManager:startDriveToTool(employee, vehicle, tool, fieldId, workType)
+ local approachX, approachZ, approachAngle = self:calculateApproachPosition(vehicle, tool)
+
+ local aiJob = g_currentMission.aiJobTypeManager:createJob(AIJobType.GOTO)
+ if not aiJob then
+ CustomUtils:error("[JobManager] Failed to create GOTO job for drive-to-tool. Falling back to teleport.")
+ self:fallbackTeleportAttach(employee, vehicle, tool, fieldId, workType)
+ return
+ end
+
+ local farmId = g_currentMission:getFarmId()
+ aiJob:applyCurrentState(vehicle, g_currentMission, farmId, false)
+ aiJob.positionAngleParameter:setPosition(approachX, approachZ)
+ aiJob.positionAngleParameter:setAngle(approachAngle)
+ aiJob:setValues()
+
+ local validateSuccess, errorMessage = aiJob:validate(farmId)
+ if validateSuccess then
+ g_currentMission.aiSystem:startJob(aiJob, farmId)
+ employee.currentJob = {
+ type = "DRIVING_TO_TOOL",
+ aiJobId = aiJob.jobId,
+ fieldId = fieldId,
+ workType = workType,
+ targetToolId = tool.id,
+ startTime = g_currentMission.time
+ }
+ CustomUtils:info("[JobManager] %s is now driving to tool %s (ID: %d)", employee.name, tool:getName(), tool.id)
+ else
+ CustomUtils:error("[JobManager] Drive-to-tool GOTO failed validation: %s. Falling back to teleport.", tostring(errorMessage))
+ self:fallbackTeleportAttach(employee, vehicle, tool, fieldId, workType)
+ end
+end
+
+---Attempts to attach the tool directly if in range, otherwise teleports it
+---@param employee table
+---@param vehicle table
+---@param tool table
+---@param fieldId number
+---@param workType string
+function JobManager:tryDirectAttachOrTeleport(employee, vehicle, tool, fieldId, workType)
+ -- Check if already in attach range
+ if vehicle.spec_attacherJoints then
+ AttacherJoints.updateVehiclesInAttachRange(vehicle,
+ AttacherJoints.MAX_ATTACH_DISTANCE_SQ or 100,
+ AttacherJoints.MAX_ATTACH_ANGLE or 1.0, true)
+
+ local info = vehicle.spec_attacherJoints.attachableInfo
+ if info and info.attachable == tool then
+ CustomUtils:info("[JobManager] %s: Tool %s in attach range! Attaching directly...", employee.name, tool:getName())
+ vehicle:attachImplementFromInfo(info)
+ employee.currentJob = {
+ type = "ATTACHING_TOOL",
+ fieldId = fieldId,
+ workType = workType,
+ targetToolId = tool.id,
+ attachStartTime = g_currentMission.time
+ }
+ return
+ end
+ end
+
+ -- Not in range — teleport tool to vehicle's rear
+ CustomUtils:warning("[JobManager] %s: Tool not in attach range. Teleporting tool to vehicle rear.", employee.name)
+ self:fallbackTeleportAttach(employee, vehicle, tool, fieldId, workType)
+end
+
+---Fallback: teleport tool near vehicle and attach directly (used when GOTO fails or timeout)
+---@param employee table
+---@param vehicle table
+---@param tool table
+---@param fieldId number
+---@param workType string
+function JobManager:fallbackTeleportAttach(employee, vehicle, tool, fieldId, workType)
+ CustomUtils:warning("[JobManager] FALLBACK: Teleporting tool %s to vehicle %s", tool:getName(), vehicle:getName())
+
+ -- Remove tool from physics before repositioning to prevent collision forces
+ if tool.removeFromPhysics then
+ tool:removeFromPhysics()
+ end
+
+ self:positionToolNearVehicle(tool, vehicle)
+
+ -- Re-enable tool physics before attaching
+ if tool.addToPhysics then
+ tool:addToPhysics()
+ end
+
+ local vJointIdx, tJointIdx = self:findCompatibleJoints(vehicle, tool)
+ if vehicle.attachImplement then
+ vehicle:attachImplement(tool, tJointIdx, vJointIdx)
+ end
+
+ -- Stabilize vehicle to prevent flip/roll from teleport collision
+ self:stabilizeVehicle(vehicle)
+
+ employee.currentJob = {
+ type = "EQUIPMENT_READY",
+ fieldId = fieldId,
+ workType = workType,
+ readyFrame = 0,
+ readyTime = g_currentMission.time + 2000
+ }
+end
+
+---Stabilizes a vehicle after tool teleport to prevent flip/roll
+---Preserves position and heading but resets pitch and roll to upright
+---@param vehicle table
+function JobManager:stabilizeVehicle(vehicle)
+ if not vehicle or not vehicle.rootNode then
+ return
+ end
+
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local _, yRot, _ = getWorldRotation(vehicle.rootNode)
+
+ -- Remove from physics, reposition upright, re-add
+ if vehicle.removeFromPhysics then
+ vehicle:removeFromPhysics()
+ end
+
+ -- setRelativePosition(x, offsetY, z, yRot) auto-gets terrain height, resets pitch/roll to 0
+ if vehicle.setRelativePosition then
+ vehicle:setRelativePosition(vx, 0.5, vz, yRot)
+ CustomUtils:debug("[JobManager] Stabilized vehicle %s at (%.1f, %.1f) heading %.1f°", vehicle:getName(), vx, vz, math.deg(yRot))
+ end
+
+ if vehicle.addToPhysics then
+ vehicle:addToPhysics()
+ end
+end
+
+---Starts a GOTO job to return the current tool to its parking spot before switching tools
+---@param employee table
+---@param vehicle table
+---@param tool table The tool to return
+---@param nextFieldId number
+---@param nextWorkType string
+function JobManager:startReturnTool(employee, vehicle, tool, nextFieldId, nextWorkType)
+ -- Since the tool is attached, we can just detach it in place after the fieldwork is done.
+ -- The vehicle is already near the field edge. Simply detach and proceed to next task.
+ CustomUtils:info("[JobManager] %s: Detaching tool %s at field edge before switching to %s",
+ employee.name, tool:getName(), nextWorkType)
+ self:detachAllImplements(vehicle)
+ employee.currentJob = nil
+ self:startFieldWork(employee, nextFieldId, nextWorkType)
+end
+
+---Starts a GOTO job to return the current tool to a specific parking spot
+---Used when the employee needs to drive the tool back to its original location
+---@param employee table
+---@param vehicle table
+---@param parkingX number
+---@param parkingZ number
+---@param nextFieldId number
+---@param nextWorkType string
+function JobManager:startReturnToolToSpot(employee, vehicle, parkingX, parkingZ, nextFieldId, nextWorkType)
+ local aiJob = g_currentMission.aiJobTypeManager:createJob(AIJobType.GOTO)
+ if not aiJob then
+ CustomUtils:error("[JobManager] Failed to create GOTO job for tool return. Detaching in place.")
+ self:detachAllImplements(vehicle)
+ employee.currentJob = nil
+ self:startFieldWork(employee, nextFieldId, nextWorkType)
+ return
+ end
+
+ local farmId = g_currentMission:getFarmId()
+ aiJob:applyCurrentState(vehicle, g_currentMission, farmId, false)
+ aiJob.positionAngleParameter:setPosition(parkingX, parkingZ)
+ aiJob:setValues()
+
+ local validateSuccess, errorMessage = aiJob:validate(farmId)
+ if validateSuccess then
+ g_currentMission.aiSystem:startJob(aiJob, farmId)
+ employee.currentJob = {
+ type = "RETURNING_TOOL",
+ aiJobId = aiJob.jobId,
+ nextFieldId = nextFieldId,
+ nextWorkType = nextWorkType,
+ startTime = g_currentMission.time
+ }
+ CustomUtils:info("[JobManager] %s is returning tool to parking before switching to %s",
+ employee.name, nextWorkType)
+ else
+ CustomUtils:error("[JobManager] Tool return GOTO failed validation: %s. Detaching in place.", tostring(errorMessage))
+ self:detachAllImplements(vehicle)
+ employee.currentJob = nil
+ self:startFieldWork(employee, nextFieldId, nextWorkType)
+ end
+end
+
+---Gets the first attached implement on a vehicle
+---@param vehicle table
+---@return table|nil implement object
+function JobManager:getFirstAttachedImplement(vehicle)
+ if not vehicle or not vehicle.getAttachedImplements then return nil end
+ local implements = vehicle:getAttachedImplements()
+ if #implements > 0 and implements[1].object then
+ return implements[1].object
+ end
+ return nil
+end
+
+---Checks if the vehicle has the correct tool for a given category
+---@param vehicle table
+---@param categoryName string
+---@return boolean
+function JobManager:hasCorrectTool(vehicle, categoryName)
+ if not vehicle or not categoryName then return false end
+ local attachedImplements = vehicle:getAttachedImplements()
+ for _, implement in ipairs(attachedImplements) do
+ local obj = implement.object
+ if obj then
+ local storeItem = g_storeManager:getItemByXMLFilename(obj.configFileName)
+ if storeItem and storeItem.categoryName == categoryName then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+---Finds compatible rear attacher joints between a vehicle and a tool
+---@param vehicle table
+---@param tool table
+---@return number vehicleJointIndex, number toolJointIndex
+function JobManager:findCompatibleJoints(vehicle, tool)
+ local vehicleJointIndex = 1
+ local toolJointIndex = 1
+
+ if vehicle.getAttacherJoints and tool.getInputAttacherJoints then
+ local vJoints = vehicle:getAttacherJoints()
+ local tJoints = tool:getInputAttacherJoints()
+
+ local rearIndices = {}
+ for i, joint in ipairs(vJoints) do
+ local lx, ly, lz = localToLocal(joint.jointTransform, vehicle.rootNode, 0, 0, 0)
+ if joint.attacherJointDirection == -1 or lz < -0.2 then
+ table.insert(rearIndices, i)
+ end
+ end
+
+ local found = false
+ for _, vIdx in ipairs(#rearIndices > 0 and rearIndices or {1}) do
+ local vJoint = vJoints[vIdx]
+ for tIdx, tJoint in ipairs(tJoints) do
+ if vJoint.jointType == tJoint.jointType then
+ vehicleJointIndex = vIdx
+ toolJointIndex = tIdx
+ found = true
+ break
+ end
+ end
+ if found then break end
+ end
+ end
+
+ return vehicleJointIndex, toolJointIndex
+end
+
function JobManager:findSuitableTool(categoryName)
local items = g_storeManager:getItems()
for _, item in pairs(items) do
@@ -284,35 +768,7 @@ function JobManager:rentAndAttach(vehicle, storeItem, callback)
if vehicleLoadState == VehicleLoadingState.OK then
local tool = vehicles[1]
- local vehicleJointIndex = 1
- local toolJointIndex = 1
-
- if vehicle.getAttacherJoints and tool.getInputAttacherJoints then
- local vJoints = vehicle:getAttacherJoints()
- local tJoints = tool:getInputAttacherJoints()
-
- local rearIndices = {}
- for i, joint in ipairs(vJoints) do
- local lx, ly, lz = localToLocal(joint.jointTransform, vehicle.rootNode, 0, 0, 0)
- if joint.attacherJointDirection == -1 or lz < -0.2 then
- table.insert(rearIndices, i)
- end
- end
-
- local found = false
- for _, vIdx in ipairs(#rearIndices > 0 and rearIndices or {1}) do
- local vJoint = vJoints[vIdx]
- for tIdx, tJoint in ipairs(tJoints) do
- if vJoint.jointType == tJoint.jointType then
- vehicleJointIndex = vIdx
- toolJointIndex = tIdx
- found = true
- break
- end
- end
- if found then break end
- end
- end
+ local vehicleJointIndex, toolJointIndex = self:findCompatibleJoints(vehicle, tool)
CustomUtils:debug("[JobManager] Attaching %s (Joint: %d) to %s (Joint: %d)",
tool:getName(), toolJointIndex, vehicle:getName(), vehicleJointIndex)
@@ -369,6 +825,11 @@ function JobManager:stopJob(employee)
employee.currentJob = nil
+ -- Clear origin snapshot on manual stop
+ if g_snapshotManager then
+ g_snapshotManager:clearSnapshot(employee.id)
+ end
+
if employee.temporaryRental then
g_employeeManager:returnRentedEquipment(employee)
end
@@ -378,6 +839,7 @@ function JobManager:stopJob(employee)
end
function JobManager:handleFieldworkCompletion(employee)
+ -- Return rented equipment from completed task
if employee.temporaryRental then
g_employeeManager:returnRentedEquipment(employee)
end
@@ -386,21 +848,98 @@ function JobManager:handleFieldworkCompletion(employee)
g_employeeManager:onJobCompleted(employee)
end
+ -- Task-queue chaining: check for next task
+ local queue = employee.taskQueue or {}
+ local currentIdx = employee.currentTaskIndex or 1
+ local nextIdx = currentIdx + 1
+
+ if employee.isAutonomous and nextIdx <= #queue then
+ local nextTask = queue[nextIdx]
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+
+ -- Check if the current tool works for the next task
+ local nextCategory = JobManager.WORK_TYPE_TO_CATEGORY[nextTask]
+ local currentToolOk = vehicle and self:hasCorrectTool(vehicle, nextCategory)
+
+ if not currentToolOk and vehicle then
+ -- Need to swap tools — return the current one first
+ local currentTool = self:getFirstAttachedImplement(vehicle)
+ if currentTool then
+ CustomUtils:info("[JobManager] %s needs tool swap: current tool won't work for %s. Returning tool first.",
+ employee.name, nextTask)
+ employee.currentTaskIndex = nextIdx
+ self:startReturnTool(employee, vehicle, currentTool, employee.targetFieldId, nextTask)
+ return
+ end
+ end
+
+ -- Tool is already correct or no tool to return — advance directly
+ employee.currentTaskIndex = nextIdx
+ CustomUtils:info("[JobManager] %s advancing to task %d/%d: %s",
+ employee.name, nextIdx, #queue, nextTask)
+ employee.currentJob = nil
+ self:startFieldWork(employee, employee.targetFieldId, nextTask)
+ return
+ end
+
+ -- All tasks complete (or no queue) — workflow finished
+ if #queue > 0 then
+ CustomUtils:info("[JobManager] %s completed all %d tasks in workflow", employee.name, #queue)
+ employee.currentTaskIndex = 1
+ employee.isAutonomous = false -- Queue exhausted, stop autonomous mode
+ local msg = string.format(g_i18n:getText("em_workflow_queue_complete"), employee.name)
+ g_currentMission:showBlinkingWarning(msg, 5000)
+ end
+
+ -- Attempt return-to-origin via snapshot (preferred) or parking spot (fallback)
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ local snapshot = g_snapshotManager and g_snapshotManager:getSnapshot(employee.id)
+
+ if snapshot and vehicle and vehicle.rootNode then
+ -- Detach all tools, then restore them to their original positions
+ self:detachAllImplements(vehicle)
+ g_snapshotManager:restoreTools(snapshot)
+
+ -- Create a spot-like object from the snapshot vehicle data for GOTO
+ local snapshotSpot = {
+ id = 0,
+ name = "origin",
+ x = snapshot.vehicle.x,
+ z = snapshot.vehicle.z,
+ angle = snapshot.vehicle.angle
+ }
+
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local dx = vx - snapshotSpot.x
+ local dz = vz - snapshotSpot.z
+ local dist = math.sqrt(dx * dx + dz * dz)
+
+ if dist > 20 then
+ CustomUtils:info("[JobManager] %s returning to origin (%.0fm away)", employee.name, dist)
+ self:startReturnToParking(employee, vehicle, snapshotSpot)
+ return
+ else
+ -- Already close to origin, just clear and finish
+ g_snapshotManager:clearSnapshot(employee.id)
+ employee.currentJob = nil
+ return
+ end
+ end
+
+ -- Fallback: parking spot return (no snapshot available)
if g_parkingManager and employee.assignedVehicleId then
local spot = g_parkingManager:getSpotForVehicle(employee.assignedVehicleId)
- if spot then
- local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
- if vehicle and vehicle.rootNode then
- local vx, _, vz = getWorldTranslation(vehicle.rootNode)
- local dx = vx - spot.x
- local dz = vz - spot.z
- local dist = math.sqrt(dx * dx + dz * dz)
-
- if dist > 20 then
- CustomUtils:info("[JobManager] %s returning to parking '%s' (%.0fm away)", employee.name, spot.name, dist)
- self:startReturnToParking(employee, vehicle, spot)
- return
- end
+ if spot and vehicle and vehicle.rootNode then
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local dx = vx - spot.x
+ local dz = vz - spot.z
+ local dist = math.sqrt(dx * dx + dz * dz)
+
+ if dist > 20 then
+ CustomUtils:info("[JobManager] %s returning to parking '%s' (%.0fm away)",
+ employee.name, spot.name, dist)
+ self:startReturnToParking(employee, vehicle, spot)
+ return
end
end
end
@@ -447,28 +986,17 @@ function JobManager:handleParkingArrival(employee)
end
end
+ -- Clear origin snapshot now that we've arrived
+ if g_snapshotManager then
+ g_snapshotManager:clearSnapshot(employee.id)
+ end
+
employee.currentJob = nil
if g_employeeManager then
g_employeeManager:onJobCompleted(employee)
end
end
-function JobManager:findToolInParking(categoryName, vehicle)
- if not g_parkingManager then return nil end
-
- local tool, spot = g_parkingManager:findToolInParking(categoryName)
- if tool and spot and vehicle and vehicle.rootNode then
- local vx, _, vz = getWorldTranslation(vehicle.rootNode)
- local dx = vx - spot.x
- local dz = vz - spot.z
- local dist = math.sqrt(dx * dx + dz * dz)
-
- if dist < 50 then
- return tool
- end
- end
- return nil
-end
function JobManager:update(dt)
for _, employee in ipairs(g_employeeManager.employees) do
@@ -494,14 +1022,62 @@ function JobManager:update(dt)
CustomUtils:info("[JobManager] Job %d for employee %s finished or removed", employee.currentJob.aiJobId, employee.name)
if employee.currentJob.type == "TRANSIT" and employee.pendingJob then
- CustomUtils:info("[JobManager] Transit complete. Starting pending fieldwork...")
- local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ CustomUtils:info("[JobManager] Transit complete. Deferring fieldwork start via EQUIPMENT_READY...")
local pending = employee.pendingJob
- self:startFieldWorkJob(employee, vehicle, pending.fieldId, pending.workType)
+ employee.pendingJob = nil
+ employee.currentJob = {
+ type = "EQUIPMENT_READY",
+ fieldId = pending.fieldId,
+ workType = pending.workType,
+ readyFrame = 0,
+ readyTime = g_currentMission.time + 500
+ }
+ elseif employee.currentJob.type == "DRIVING_TO_TOOL" then
+ -- GOTO completed — try direct attach or teleport (no alignment step)
+ CustomUtils:info("[JobManager] %s arrived near tool. Attempting attach or teleport...", employee.name)
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ local tool = g_employeeManager:getVehicleById(employee.currentJob.targetToolId)
+ if vehicle and tool then
+ self:tryDirectAttachOrTeleport(employee, vehicle, tool, employee.currentJob.fieldId, employee.currentJob.workType)
+ else
+ CustomUtils:error("[JobManager] Vehicle or tool lost after DRIVING_TO_TOOL for %s", employee.name)
+ employee.currentJob = nil
+ end
+ elseif employee.currentJob.type == "RETURNING_TOOL" then
+ -- GOTO to return position completed — detach tool and start next task
+ CustomUtils:info("[JobManager] %s arrived at drop-off point. Detaching tool...", employee.name)
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ if vehicle then
+ self:detachAllImplements(vehicle)
+ end
+ local nextFieldId = employee.currentJob.nextFieldId
+ local nextWorkType = employee.currentJob.nextWorkType
+ employee.currentJob = nil
+ self:startFieldWork(employee, nextFieldId, nextWorkType)
elseif employee.currentJob.type == "RETURN_TO_PARKING" then
self:handleParkingArrival(employee)
elseif employee.currentJob.type == "FIELDWORK" then
- self:handleFieldworkCompletion(employee)
+ -- Rapid-finish guard: detect instant completions (field already done)
+ local elapsed = g_currentMission.time - (employee.currentJob.startTime or 0)
+ if elapsed < 2000 then
+ employee.rapidFailCount = (employee.rapidFailCount or 0) + 1
+ CustomUtils:warning("[JobManager] %s: FIELDWORK finished instantly (%dms). Rapid fail #%d",
+ employee.name, elapsed, employee.rapidFailCount)
+ if employee.rapidFailCount >= 3 then
+ CustomUtils:warning("[JobManager] %s: Too many rapid fails. Pausing autonomous mode.", employee.name)
+ employee.isAutonomous = false
+ employee.rapidFailCount = 0
+ employee.currentJob = nil
+ if employee.temporaryRental then
+ g_employeeManager:returnRentedEquipment(employee)
+ end
+ else
+ self:handleFieldworkCompletion(employee)
+ end
+ else
+ employee.rapidFailCount = 0
+ self:handleFieldworkCompletion(employee)
+ end
else
employee.currentJob = nil
if g_employeeManager then
@@ -512,6 +1088,105 @@ function JobManager:update(dt)
end
end
end
+ elseif employee.currentJob and employee.currentJob.type == "EQUIPMENT_READY" then
+ local job = employee.currentJob
+ job.readyFrame = (job.readyFrame or 0) + 1
+
+ if job.readyFrame >= 3 and g_currentMission.time >= (job.readyTime or 0) then
+ CustomUtils:info("[JobManager] EQUIPMENT_READY matured for %s (frames: %d). Evaluating distance...",
+ employee.name, job.readyFrame)
+
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ local field = g_fieldManager:getFieldById(job.fieldId)
+
+ if vehicle and field then
+ local x, z = field:getCenterOfFieldWorldPosition()
+ local vx, _, vz = getWorldTranslation(vehicle.rootNode)
+ local distance = MathUtil.vector2Length(vx - x, vz - z)
+
+ CustomUtils:info("[JobManager] Distance to Field %d: %.1f m", job.fieldId, distance)
+
+ if distance > 150 then
+ CustomUtils:info("[JobManager] Field is far. Starting TRANSIT (GOTO) job.")
+ local aiJob = g_currentMission.aiJobTypeManager:createJob(AIJobType.GOTO)
+ if aiJob then
+ local farmId = g_currentMission:getFarmId()
+ aiJob:applyCurrentState(vehicle, g_currentMission, farmId, false)
+ aiJob.positionAngleParameter:setPosition(x, z)
+
+ local dx, dz = x - vx, z - vz
+ local angle = MathUtil.getYRotationFromDirection(dx, dz)
+ aiJob.positionAngleParameter:setAngle(angle)
+ aiJob:setValues()
+
+ local validateSuccess, errorMessage = aiJob:validate(farmId)
+ if validateSuccess then
+ g_currentMission.aiSystem:startJob(aiJob, farmId)
+ employee.currentJob = {
+ aiJobId = aiJob.jobId,
+ type = "TRANSIT",
+ fieldId = job.fieldId,
+ workType = job.workType,
+ startTime = g_currentMission.time
+ }
+ employee.pendingJob = {
+ fieldId = job.fieldId,
+ workType = job.workType
+ }
+ CustomUtils:info("[JobManager] %s is now in TRANSIT to field %d", employee.name, job.fieldId)
+ else
+ CustomUtils:error("[JobManager] Transit GOTO failed validation: %s", tostring(errorMessage))
+ employee.currentJob = nil
+ end
+ else
+ CustomUtils:error("[JobManager] Failed to create GOTO job")
+ employee.currentJob = nil
+ end
+ else
+ CustomUtils:info("[JobManager] Starting FIELDWORK for %s (close proximity).", employee.name)
+ self:startFieldWorkJob(employee, vehicle, job.fieldId, job.workType)
+ end
+ else
+ CustomUtils:error("[JobManager] EQUIPMENT_READY: vehicle or field not found for %s", employee.name)
+ employee.currentJob = nil
+ end
+ else
+ -- Debug logging for EQUIPMENT_READY wait
+ employee.debugTimer = (employee.debugTimer or 0) + dt
+ if employee.debugTimer > 5000 then
+ employee.debugTimer = 0
+ CustomUtils:debug("[JobMonitor] %s: EQUIPMENT_READY (frame %d, waiting for stabilization)...",
+ employee.name, job.readyFrame or 0)
+ end
+ end
+ elseif employee.currentJob and employee.currentJob.type == "ATTACHING_TOOL" then
+ -- Wait for attachment animation to complete
+ local vehicle = g_employeeManager:getVehicleById(employee.assignedVehicleId)
+ if vehicle then
+ local allDone = true
+ local implements = vehicle:getAttachedImplements()
+ for _, impl in ipairs(implements) do
+ if impl.attachingIsInProgress then
+ allDone = false
+ end
+ end
+
+ if allDone then
+ local job = employee.currentJob
+ CustomUtils:info("[JobManager] %s: Tool attachment complete! Proceeding to EQUIPMENT_READY.",
+ employee.name)
+ employee.currentJob = {
+ type = "EQUIPMENT_READY",
+ fieldId = job.fieldId,
+ workType = job.workType,
+ readyFrame = 0,
+ readyTime = g_currentMission.time + 1000
+ }
+ end
+ else
+ CustomUtils:error("[JobManager] ATTACHING_TOOL: vehicle lost for %s", employee.name)
+ employee.currentJob = nil
+ end
elseif employee.currentJob and employee.currentJob.type == "PREPARING" then
employee.debugTimer = (employee.debugTimer or 0) + dt
if employee.debugTimer > 5000 then
diff --git a/scripts/modcontroller.lua b/scripts/modcontroller.lua
index bdc4927..f7c7893 100644
--- a/scripts/modcontroller.lua
+++ b/scripts/modcontroller.lua
@@ -29,9 +29,10 @@ function ModController:loadMap(name, itemSystem, missionInfo, missionDynamicInfo
g_employeeManager = EmployeeManager:new(g_currentMission)
g_parkingManager = ParkingManager:new()
+ g_snapshotManager = VehicleSnapshotManager:new()
g_persistenceManager = PersistenceManager:new()
- g_persistenceManager:addStrategy(DBAPIPersistence:new())
+ g_persistenceManager:addStrategy(SILODBPersistence:new())
g_persistenceManager:addStrategy(XMLPersistence:new())
g_persistenceManager:selectStrategy()
@@ -69,7 +70,7 @@ function ModController:loadMap(name, itemSystem, missionInfo, missionDynamicInfo
g_employeeManager:generateInitialPool(10)
end
else
- CustomUtils:info("[ModController] Load deferred — DBAPI may not be ready yet")
+ CustomUtils:info("[ModController] Load deferred — SILODB may not be ready yet")
end
if rawget(_G, 'g_modGui') ~= nil then
@@ -86,14 +87,14 @@ end
function ModController:saveEmployees()
if g_persistenceManager == nil or g_employeeManager == nil then return end
- g_persistenceManager:save(g_employeeManager, g_parkingManager)
+ g_persistenceManager:save(g_employeeManager, g_parkingManager, g_snapshotManager)
end
function ModController:loadEmployees(savegameDir)
if g_persistenceManager == nil or g_employeeManager == nil then
return false
end
- return g_persistenceManager:load(g_employeeManager, g_parkingManager)
+ return g_persistenceManager:load(g_employeeManager, g_parkingManager, g_snapshotManager)
end
function ModController:deleteMap()
@@ -107,6 +108,7 @@ function ModController:deleteMap()
g_employeeManager = nil
end
g_parkingManager = nil
+ g_snapshotManager = nil
g_persistenceManager = nil
end
@@ -114,7 +116,7 @@ function ModController:update(dt)
if not self.deferredLoadAttempted and not self.dataLoaded then
self.deferredLoadAttempted = true
g_persistenceManager:selectStrategy()
- self.dataLoaded = g_persistenceManager:load(g_employeeManager, g_parkingManager)
+ self.dataLoaded = g_persistenceManager:load(g_employeeManager, g_parkingManager, g_snapshotManager)
if not self.dataLoaded or #g_employeeManager.employees == 0 then
CustomUtils:info("[ModController] Deferred load: no data, generating pool")
g_employeeManager:generateInitialPool(10)
diff --git a/scripts/persistence/DBAPIPersistence.lua b/scripts/persistence/DBAPIPersistence.lua
deleted file mode 100644
index d4efdfd..0000000
--- a/scripts/persistence/DBAPIPersistence.lua
+++ /dev/null
@@ -1,155 +0,0 @@
-DBAPIPersistence = {}
-DBAPIPersistence.__index = DBAPIPersistence
-DBAPIPersistence.NAMESPACE = "FS25_EmployeeManager"
-
-function DBAPIPersistence:new()
- return setmetatable({}, self)
-end
-
-function DBAPIPersistence:getName()
- return "DBAPI"
-end
-
-function DBAPIPersistence:getAPI()
- if g_globalMods then
- return g_globalMods["FS25_DBAPI"]
- end
- return nil
-end
-
-function DBAPIPersistence:isAvailable()
- if g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
- CustomUtils:debug("[DBAPIPersistence] Multiplayer detected, not available")
- return false
- end
-
- local api = self:getAPI()
- if api == nil then
- CustomUtils:debug("[DBAPIPersistence] g_globalMods['FS25_DBAPI'] not found")
- return false
- end
-
- if not api.isReady() then
- CustomUtils:debug("[DBAPIPersistence] DBAPI present but not ready")
- return false
- end
-
- return true
-end
-
-function DBAPIPersistence:save(employeeManager, parkingManager)
- local api = self:getAPI()
- if api == nil then
- CustomUtils:error("[DBAPIPersistence] DBAPI not available for save")
- return false
- end
-
- local ns = self.NAMESPACE
-
- local employeeIds = {}
- for _, e in ipairs(employeeManager.employees) do
- table.insert(employeeIds, e.id)
- CustomUtils:debug("[DBAPIPersistence] Saving employee_%d (%s) isHired=%s vehicle=%s", e.id, e.name, tostring(e.isHired), tostring(e.assignedVehicleId))
- local data = e:toTable()
- local success, err = api.setValue(ns, "employee_" .. tostring(e.id), data)
- CustomUtils:debug("[DBAPIPersistence] setValue employee_%d => success=%s err=%s", e.id, tostring(success), tostring(err))
- if not success then
- CustomUtils:error("[DBAPIPersistence] Failed to save employee %d: %s", e.id, tostring(err))
- return false
- end
- end
-
- local success, err
- success, err = api.setValue(ns, "meta_employeeIds", employeeIds)
- CustomUtils:debug("[DBAPIPersistence] setValue meta_employeeIds => success=%s err=%s", tostring(success), tostring(err))
- if not success then
- CustomUtils:error("[DBAPIPersistence] Failed to save meta_employeeIds: %s", tostring(err))
- return false
- end
-
- api.setValue(ns, "meta_nextEmployeeId", employeeManager.nextEmployeeId)
- api.setValue(ns, "meta_lastPoolRefreshDay", employeeManager.lastPoolRefreshDay or 0)
- api.setValue(ns, "meta_lastPaymentPeriod", employeeManager.lastPaymentPeriod or 0)
-
- if parkingManager then
- local parkingData = {
- spots = parkingManager.spots,
- nextSpotId = parkingManager.nextSpotId,
- }
- api.setValue(ns, "parking", parkingData)
- end
-
- local hiredCount = 0
- for _, e in ipairs(employeeManager.employees) do
- if e.isHired then hiredCount = hiredCount + 1 end
- end
- CustomUtils:info("[DBAPIPersistence] Saved %d employees (%d hired) via DBAPI", #employeeManager.employees, hiredCount)
- return true
-end
-
-function DBAPIPersistence:load(employeeManager, parkingManager)
- local api = self:getAPI()
- if api == nil then
- CustomUtils:error("[DBAPIPersistence] DBAPI not available for load")
- return false
- end
-
- local ns = self.NAMESPACE
-
- local employeeIds = api.getValue(ns, "meta_employeeIds")
- if employeeIds == nil or type(employeeIds) ~= "table" or #employeeIds == 0 then
- CustomUtils:info("[DBAPIPersistence] No employee data found in DBAPI")
- return false
- end
-
- employeeManager.employees = {}
- local maxId = 0
- for _, id in ipairs(employeeIds) do
- local data = api.getValue(ns, "employee_" .. tostring(id))
- if data ~= nil then
- local emp = Employee.fromTable(data)
- if emp ~= nil then
- if emp.assignedVehicleId and emp.assignedVehicleId ~= 0 then
- local vehicle = employeeManager:getVehicleById(emp.assignedVehicleId)
- if vehicle then
- emp:assignVehicle(vehicle)
- end
- end
- table.insert(employeeManager.employees, emp)
- if emp.id > maxId then maxId = emp.id end
- end
- end
- end
-
- local nextId = api.getValue(ns, "meta_nextEmployeeId")
- employeeManager.nextEmployeeId = nextId or (maxId + 1)
- if employeeManager.nextEmployeeId <= maxId then
- employeeManager.nextEmployeeId = maxId + 1
- end
-
- employeeManager.lastPoolRefreshDay = api.getValue(ns, "meta_lastPoolRefreshDay") or 0
- employeeManager.lastPaymentPeriod = api.getValue(ns, "meta_lastPaymentPeriod") or 0
-
- if parkingManager then
- local parkingData = api.getValue(ns, "parking")
- if parkingData and type(parkingData) == "table" then
- parkingManager.spots = parkingData.spots or {}
- parkingManager.nextSpotId = parkingData.nextSpotId or 1
- CustomUtils:info("[DBAPIPersistence] Loaded %d parking spots", #parkingManager.spots)
- end
- end
-
- local hiredCount = 0
- for _, e in ipairs(employeeManager.employees) do
- if e.isHired then hiredCount = hiredCount + 1 end
- end
- CustomUtils:info("[DBAPIPersistence] Loaded %d employees (%d hired) via DBAPI", #employeeManager.employees, hiredCount)
-
- local numToGenerate = 10 - #employeeManager.employees
- if numToGenerate > 0 then
- CustomUtils:info("[DBAPIPersistence] Filling pool: generating %d candidates", numToGenerate)
- employeeManager:generateInitialPool(numToGenerate)
- end
-
- return #employeeManager.employees > 0
-end
diff --git a/scripts/persistence/PersistenceManager.lua b/scripts/persistence/PersistenceManager.lua
index 1d9b8e2..0eb7566 100644
--- a/scripts/persistence/PersistenceManager.lua
+++ b/scripts/persistence/PersistenceManager.lua
@@ -29,7 +29,7 @@ function PersistenceManager:selectStrategy()
return nil
end
-function PersistenceManager:save(employeeManager, parkingManager)
+function PersistenceManager:save(employeeManager, parkingManager, snapshotManager)
if employeeManager == nil then
CustomUtils:warning("[PersistenceManager] save() called with nil employeeManager")
return false
@@ -49,7 +49,7 @@ function PersistenceManager:save(employeeManager, parkingManager)
CustomUtils:info("[PersistenceManager] Saving with %s...", strategy:getName())
local ok, result = pcall(function()
- return strategy:save(employeeManager, parkingManager)
+ return strategy:save(employeeManager, parkingManager, snapshotManager)
end)
if ok and result then
@@ -66,7 +66,7 @@ function PersistenceManager:save(employeeManager, parkingManager)
return false
end
-function PersistenceManager:load(employeeManager, parkingManager)
+function PersistenceManager:load(employeeManager, parkingManager, snapshotManager)
if employeeManager == nil then
CustomUtils:warning("[PersistenceManager] load() called with nil employeeManager")
return false
@@ -86,7 +86,7 @@ function PersistenceManager:load(employeeManager, parkingManager)
CustomUtils:info("[PersistenceManager] Loading with %s...", strategy:getName())
local ok, result = pcall(function()
- return strategy:load(employeeManager, parkingManager)
+ return strategy:load(employeeManager, parkingManager, snapshotManager)
end)
if ok and result then
diff --git a/scripts/persistence/PersistenceStrategy.lua b/scripts/persistence/PersistenceStrategy.lua
index 3fa6e4e..193e7fc 100644
--- a/scripts/persistence/PersistenceStrategy.lua
+++ b/scripts/persistence/PersistenceStrategy.lua
@@ -13,12 +13,12 @@ function PersistenceStrategy:isAvailable()
return false
end
-function PersistenceStrategy:save(employeeManager, parkingManager)
+function PersistenceStrategy:save(employeeManager, parkingManager, snapshotManager)
CustomUtils:warning("[PersistenceStrategy] save() not implemented for %s", self:getName())
return false
end
-function PersistenceStrategy:load(employeeManager, parkingManager)
+function PersistenceStrategy:load(employeeManager, parkingManager, snapshotManager)
CustomUtils:warning("[PersistenceStrategy] load() not implemented for %s", self:getName())
return false
end
diff --git a/scripts/persistence/SILODBPersistence.lua b/scripts/persistence/SILODBPersistence.lua
new file mode 100644
index 0000000..3d6c155
--- /dev/null
+++ b/scripts/persistence/SILODBPersistence.lua
@@ -0,0 +1,291 @@
+SILODBPersistence = {}
+SILODBPersistence.__index = SILODBPersistence
+SILODBPersistence.NAMESPACE = "FS25_EmployeeManager"
+SILODBPersistence.CURRENT_VERSION = 1
+
+function SILODBPersistence:new()
+ local self = setmetatable({}, SILODBPersistence)
+ self.db = nil
+ return self
+end
+
+function SILODBPersistence:getName()
+ return "SILODB"
+end
+
+function SILODBPersistence:getAPI()
+ if g_globalMods then
+ return g_globalMods["FS25_SILODB"]
+ end
+ return nil
+end
+
+function SILODBPersistence:getDb()
+ if self.db then return self.db end
+
+ local api = self:getAPI()
+ if api and api.isReady() and api.hasORM and api.hasORM() then
+ local db = api.bind(self.NAMESPACE)
+ if db then
+ self:initModels(db)
+ self:migrate(db)
+ self.db = db
+ return db
+ end
+ end
+ return nil
+end
+
+function SILODBPersistence:initModels(db)
+ -- Define Employee model
+ local _, err = db:define("Employee", {
+ fields = {
+ data = { type = "table", required = true }
+ }
+ })
+ if err then CustomUtils:error("[SILODBPersistence] Error defining Employee model: %s", tostring(err)) end
+
+ -- Define Parking model
+ _, err = db:define("Parking", {
+ fields = {
+ spots = { type = "table", required = true },
+ nextSpotId = { type = "number", default = 1 }
+ }
+ })
+ if err then CustomUtils:error("[SILODBPersistence] Error defining Parking model: %s", tostring(err)) end
+
+ -- Define Settings model
+ _, err = db:define("Settings", {
+ fields = {
+ version = { type = "number", default = 1 },
+ nextEmployeeId = { type = "number", default = 1 },
+ lastPoolRefreshDay = { type = "number", default = 0 },
+ lastPaymentPeriod = { type = "number", default = 0 }
+ }
+ })
+ if err then CustomUtils:error("[SILODBPersistence] Error defining Settings model: %s", tostring(err)) end
+
+ -- Define VehicleSnapshot model
+ _, err = db:define("VehicleSnapshot", {
+ fields = {
+ snapshots = { type = "table", required = true }
+ }
+ })
+ if err then CustomUtils:error("[SILODBPersistence] Error defining VehicleSnapshot model: %s", tostring(err)) end
+
+ -- Define FieldConfig model
+ _, err = db:define("FieldConfig", {
+ fields = {
+ fieldId = { type = "number", required = true },
+ cropName = { type = "string", required = true }
+ }
+ })
+ if err then CustomUtils:error("[SILODBPersistence] Error defining FieldConfig model: %s", tostring(err)) end
+end
+
+function SILODBPersistence:migrate(db)
+ local settings, _ = db:find("Settings")
+ local version = settings and settings.version or 0
+
+ if version < self.CURRENT_VERSION then
+ CustomUtils:info("[SILODBPersistence] Migrating database from version %d to %d", version, self.CURRENT_VERSION)
+
+ -- Add migration logic here when CURRENT_VERSION increases
+ -- Example: if version < 1 then ... end
+
+ if settings then
+ db:update("Settings", settings.id, { version = self.CURRENT_VERSION })
+ else
+ db:create("Settings", { version = self.CURRENT_VERSION })
+ end
+ end
+end
+
+function SILODBPersistence:isAvailable()
+ if g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
+ return false
+ end
+
+ local api = self:getAPI()
+ if api == nil or not api.isReady() then
+ return false
+ end
+
+ if not api.hasORM or not api.hasORM() then
+ CustomUtils:warning("[SILODBPersistence] SILODB version too old (no ORM support)")
+ return false
+ end
+
+ return true
+end
+
+function SILODBPersistence:save(employeeManager, parkingManager, snapshotManager)
+ local db = self:getDb()
+ if not db then
+ CustomUtils:error("[SILODBPersistence] SILODB ORM not available for save")
+ return false
+ end
+
+ -- 1. Save Settings (singleton)
+ local settings = {
+ nextEmployeeId = employeeManager.nextEmployeeId or 1,
+ lastPoolRefreshDay = employeeManager.lastPoolRefreshDay or 0,
+ lastPaymentPeriod = employeeManager.lastPaymentPeriod or 0
+ }
+ local sRec, _ = db:find("Settings")
+ if sRec then
+ db:update("Settings", sRec.id, settings)
+ else
+ db:create("Settings", settings)
+ end
+
+ -- 2. Save Parking (singleton)
+ if parkingManager then
+ local pData = {
+ spots = parkingManager.spots or {},
+ nextSpotId = parkingManager.nextSpotId or 1
+ }
+ local pRec, _ = db:find("Parking")
+ if pRec then
+ db:update("Parking", pRec.id, pData)
+ else
+ db:create("Parking", pData)
+ end
+ end
+
+ -- 3. Save Employees
+ -- Clear existing records to avoid duplicates and bloat
+ local existing, _ = db:findAll("Employee")
+ if existing then
+ for _, rec in ipairs(existing) do
+ db:delete("Employee", rec.id)
+ end
+ end
+
+ local count = 0
+ for _, e in ipairs(employeeManager.employees) do
+ local _, err = db:create("Employee", { data = e:toTable() })
+ if not err then
+ count = count + 1
+ else
+ CustomUtils:error("[SILODBPersistence] Failed to save employee %d: %s", e.id, tostring(err))
+ end
+ end
+
+ -- 4. Save Field Configs
+ local existingFC, _ = db:findAll("FieldConfig")
+ if existingFC then
+ for _, rec in ipairs(existingFC) do
+ db:delete("FieldConfig", rec.id)
+ end
+ end
+
+ local fcCount = 0
+ for fieldId, config in pairs(employeeManager.fieldConfigs or {}) do
+ local _, err = db:create("FieldConfig", { fieldId = fieldId, cropName = config.cropName or "" })
+ if not err then
+ fcCount = fcCount + 1
+ end
+ end
+
+ -- 5. Save Vehicle Snapshots
+ if snapshotManager then
+ local snapData = snapshotManager:toTable()
+ local snapRec, _ = db:find("VehicleSnapshot")
+ if snapRec then
+ db:update("VehicleSnapshot", snapRec.id, { snapshots = snapData })
+ else
+ db:create("VehicleSnapshot", { snapshots = snapData })
+ end
+ end
+
+ CustomUtils:info("[SILODBPersistence] Saved %d employees and %d field configs via SILODB ORM", count, fcCount)
+ return true
+end
+
+function SILODBPersistence:load(employeeManager, parkingManager, snapshotManager)
+ local db = self:getDb()
+ if not db then
+ CustomUtils:error("[SILODBPersistence] SILODB ORM not available for load")
+ return false
+ end
+
+ -- 1. Load Settings
+ local sRec, _ = db:find("Settings")
+ if sRec then
+ employeeManager.nextEmployeeId = sRec.nextEmployeeId or 1
+ employeeManager.lastPoolRefreshDay = sRec.lastPoolRefreshDay or 0
+ employeeManager.lastPaymentPeriod = sRec.lastPaymentPeriod or 0
+ end
+
+ -- 2. Load Parking
+ if parkingManager then
+ local pRec, _ = db:find("Parking")
+ if pRec then
+ parkingManager.spots = pRec.spots or {}
+ parkingManager.nextSpotId = pRec.nextSpotId or 1
+ CustomUtils:debug("[SILODBPersistence] Loaded %d parking spots", #parkingManager.spots)
+ end
+ end
+
+ -- 3. Load Employees
+ local emps, _ = db:findAll("Employee")
+ if emps and #emps > 0 then
+ employeeManager.employees = {}
+ local maxId = 0
+ local hiredCount = 0
+ for _, rec in ipairs(emps) do
+ if rec.data then
+ local emp = Employee.fromTable(rec.data)
+ if emp then
+ -- Re-assign vehicle if needed
+ if emp.assignedVehicleId and emp.assignedVehicleId ~= 0 then
+ local vehicle = employeeManager:getVehicleById(emp.assignedVehicleId)
+ if vehicle then
+ emp:assignVehicle(vehicle)
+ end
+ end
+ table.insert(employeeManager.employees, emp)
+ if emp.id > maxId then maxId = emp.id end
+ if emp.isHired then hiredCount = hiredCount + 1 end
+ end
+ end
+ end
+
+ -- Sync nextEmployeeId if needed
+ if employeeManager.nextEmployeeId <= maxId then
+ employeeManager.nextEmployeeId = maxId + 1
+ end
+
+ CustomUtils:info("[SILODBPersistence] Loaded %d employees (%d hired) via SILODB ORM", #employeeManager.employees, hiredCount)
+ else
+ CustomUtils:info("[SILODBPersistence] No employee data found in SILODB ORM")
+ end
+
+ -- 4. Load Field Configs
+ local fcs, _ = db:findAll("FieldConfig")
+ employeeManager.fieldConfigs = {}
+ if fcs then
+ for _, rec in ipairs(fcs) do
+ employeeManager.fieldConfigs[rec.fieldId] = { cropName = rec.cropName }
+ end
+ CustomUtils:debug("[SILODBPersistence] Loaded %d field configs", #fcs)
+ end
+
+ -- 5. Load Vehicle Snapshots
+ if snapshotManager then
+ local snapRec, _ = db:find("VehicleSnapshot")
+ if snapRec and snapRec.snapshots then
+ snapshotManager:fromTable(snapRec.snapshots)
+ end
+ end
+
+ -- Fill pool if empty or low
+ local numToGenerate = 10 - #employeeManager.employees
+ if numToGenerate > 0 then
+ CustomUtils:info("[SILODBPersistence] Filling pool: generating %d candidates", numToGenerate)
+ employeeManager:generateInitialPool(numToGenerate)
+ end
+
+ return #employeeManager.employees > 0
+end
diff --git a/scripts/persistence/XMLPersistence.lua b/scripts/persistence/XMLPersistence.lua
index e91700f..1fa5e6f 100644
--- a/scripts/persistence/XMLPersistence.lua
+++ b/scripts/persistence/XMLPersistence.lua
@@ -20,7 +20,7 @@ function XMLPersistence:getSavegameDirectory()
return nil
end
-function XMLPersistence:save(employeeManager, parkingManager)
+function XMLPersistence:save(employeeManager, parkingManager, snapshotManager)
local dir = self:getSavegameDirectory()
if dir == nil then
CustomUtils:warning("[XMLPersistence] No savegame directory, cannot save")
@@ -35,6 +35,44 @@ function XMLPersistence:save(employeeManager, parkingManager)
end
employeeManager:saveToXMLFile(xmlFile, "employeeManager")
+
+ -- Save Field Configs
+ local fieldKey = "employeeManager.fieldConfigs"
+ local fIdx = 0
+ for fieldId, config in pairs(employeeManager.fieldConfigs or {}) do
+ local base = string.format("%s.fieldConfig(%d)", fieldKey, fIdx)
+ setXMLInt(xmlFile, base .. "#fieldId", fieldId)
+ setXMLString(xmlFile, base .. "#cropName", config.cropName or "")
+ fIdx = fIdx + 1
+ end
+
+ -- Save Vehicle Snapshots
+ if snapshotManager then
+ local snapData = snapshotManager:toTable()
+ local sIdx = 0
+ for empIdStr, snap in pairs(snapData) do
+ local base = string.format("employeeManager.vehicleSnapshots.snapshot(%d)", sIdx)
+ setXMLString(xmlFile, base .. "#employeeId", empIdStr)
+ setXMLFloat(xmlFile, base .. ".vehicle#id", snap.vehicle.id or 0)
+ setXMLString(xmlFile, base .. ".vehicle#name", snap.vehicle.name or "")
+ setXMLFloat(xmlFile, base .. ".vehicle#x", snap.vehicle.x)
+ setXMLFloat(xmlFile, base .. ".vehicle#y", snap.vehicle.y)
+ setXMLFloat(xmlFile, base .. ".vehicle#z", snap.vehicle.z)
+ setXMLFloat(xmlFile, base .. ".vehicle#angle", snap.vehicle.angle or 0)
+
+ for tIdx, tool in ipairs(snap.tools or {}) do
+ local tBase = string.format("%s.tool(%d)", base, tIdx - 1)
+ setXMLFloat(xmlFile, tBase .. "#id", tool.id or 0)
+ setXMLString(xmlFile, tBase .. "#name", tool.name or "")
+ setXMLFloat(xmlFile, tBase .. "#x", tool.x)
+ setXMLFloat(xmlFile, tBase .. "#y", tool.y)
+ setXMLFloat(xmlFile, tBase .. "#z", tool.z)
+ setXMLFloat(xmlFile, tBase .. "#angle", tool.angle or 0)
+ end
+ sIdx = sIdx + 1
+ end
+ end
+
saveXMLFile(xmlFile)
delete(xmlFile)
@@ -46,7 +84,7 @@ function XMLPersistence:save(employeeManager, parkingManager)
return true
end
-function XMLPersistence:load(employeeManager, parkingManager)
+function XMLPersistence:load(employeeManager, parkingManager, snapshotManager)
local dir = self:getSavegameDirectory()
if dir == nil then
CustomUtils:warning("[XMLPersistence] No savegame directory available for loading")
@@ -67,6 +105,65 @@ function XMLPersistence:load(employeeManager, parkingManager)
end
employeeManager:loadFromXMLFile(xmlFile, "employeeManager")
+
+ -- Load Field Configs
+ employeeManager.fieldConfigs = {}
+ local fieldKey = "employeeManager.fieldConfigs"
+ local fIdx = 0
+ while true do
+ local base = string.format("%s.fieldConfig(%d)", fieldKey, fIdx)
+ local fId = getXMLInt(xmlFile, base .. "#fieldId")
+ if not fId then break end
+ local cName = getXMLString(xmlFile, base .. "#cropName")
+ employeeManager.fieldConfigs[fId] = { cropName = cName }
+ fIdx = fIdx + 1
+ end
+
+ -- Load Vehicle Snapshots
+ if snapshotManager then
+ local snapData = {}
+ local sIdx = 0
+ while true do
+ local base = string.format("employeeManager.vehicleSnapshots.snapshot(%d)", sIdx)
+ local empIdStr = getXMLString(xmlFile, base .. "#employeeId")
+ if not empIdStr then break end
+
+ local snap = {
+ employeeId = tonumber(empIdStr),
+ timestamp = 0,
+ vehicle = {
+ id = getXMLFloat(xmlFile, base .. ".vehicle#id") or 0,
+ name = getXMLString(xmlFile, base .. ".vehicle#name") or "",
+ x = getXMLFloat(xmlFile, base .. ".vehicle#x") or 0,
+ y = getXMLFloat(xmlFile, base .. ".vehicle#y") or 0,
+ z = getXMLFloat(xmlFile, base .. ".vehicle#z") or 0,
+ angle = getXMLFloat(xmlFile, base .. ".vehicle#angle") or 0
+ },
+ tools = {}
+ }
+
+ local tIdx = 0
+ while true do
+ local tBase = string.format("%s.tool(%d)", base, tIdx)
+ local tId = getXMLFloat(xmlFile, tBase .. "#id")
+ if not tId then break end
+ table.insert(snap.tools, {
+ id = tId,
+ name = getXMLString(xmlFile, tBase .. "#name") or "",
+ x = getXMLFloat(xmlFile, tBase .. "#x") or 0,
+ y = getXMLFloat(xmlFile, tBase .. "#y") or 0,
+ z = getXMLFloat(xmlFile, tBase .. "#z") or 0,
+ angle = getXMLFloat(xmlFile, tBase .. "#angle") or 0
+ })
+ tIdx = tIdx + 1
+ end
+
+ snapData[empIdStr] = snap
+ sIdx = sIdx + 1
+ end
+ snapshotManager:fromTable(snapData)
+ end
+
delete(xmlFile)
local hiredCount = 0
diff --git a/scripts/types/employee.lua b/scripts/types/employee.lua
index e470f8f..4a8d202 100644
--- a/scripts/types/employee.lua
+++ b/scripts/types/employee.lua
@@ -24,6 +24,7 @@ function Employee.new(id, name, skills)
self.isRenting = false
self.isAutonomous = false
self.taskQueue = {}
+ self.currentTaskIndex = 1
self.shiftStart = 6
self.shiftEnd = 18
@@ -203,6 +204,7 @@ function Employee:toTable()
targetFieldId = self.targetFieldId,
isAutonomous = self.isAutonomous,
taskQueue = self.taskQueue,
+ currentTaskIndex = self.currentTaskIndex,
shiftStart = self.shiftStart,
shiftEnd = self.shiftEnd,
traits = self.traits,
@@ -243,6 +245,7 @@ function Employee.fromTable(data)
e.isRenting = data.isRenting
e.assignedVehicleId = data.assignedVehicleId
e.taskQueue = data.taskQueue or {}
+ e.currentTaskIndex = data.currentTaskIndex or 1
e.shiftStart = data.shiftStart or 6
e.shiftEnd = data.shiftEnd or 18
if data.traits and type(data.traits) == "table" then
@@ -283,8 +286,9 @@ function Employee:writeStream(streamId, connection)
end
streamWriteInt32(streamId, self.shiftStart or 6)
streamWriteInt32(streamId, self.shiftEnd or 18)
+ streamWriteInt32(streamId, self.currentTaskIndex or 1)
- streamWriteInt8(streamId, 3)
+ streamWriteInt8(streamId, 4)
local traitsStr = TraitSystem.serialize(self.traits)
streamWriteString(streamId, traitsStr)
@@ -306,6 +310,9 @@ function Employee:writeStream(streamId, connection)
streamWriteFloat32(streamId, self.milestoneWageMult or 1.0)
+ -- v4: autonomous state
+ streamWriteBool(streamId, self.isAutonomous or false)
+
-- v3: personal info
streamWriteInt8(streamId, self.age or 30)
streamWriteString(streamId, self.nationality or "FR")
@@ -333,6 +340,7 @@ function Employee:readStream(streamId, connection)
end
self.shiftStart = streamReadInt32(streamId)
self.shiftEnd = streamReadInt32(streamId)
+ self.currentTaskIndex = streamReadInt32(streamId)
local streamVersion = streamReadInt8(streamId)
if streamVersion >= 2 then
@@ -365,6 +373,12 @@ function Employee:readStream(streamId, connection)
self.milestoneWageMult = 1.0
end
+ if streamVersion >= 4 then
+ self.isAutonomous = streamReadBool(streamId)
+ else
+ self.isAutonomous = false
+ end
+
if streamVersion >= 3 then
self.age = streamReadInt8(streamId)
self.nationality = streamReadString(streamId)
diff --git a/textures/ui_elements.xml b/textures/ui_elements.xml
index 9e4f2e1..bda2677 100644
--- a/textures/ui_elements.xml
+++ b/textures/ui_elements.xml
@@ -1,4 +1,8 @@
-
-
\ No newline at end of file
+
+
+
+
+
+
diff --git a/xml/gui/EMEmployeeFrame.xml b/xml/gui/EMEmployeeFrame.xml
index 60a52f0..9bbaa5a 100644
--- a/xml/gui/EMEmployeeFrame.xml
+++ b/xml/gui/EMEmployeeFrame.xml
@@ -2,7 +2,9 @@
-
+
+
+
diff --git a/xml/gui/EMFieldFrame.xml b/xml/gui/EMFieldFrame.xml
index 7b89737..bd43846 100644
--- a/xml/gui/EMFieldFrame.xml
+++ b/xml/gui/EMFieldFrame.xml
@@ -2,123 +2,128 @@
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
+
-
-
+
+
-
-
-
-
+
+
diff --git a/xml/gui/EMVehicleFrame.xml b/xml/gui/EMVehicleFrame.xml
index adfcb11..a47e671 100644
--- a/xml/gui/EMVehicleFrame.xml
+++ b/xml/gui/EMVehicleFrame.xml
@@ -2,7 +2,9 @@
-
+
+
+
diff --git a/xml/gui/EMWorkflowFrame.xml b/xml/gui/EMWorkflowFrame.xml
index 0563189..08b7282 100644
--- a/xml/gui/EMWorkflowFrame.xml
+++ b/xml/gui/EMWorkflowFrame.xml
@@ -2,7 +2,9 @@
-
+
+
+
diff --git a/xml/gui/MenuEmployeeManager.xml b/xml/gui/MenuEmployeeManager.xml
index a31051b..c56f765 100644
--- a/xml/gui/MenuEmployeeManager.xml
+++ b/xml/gui/MenuEmployeeManager.xml
@@ -8,7 +8,9 @@
-->
-
+
+
+
diff --git a/xml/gui/guiProfiles.xml b/xml/gui/guiProfiles.xml
index f03c692..da5b69c 100644
--- a/xml/gui/guiProfiles.xml
+++ b/xml/gui/guiProfiles.xml
@@ -4,6 +4,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -99,6 +138,15 @@
+
+
+
+
+
+
+
+
+
@@ -296,11 +344,11 @@
-
+
-
+