diff --git a/l10n/l10n_de.xml b/l10n/l10n_de.xml index d8fab79..af5bf4f 100644 --- a/l10n/l10n_de.xml +++ b/l10n/l10n_de.xml @@ -52,6 +52,14 @@ + + + + + + + + diff --git a/l10n/l10n_en.xml b/l10n/l10n_en.xml index fd00485..0164034 100644 --- a/l10n/l10n_en.xml +++ b/l10n/l10n_en.xml @@ -52,6 +52,14 @@ + + + + + + + + diff --git a/l10n/l10n_fr.xml b/l10n/l10n_fr.xml index ec7c1e6..39da2b4 100644 --- a/l10n/l10n_fr.xml +++ b/l10n/l10n_fr.xml @@ -52,6 +52,14 @@ + + + + + + + + diff --git a/modDesc.xml b/modDesc.xml index 306aee1..86aec33 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -11,10 +11,14 @@ + diff --git a/scripts/HP_AppearanceMenu.lua b/scripts/HP_AppearanceMenu.lua index a2348a3..c868c4e 100644 --- a/scripts/HP_AppearanceMenu.lua +++ b/scripts/HP_AppearanceMenu.lua @@ -219,6 +219,10 @@ function HP_AppearanceMenu:syncSelectionFromDraft() end function HP_AppearanceMenu:open() + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + hpamPrint("Appearance menu unavailable: Hired Helper Tool is active and HelperProfiles is disabled.") + return false + end self:init() if HP_ASBridge ~= nil and HP_ASBridge.reload ~= nil then HP_ASBridge:reload() end self:buildDraftFromBridge() diff --git a/scripts/HP_Compatibility.lua b/scripts/HP_Compatibility.lua new file mode 100644 index 0000000..8aa7b1a --- /dev/null +++ b/scripts/HP_Compatibility.lua @@ -0,0 +1,76 @@ +-- HP_Compatibility.lua (FS25_HelperProfiles) +-- Central runtime guard for helper-roster mods that cannot safely coexist. + +HP_Compatibility = HP_Compatibility or { + checked = false, + blocked = false, + conflictMod = nil, + warningLogged = false +} + +local LOG = "[FS25_HelperProfiles/Compatibility] " +local CONFLICT_NAMES = {"FS25_HiredHelperTool", "HiredHelperTool"} + +local function isLoadedByName(name) + local loaded = rawget(_G, "g_modIsLoaded") + if type(loaded) == "table" and loaded[name] == true then + return true + end + + if g_modManager ~= nil and type(g_modManager.getModByName) == "function" then + local ok, mod = pcall(g_modManager.getModByName, g_modManager, name) + if ok and type(mod) == "table" then + return mod.isLoaded == true or mod.isActive == true or mod.isSelected == true + end + end + + return false +end + +function HP_Compatibility:detect() + self.checked = true + self.blocked = false + self.conflictMod = nil + + for _, name in ipairs(CONFLICT_NAMES) do + if isLoadedByName(name) then + self.blocked = true + self.conflictMod = name + break + end + end + + if self.blocked and not self.warningLogged then + self.warningLogged = true + print(LOG .. "HelperProfiles disabled for this session: incompatible mod detected (" .. tostring(self.conflictMod) .. "). Disable either HelperProfiles or Hired Helper Tool and reload the save.") + end + + return self.blocked +end + +function HP_Compatibility:isBlocked() + if not self.checked then + self:detect() + end + return self.blocked == true +end + +function HP_Compatibility:getReason() + if not self:isBlocked() then return nil end + return "incompatible-helper-roster-mod:" .. tostring(self.conflictMod or "unknown") +end + +function HP_Compatibility:loadMap() + self.checked = false + self.warningLogged = false + self:detect() +end + +function HP_Compatibility:deleteMap() + self.checked = false + self.blocked = false + self.conflictMod = nil + self.warningLogged = false +end + +addModEventListener(HP_Compatibility) diff --git a/scripts/HP_RosterExpansion.lua b/scripts/HP_RosterExpansion.lua index b8368ff..06ca2d0 100644 --- a/scripts/HP_RosterExpansion.lua +++ b/scripts/HP_RosterExpansion.lua @@ -67,22 +67,6 @@ local function getByIndex(manager, index) return manager.indexToHelper ~= nil and manager.indexToHelper[index] or nil end -local function isKnownExternalExpansionLoaded() - local loaded = rawget(_G, "g_modIsLoaded") - if type(loaded) == "table" then - if loaded.FS25_HiredHelperTool == true or loaded.HiredHelperTool == true then return true end - end - if g_modManager ~= nil and type(g_modManager.getModByName) == "function" then - for _, name in ipairs({"FS25_HiredHelperTool", "HiredHelperTool"}) do - local ok, mod = pcall(g_modManager.getModByName, g_modManager, name) - if ok and type(mod) == "table" and (mod.isLoaded == true or mod.isActive == true or mod.isSelected == true) then - return true - end - end - end - return false -end - local function invalidateRosterCache() if HelperProfiles ~= nil then HelperProfiles._defaultOrderRefs = nil @@ -110,11 +94,11 @@ function HP_RosterExpansion:tryExpand(reason) local manager = g_helperManager local before = getCount(manager) - if isKnownExternalExpansionLoaded() then + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then self.completed = true - self.result = "external-expansion-mod" + self.result = "blocked-incompatible-mod" invalidateRosterCache() - log("Known external helper expansion detected; no helpers injected: reason=%s helpers=%d", tostring(reason), before) + log("HelperProfiles roster disabled because Hired Helper Tool is active: reason=%s helpers=%d", tostring(reason), before) return true end diff --git a/scripts/HP_UI.lua b/scripts/HP_UI.lua index f0a2296..1d297a8 100644 --- a/scripts/HP_UI.lua +++ b/scripts/HP_UI.lua @@ -140,7 +140,13 @@ end -- ===== Public configuration setters ========================================= function HP_UI:setVisible(value) self.visible = value and true or false end -function HP_UI:toggle() self.visible = not self.visible end +function HP_UI:toggle() + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + self.visible = false + return + end + self.visible = not self.visible +end function HP_UI:setAnchor(anchor) anchor = string.upper(anchor or "") diff --git a/scripts/HP_WorkerAppearance.lua b/scripts/HP_WorkerAppearance.lua index 734fbe5..3873784 100644 --- a/scripts/HP_WorkerAppearance.lua +++ b/scripts/HP_WorkerAppearance.lua @@ -320,6 +320,7 @@ function HP_WorkerAppearance:installHooks() end function HP_WorkerAppearance:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end self.scanTimer = (self.scanTimer or 0) + (dt or 0) if self.scanTimer >= 1000 then self.scanTimer = 0 @@ -329,6 +330,10 @@ function HP_WorkerAppearance:update(dt) end function HP_WorkerAppearance:loadMap() + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + hpwaPrint("Disabled because Hired Helper Tool is active.") + return + end self:installHooks() self:patchLoadedVehicles() end diff --git a/scripts/HelperProfiles.lua b/scripts/HelperProfiles.lua index 8019c31..8d6b5e0 100644 --- a/scripts/HelperProfiles.lua +++ b/scripts/HelperProfiles.lua @@ -125,6 +125,7 @@ end -- Once the idle managed roster order has been cached, keep returning that complete roster -- even while GIANTS removes active workers from availableHelpers. function HelperProfiles:getProfiles() + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return {} end local available = _getHelpersRaw() -- Only establish the stable managed roster while every helper is idle. @@ -429,6 +430,10 @@ end -- Expecting FS25 style callback with key status (1 on press). function HelperProfiles:onCycleAction(actionName, keyStatus) if keyStatus ~= 1 then return end -- press only + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + self:_flash(hpI18n("hp_incompatible_hired_helper_tool", "HelperProfiles is disabled because Hired Helper Tool is active."), 2.5) + return + end self:cycleSelectionDebounced(1) end @@ -511,6 +516,7 @@ end -- FS lifecycle ---------------------------------------------------------------------- function HelperProfiles:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end if not HelperProfiles._hooksDone then hookOnce() end diff --git a/scripts/RegisterPlayerActionEvents.lua b/scripts/RegisterPlayerActionEvents.lua index 6993735..63aaefe 100644 --- a/scripts/RegisterPlayerActionEvents.lua +++ b/scripts/RegisterPlayerActionEvents.lua @@ -97,16 +97,16 @@ end local function _registerPlayerAction(field, inputAction, target, callback, label) if HelperProfiles[field] ~= nil then return end if g_inputBinding == nil or inputAction == nil then - print(LOG .. "❌ Failed to register " .. tostring(label) .. " (player): input action unavailable") + print(LOG .. "Failed to register " .. tostring(label) .. " (player): input action unavailable") return end local ok, id = g_inputBinding:registerActionEvent(inputAction, target, callback, false, true, false, true) if ok and id then HelperProfiles[field] = id _setActionEventLowPriority(id, true) - print(LOG .. "✅ Registered " .. tostring(label) .. " (player)") + print(LOG .. "Registered " .. tostring(label) .. " (player)") else - print(LOG .. "❌ Failed to register " .. tostring(label) .. " (player)") + print(LOG .. "Failed to register " .. tostring(label) .. " (player)") end end @@ -160,27 +160,27 @@ end local function _addVehicleAction(vehicle, spec, inputAction, target, callback, label, loggedFlagName) if inputAction == nil then - print(LOG .. "❌ Failed to register " .. tostring(label) .. " (vehicle): input action unavailable") + print(LOG .. "Failed to register " .. tostring(label) .. " (vehicle): input action unavailable") return nil end local _, id = vehicle:addActionEvent(spec.actionEvents, inputAction, target, callback, false, true, false, true) if id ~= nil then _setActionEventLowPriority(id, true) if loggedFlagName == "cycle" and not _loggedVehicleCycle then - print(LOG .. "✅ Registered " .. tostring(label) .. " (vehicle)") + print(LOG .. "Registered " .. tostring(label) .. " (vehicle)") _loggedVehicleCycle = true elseif loggedFlagName == "toggle" and not _loggedVehicleToggle then - print(LOG .. "✅ Registered " .. tostring(label) .. " (vehicle)") + print(LOG .. "Registered " .. tostring(label) .. " (vehicle)") _loggedVehicleToggle = true elseif loggedFlagName == "mode" and not _loggedVehicleMode then - print(LOG .. "✅ Registered " .. tostring(label) .. " (vehicle)") + print(LOG .. "Registered " .. tostring(label) .. " (vehicle)") _loggedVehicleMode = true elseif loggedFlagName == "appearance" and not _loggedVehicleAppearanceMenu then - print(LOG .. "✅ Registered " .. tostring(label) .. " (vehicle)") + print(LOG .. "Registered " .. tostring(label) .. " (vehicle)") _loggedVehicleAppearanceMenu = true end else - print(LOG .. "❌ Failed to register " .. tostring(label) .. " (vehicle)") + print(LOG .. "Failed to register " .. tostring(label) .. " (vehicle)") end return id end @@ -260,7 +260,7 @@ if HP_AppearanceBindingsScreen ~= nil then local inputAction = InputAction ~= nil and InputAction.HP_CLEAR_ALL_BINDINGS or nil if screen == nil or g_inputBinding == nil or g_inputBinding.registerActionEvent == nil or inputAction == nil then - print(LOG .. "❌ Failed to register HP_CLEAR_ALL_BINDINGS (appearance dialog): input action unavailable") + print(LOG .. "Failed to register HP_CLEAR_ALL_BINDINGS (appearance dialog): input action unavailable") return end @@ -282,9 +282,9 @@ if HP_AppearanceBindingsScreen ~= nil then if g_inputBinding.setActionEventActive ~= nil then g_inputBinding:setActionEventActive(id, true) end - print(LOG .. "✅ Registered HP_CLEAR_ALL_BINDINGS (appearance dialog)") + print(LOG .. "Registered HP_CLEAR_ALL_BINDINGS (appearance dialog)") else - print(LOG .. "❌ Failed to register HP_CLEAR_ALL_BINDINGS (appearance dialog)") + print(LOG .. "Failed to register HP_CLEAR_ALL_BINDINGS (appearance dialog)") end end diff --git a/scripts/gui/HP_AppearanceBindingsScreen.lua b/scripts/gui/HP_AppearanceBindingsScreen.lua index f95f1b9..4f34766 100644 --- a/scripts/gui/HP_AppearanceBindingsScreen.lua +++ b/scripts/gui/HP_AppearanceBindingsScreen.lua @@ -89,6 +89,8 @@ local function getPresetLabelById(presetId) return tostring(presetId) end +local isHelperActive + function HP_AppearanceBindingsScreen:getHelperBindingLabel(helperRow) if helperRow == nil then return nil end @@ -106,6 +108,7 @@ function HP_AppearanceBindingsScreen:getHelperListLabel(helperRow) local link = self.draftLinks ~= nil and self.draftLinks[normalizeName(helperRow.name)] or nil local bindingLabel = self:getHelperBindingLabel(helperRow) + local label if link ~= nil and bindingLabel ~= nil and bindingLabel ~= "" then local displayName = link.displayName @@ -118,14 +121,18 @@ function HP_AppearanceBindingsScreen:getHelperListLabel(helperRow) baseLabel = baseLabel .. " (" .. hpFormat("hp_slot_label", "slot: %s", tostring(helperRow.name)) .. ")" end - return baseLabel .. " [" .. hpI18n("hp_state_bound", "BOUND") .. "] " .. bindingLabel + label = baseLabel .. " [" .. hpI18n("hp_state_bound", "BOUND") .. "] " .. bindingLabel + else + -- Important: when a persisted binding is cleared in the draft, the visible + -- slot label must revert to the real HelperProfiles slot name immediately. + label = tostring(helperRow.name or helperRow.baseName or helperRow.label or "") .. " [" .. hpI18n("hp_state_unbound", "UNBOUND") .. "]" + end + + if isHelperActive(helperRow.helper) then + label = label .. " [" .. hpI18n("hp_state_active_read_only", "ACTIVE / READ-ONLY") .. "]" end - -- Important: when a persisted binding is cleared in the draft, the visible - -- slot label must revert to the real HelperProfiles slot name immediately. - -- Do not reuse helperRow.displayName here, because that may have been - -- derived from the previously persisted AvatarSwitcher binding. - return tostring(helperRow.name or helperRow.baseName or helperRow.label or "") .. " [" .. hpI18n("hp_state_unbound", "UNBOUND") .. "]" + return label end local function getHelperDisplayName(helper, idx) @@ -139,6 +146,48 @@ local function getHelperDisplayName(helper, idx) return fallback, fallback end +local function cloneLink(link) + if type(link) ~= "table" then return nil end + return { + name = link.name, + displayName = link.displayName, + presetId = link.presetId or link.selectedPresetId, + selectedPresetId = link.selectedPresetId or link.presetId, + category = link.category, + characterId = link.characterId, + } +end + +local function linksEqual(a, b) + if a == nil or b == nil then return a == b end + return tostring(a.name or "") == tostring(b.name or "") + and tostring(a.displayName or "") == tostring(b.displayName or "") + and tostring(a.presetId or a.selectedPresetId or "") == tostring(b.presetId or b.selectedPresetId or "") + and tostring(a.category or "") == tostring(b.category or "") + and tostring(a.characterId or "") == tostring(b.characterId or "") +end + +local function linkMapsEqual(a, b) + a = a or {} + b = b or {} + for key, value in pairs(a) do + if not linksEqual(value, b[key]) then return false end + end + for key, value in pairs(b) do + if not linksEqual(value, a[key]) then return false end + end + return true +end + +isHelperActive = function(helper) + if helper == nil then return false end + if HelperProfiles ~= nil and HelperProfiles.isHelperActive ~= nil then + local ok, active = pcall(HelperProfiles.isHelperActive, HelperProfiles, helper) + if ok then return active == true end + end + return helper.inUse == true +end + local function getHelpers() local rows = {} local list = HelperProfiles ~= nil and HelperProfiles.getProfiles ~= nil and HelperProfiles:getProfiles() or {} @@ -164,6 +213,7 @@ function HP_AppearanceBindingsScreen.new(target, customMt) self.categoryRows = {} self.presetRows = {} self.draftLinks = {} + self.originalLinks = {} self.selectedHelperIndex = 1 self.selectedCategoryIndex = 1 @@ -177,6 +227,7 @@ end function HP_AppearanceBindingsScreen:refreshDirtyState() + self.appearanceDirty = not linkMapsEqual(self.draftLinks, self.originalLinks) self.dirty = self.appearanceDirty == true end @@ -247,17 +298,12 @@ function HP_AppearanceBindingsScreen:reloadData(reloadBridge) end self.draftLinks = {} + self.originalLinks = {} if HP_ASBridge ~= nil and HP_ASBridge.getLinksSnapshot ~= nil then local snap = HP_ASBridge:getLinksSnapshot() or {} for k, link in pairs(snap) do - self.draftLinks[k] = { - name = link.name, - displayName = link.displayName, - presetId = link.presetId or link.selectedPresetId, - selectedPresetId = link.selectedPresetId or link.presetId, - category = link.category, - characterId = link.characterId, - } + self.draftLinks[k] = cloneLink(link) + self.originalLinks[k] = cloneLink(link) end end @@ -405,6 +451,35 @@ function HP_AppearanceBindingsScreen:getSelectedPresetRow() return self.presetRows[clamp(self.selectedPresetIndex or 1, 1, #self.presetRows)] end +function HP_AppearanceBindingsScreen:isHelperRowReadOnly(helperRow) + return helperRow ~= nil and isHelperActive(helperRow.helper) +end + +function HP_AppearanceBindingsScreen:showActiveSlotReadOnly(helperRow) + local slot = helperRow ~= nil and (helperRow.slot or helperRow.name) or "?" + self:setStatus(hpFormat( + "hp_error_active_slot_read_only", + "Slot %s is active. Release the worker before changing its appearance binding.", + tostring(slot) + )) +end + +function HP_AppearanceBindingsScreen:restoreActiveDraftLinks() + local restored = 0 + for _, helperRow in ipairs(self.helperRows or {}) do + if self:isHelperRowReadOnly(helperRow) then + local key = normalizeName(helperRow.name) + local original = self.originalLinks ~= nil and self.originalLinks[key] or nil + local draft = self.draftLinks ~= nil and self.draftLinks[key] or nil + if not linksEqual(original, draft) then + self.draftLinks[key] = cloneLink(original) + restored = restored + 1 + end + end + end + return restored +end + function HP_AppearanceBindingsScreen:updateDetailText() local helperRow = self:getSelectedHelperRow() local category = self:getSelectedCategoryId() @@ -415,6 +490,14 @@ function HP_AppearanceBindingsScreen:updateDetailText() detail = hpFormat("hp_detail_selected", "Selected: %s | %s | %s [%s]", tostring(helperRow.displayName or helperRow.name), tostring(category or "-"), tostring(presetRow.label or presetRow.id), tostring(presetRow.id)) end + if helperRow ~= nil and self:isHelperRowReadOnly(helperRow) then + detail = hpFormat( + "hp_detail_active_read_only", + "Slot %s is active and read-only. Release the worker before rebinding it.", + tostring(helperRow.slot or helperRow.name or "?") + ) + end + if self.detailText ~= nil then self.detailText:setText(detail) end local status = self.actionMessage @@ -443,6 +526,10 @@ function HP_AppearanceBindingsScreen:onClickBindAppearance(sender) self:setStatus(hpI18n("hp_error_no_helper_selected", "Cannot bind: no helper selected")) return end + if self:isHelperRowReadOnly(helperRow) then + self:showActiveSlotReadOnly(helperRow) + return + end if presetRow == nil or presetRow.id == nil or presetRow.id == "" then self:setStatus(hpI18n("hp_error_no_appearance_selected", "Cannot bind: no appearance selected")) return @@ -475,6 +562,10 @@ end function HP_AppearanceBindingsScreen:onClickClearBinding(sender) local helperRow = self:getSelectedHelperRow() if helperRow == nil then return end + if self:isHelperRowReadOnly(helperRow) then + self:showActiveSlotReadOnly(helperRow) + return + end self.draftLinks[normalizeName(helperRow.name)] = nil self.appearanceDirty = true self:refreshDirtyState() @@ -484,10 +575,45 @@ function HP_AppearanceBindingsScreen:onClickClearBinding(sender) end function HP_AppearanceBindingsScreen:onClickClearAllBindings(sender) - self.draftLinks = {} - self.appearanceDirty = true - self:refreshDirtyState() - self.actionMessage = hpI18n("hp_status_draft_clear_all", "All draft bindings cleared. Press Save to persist.") + local activeKeys = {} + local activeCount = 0 + for _, helperRow in ipairs(self.helperRows or {}) do + if self:isHelperRowReadOnly(helperRow) then + activeKeys[normalizeName(helperRow.name)] = true + activeCount = activeCount + 1 + end + end + + local clearedCount = 0 + for key, _ in pairs(self.draftLinks or {}) do + if activeKeys[key] ~= true then + self.draftLinks[key] = nil + clearedCount = clearedCount + 1 + end + end + + if clearedCount > 0 then + self.appearanceDirty = true + self:refreshDirtyState() + if activeCount > 0 then + self.actionMessage = hpFormat( + "hp_status_draft_clear_all_skipped_active", + "Cleared %d available binding(s); %d active slot(s) were left unchanged. Press Save to persist.", + clearedCount, activeCount + ) + else + self.actionMessage = hpI18n("hp_status_draft_clear_all", "All draft bindings cleared. Press Save to persist.") + end + elseif activeCount > 0 then + self.actionMessage = hpFormat( + "hp_status_clear_all_active_only", + "No available bindings were cleared; %d active slot(s) remain read-only.", + activeCount + ) + else + self.actionMessage = hpI18n("hp_status_no_bindings_to_clear", "No bindings to clear.") + end + if self.helperTable ~= nil then self.helperTable:reloadData() end self:updateDetailText() end @@ -505,8 +631,23 @@ function HP_AppearanceBindingsScreen:onClickBack(sender) end function HP_AppearanceBindingsScreen:saveBindings(closeAfterSave) + local restoredActive = self:restoreActiveDraftLinks() + if restoredActive > 0 then + self.actionMessage = hpFormat( + "hp_status_active_changes_skipped", + "Ignored appearance changes for %d active slot(s). Release those workers before rebinding them.", + restoredActive + ) + if self.helperTable ~= nil then self.helperTable:reloadData() end + end + + self:refreshDirtyState() if self.dirty ~= true then - self:setStatus(hpI18n("hp_status_ready", "Ready")) + if restoredActive == 0 then + self:setStatus(hpI18n("hp_status_ready", "Ready")) + else + self:updateDetailText() + end if closeAfterSave == true then self:close() end return true end @@ -522,7 +663,10 @@ function HP_AppearanceBindingsScreen:saveBindings(closeAfterSave) return false end - self.appearanceDirty = false + self.originalLinks = {} + for key, link in pairs(self.draftLinks or {}) do + self.originalLinks[key] = cloneLink(link) + end self:refreshDirtyState() if HP_WorkerAppearance ~= nil and HP_WorkerAppearance.refreshActiveWorkers ~= nil then HP_WorkerAppearance:refreshActiveWorkers() @@ -591,6 +735,10 @@ function HP_AppearanceBindingsGui:loadDialog() end function HP_AppearanceBindingsGui:open() + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + hpPrint("Appearance binding UI unavailable: Hired Helper Tool is active and HelperProfiles is disabled.") + return false + end if not self.loaded then self:loadDialog() end if self.loaded and g_gui ~= nil then self.dialog = g_gui:showDialog("HP_AppearanceBindingsDialog")