diff --git a/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua b/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua index e369314..7843492 100644 --- a/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua +++ b/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua @@ -6,17 +6,28 @@ local LANDTRAIN_DEBUG = true local LANDTRAIN_FALLBACK_MAX_DIST_SQ = 3.25 -- ~1.8 tiles local LANDTRAIN_FALLBACK_MAX_DZ = 0.9 local LANDTRAIN_FRONT_SQL_ID_KEY = "landtrainTowbarFrontSqlId" -local LANDTRAIN_FRONT_ID_KEY_LEGACY = "landtrainTowbarFrontVehicleId" local LANDTRAIN_FRONT_ATTACHMENT_A_KEY = "landtrainTowbarAttachmentA" local LANDTRAIN_FRONT_ATTACHMENT_B_KEY = "landtrainTowbarAttachmentB" +local LANDTRAIN_REAR_SQL_ID_KEY = "landtrainTowbarRearSqlId" +local LANDTRAIN_SAVED_POS_X_KEY = "landtrainSavedPosX" +local LANDTRAIN_SAVED_POS_Y_KEY = "landtrainSavedPosY" +local LANDTRAIN_SAVED_POS_Z_KEY = "landtrainSavedPosZ" +local LANDTRAIN_SAVED_DIR_KEY = "landtrainSavedDir" + +local function emitLandtrainLog(line) + print(line) + if type(writeLog) == "function" then + pcall(writeLog, "Landtrain", line) + end +end local function ltLog(msg) if not LANDTRAIN_DEBUG then return end - print("[Landtrain] " .. tostring(msg)) + emitLandtrainLog("[Landtrain] " .. tostring(msg)) end local function ltInfo(msg) - print("[Landtrain][Info] " .. tostring(msg)) + emitLandtrainLog("[Landtrain][Info] " .. tostring(msg)) end ltInfo("UnlimitedTowbarChains loaded. debug=" .. tostring(LANDTRAIN_DEBUG)) @@ -27,6 +38,115 @@ local function vehLabel(vehicle) return tostring(vehicle:getId()) .. ":" .. tostring(name) end +local _landtrainPairPickPosA = Vector3f.new() +local _landtrainPairPickPosB = Vector3f.new() + +local function hasAttachmentById(vehicle, attachmentId) + if vehicle == nil or attachmentId == nil then return false end + local script = vehicle:getScript() + if script == nil then return false end + return script:getAttachmentById(attachmentId) ~= nil +end + +local function getTowbarStyleAttachmentPair(towingVehicle, towedVehicle) + local attachmentA = nil + local attachmentB = nil + + if hasAttachmentById(towingVehicle, "trailer") then + attachmentA = "trailer" + elseif towingVehicle ~= nil then + attachmentA = towingVehicle:getTowAttachmentSelf() + end + + if hasAttachmentById(towedVehicle, "trailerfront") then + attachmentB = "trailerfront" + elseif towedVehicle ~= nil then + attachmentB = towedVehicle:getTowAttachmentSelf() + end + + if attachmentA == attachmentB then + if attachmentA == "trailer" and hasAttachmentById(towedVehicle, "trailerfront") then + attachmentB = "trailerfront" + elseif attachmentA == "trailerfront" and hasAttachmentById(towingVehicle, "trailer") then + attachmentA = "trailer" + end + end + + if attachmentA == nil then attachmentA = "trailer" end + if attachmentB == nil then attachmentB = "trailerfront" end + return attachmentA, attachmentB +end + +local function chooseLandtrainAttachmentPair(towingVehicle, towedVehicle, preferredA, preferredB) + local seen = {} + local candidates = {} + local function addCandidate(a, b) + if a == nil or b == nil or a == b then return end + local key = tostring(a) .. "|" .. tostring(b) + if seen[key] then return end + seen[key] = true + table.insert(candidates, { a = a, b = b }) + end + + addCandidate(preferredA, preferredB) + addCandidate(towingVehicle and towingVehicle:getTowAttachmentSelf() or nil, towedVehicle and towedVehicle:getTowAttachmentSelf() or nil) + addCandidate("trailer", "trailerfront") + addCandidate("trailerfront", "trailer") + + local bestPair = nil + local bestDistSq = nil + local bestDz = nil + for _, pair in ipairs(candidates) do + if hasAttachmentById(towingVehicle, pair.a) and hasAttachmentById(towedVehicle, pair.b) then + local posA = towingVehicle:getAttachmentWorldPos(pair.a, _landtrainPairPickPosA) + local posB = towedVehicle:getAttachmentWorldPos(pair.b, _landtrainPairPickPosB) + if posA ~= nil and posB ~= nil then + local dx = posA:x() - posB:x() + local dy = posA:y() - posB:y() + local dz = math.abs(posA:z() - posB:z()) + local distSq = dx * dx + dy * dy + (posA:z() - posB:z()) * (posA:z() - posB:z()) + if bestPair == nil or distSq < bestDistSq or (distSq == bestDistSq and dz < bestDz) then + bestPair = pair + bestDistSq = distSq + bestDz = dz + end + elseif bestPair == nil then + bestPair = pair + end + end + end + + if bestPair ~= nil then + return bestPair.a, bestPair.b + end + + local fallbackA = preferredA or (towingVehicle and towingVehicle:getTowAttachmentSelf()) or "trailer" + local fallbackB = preferredB or (towedVehicle and towedVehicle:getTowAttachmentSelf()) or "trailerfront" + if fallbackA == fallbackB then + if fallbackA == "trailerfront" then + fallbackA = "trailer" + else + fallbackB = "trailerfront" + end + end + return fallbackA, fallbackB +end + +local function chooseLandtrainTowbarPair(towingVehicle, towedVehicle, preferredA, preferredB) + local towbarA, towbarB = getTowbarStyleAttachmentPair(towingVehicle, towedVehicle) + if towbarA ~= towbarB and hasAttachmentById(towingVehicle, towbarA) and hasAttachmentById(towedVehicle, towbarB) then + return towbarA, towbarB + end + + if preferredA ~= nil and preferredB ~= nil and preferredA ~= preferredB + and hasAttachmentById(towingVehicle, preferredA) + and hasAttachmentById(towedVehicle, preferredB) then + return preferredA, preferredB + end + + return chooseLandtrainAttachmentPair(towingVehicle, towedVehicle, preferredA, preferredB) +end + local function dumpTowState(prefix, vehicle) if not LANDTRAIN_DEBUG then return end if vehicle == nil then @@ -131,7 +251,7 @@ local function getVehicleBySqlIdSafe(sqlId) for i = 0, vehicles:size() - 1 do local vehicle = vehicles:get(i) - if vehicle ~= nil and vehicle:getSqlId and vehicle:getSqlId() == targetSqlId then + if vehicle ~= nil and vehicle.getSqlId and vehicle:getSqlId() == targetSqlId then return vehicle end end @@ -140,11 +260,13 @@ end local function storeLandtrainFrontLinkData(towingVehicle, towedVehicle, attachmentA, attachmentB) if towingVehicle == nil or towedVehicle == nil then return end + local towingModData = towingVehicle:getModData() local towedModData = towedVehicle:getModData() - if towedModData == nil then return end + if towingModData == nil or towedModData == nil then return end if towingVehicle == towedVehicle or towingVehicle:getId() == towedVehicle:getId() then + towingModData[LANDTRAIN_REAR_SQL_ID_KEY] = nil + towingVehicle:transmitModData() towedModData[LANDTRAIN_FRONT_SQL_ID_KEY] = nil - towedModData[LANDTRAIN_FRONT_ID_KEY_LEGACY] = nil towedModData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = nil towedModData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = nil towedVehicle:transmitModData() @@ -152,15 +274,22 @@ local function storeLandtrainFrontLinkData(towingVehicle, towedVehicle, attachme return end - local towingSqlId = towingVehicle:getSqlId and towingVehicle:getSqlId() or -1 + local towingSqlId = towingVehicle.getSqlId and towingVehicle:getSqlId() or -1 if towingSqlId ~= nil and towingSqlId >= 0 then towedModData[LANDTRAIN_FRONT_SQL_ID_KEY] = towingSqlId else towedModData[LANDTRAIN_FRONT_SQL_ID_KEY] = nil end - towedModData[LANDTRAIN_FRONT_ID_KEY_LEGACY] = nil - towedModData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = attachmentA or towingVehicle:getTowAttachmentSelf() or "trailer" - towedModData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = attachmentB or towedVehicle:getTowAttachmentSelf() or "trailerfront" + local towedSqlId = towedVehicle.getSqlId and towedVehicle:getSqlId() or -1 + if towedSqlId ~= nil and towedSqlId >= 0 then + towingModData[LANDTRAIN_REAR_SQL_ID_KEY] = towedSqlId + else + towingModData[LANDTRAIN_REAR_SQL_ID_KEY] = nil + end + towingVehicle:transmitModData() + local resolvedA, resolvedB = chooseLandtrainTowbarPair(towingVehicle, towedVehicle, attachmentA, attachmentB) + towedModData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = resolvedA + towedModData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = resolvedB towedVehicle:transmitModData() end @@ -168,8 +297,19 @@ local function clearLandtrainFrontLinkData(vehicle) if vehicle == nil then return end local modData = vehicle:getModData() if modData == nil then return end + local frontSqlId = tonumber(modData[LANDTRAIN_FRONT_SQL_ID_KEY]) + local vehicleSqlId = vehicle.getSqlId and vehicle:getSqlId() or nil + if frontSqlId ~= nil and vehicleSqlId ~= nil and vehicleSqlId >= 0 then + local frontVehicle = getVehicleBySqlIdSafe(frontSqlId) + if frontVehicle ~= nil then + local frontModData = frontVehicle:getModData() + if frontModData ~= nil and tonumber(frontModData[LANDTRAIN_REAR_SQL_ID_KEY]) == vehicleSqlId then + frontModData[LANDTRAIN_REAR_SQL_ID_KEY] = nil + frontVehicle:transmitModData() + end + end + end modData[LANDTRAIN_FRONT_SQL_ID_KEY] = nil - modData[LANDTRAIN_FRONT_ID_KEY_LEGACY] = nil modData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = nil modData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = nil vehicle:transmitModData() @@ -187,6 +327,7 @@ local function isTowbarManagedVehicle(vehicle) or modData["isChangedTowedAttachment"] == true or modData["towBarChangedAttachmentId"] ~= nil or modData[LANDTRAIN_FRONT_SQL_ID_KEY] ~= nil + or modData[LANDTRAIN_REAR_SQL_ID_KEY] ~= nil end local function restoreVehicleTowbarDefaults(vehicle) @@ -244,9 +385,9 @@ local function restoreVehicleTowbarDefaults(vehicle) modData.towBarOriginalMass = nil modData.towBarOriginalBrakingForce = nil modData[LANDTRAIN_FRONT_SQL_ID_KEY] = nil - modData[LANDTRAIN_FRONT_ID_KEY_LEGACY] = nil modData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = nil modData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = nil + modData[LANDTRAIN_REAR_SQL_ID_KEY] = nil if TowBarMod and TowBarMod.Hook and TowBarMod.Hook.clearReapplied then TowBarMod.Hook.clearReapplied(vehicle) @@ -277,7 +418,7 @@ local function reconcileTowbarSplitVehicle(vehicle) end modData["towed"] = (frontVehicle ~= nil) - modData["isTowingByTowBar"] = (frontVehicle ~= nil) or (hasTowbarRear == true) + modData["isTowingByTowBar"] = (modData["towed"] == true) or (hasTowbarRear == true) vehicle:transmitModData() end @@ -344,7 +485,7 @@ end local function sanitizeLoadedTowLinks() local vehicles = getLoadedVehicles() - if #vehicles == 0 then return end + if #vehicles == 0 then return vehicles end for _, vehicle in ipairs(vehicles) do local front = vehicle:getVehicleTowedBy() @@ -357,252 +498,382 @@ local function sanitizeLoadedTowLinks() end end - for _, vehicle in ipairs(vehicles) do - reconcileTowbarSplitAround(vehicle) - refreshTowBarState(vehicle) - local frontVehicle = vehicle:getVehicleTowedBy() - if frontVehicle ~= nil and isTowbarManagedVehicle(vehicle) then - storeLandtrainFrontLinkData(frontVehicle, vehicle) - end - end + return vehicles end -local _landtrainLoadSanityTicks = 0 -local function onLandtrainLoadSanityTick() - if _landtrainLoadSanityTicks <= 0 then - Events.OnTick.Remove(onLandtrainLoadSanityTick) - return - end - - _landtrainLoadSanityTicks = _landtrainLoadSanityTicks - 1 - if (_landtrainLoadSanityTicks % 30) ~= 0 then - return - end - sanitizeLoadedTowLinks() -end - -local function scheduleLandtrainLoadSanity() - _landtrainLoadSanityTicks = 300 -- run for ~5 seconds - Events.OnTick.Remove(onLandtrainLoadSanityTick) - Events.OnTick.Add(onLandtrainLoadSanityTick) - sanitizeLoadedTowLinks() -end - -local landtrainPendingRestoreVehicleIds = {} -local landtrainReapplyTickCounter = 0 - -local function hasLandtrainFrontLinkData(vehicle) - if vehicle == nil then return false end - local modData = vehicle:getModData() - if modData == nil then return false end - return modData[LANDTRAIN_FRONT_SQL_ID_KEY] ~= nil -end - -local function queueLandtrainTowbarRestore(vehicle) - if vehicle == nil then return end - local modData = vehicle:getModData() - if modData == nil then return end - - -- Drop legacy runtime-id metadata; it is not stable across save/load sessions. - if modData[LANDTRAIN_FRONT_ID_KEY_LEGACY] ~= nil then - modData[LANDTRAIN_FRONT_ID_KEY_LEGACY] = nil - vehicle:transmitModData() - end - - local vehicleSqlId = vehicle.getSqlId and vehicle:getSqlId() or nil - local frontVehicleSqlId = tonumber(modData[LANDTRAIN_FRONT_SQL_ID_KEY]) - if frontVehicleSqlId ~= nil and vehicleSqlId ~= nil and vehicleSqlId >= 0 and frontVehicleSqlId == vehicleSqlId then - modData[LANDTRAIN_FRONT_SQL_ID_KEY] = nil - modData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] = nil - modData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] = nil - modData["towed"] = false - vehicle:transmitModData() - ltLog("queueLandtrainTowbarRestore cleared invalid self front sqlId for " .. vehLabel(vehicle)) - end - - if modData["towed"] == true - or modData["isTowingByTowBar"] == true - or modData[LANDTRAIN_FRONT_SQL_ID_KEY] ~= nil - or modData.towBarOriginalScriptName ~= nil - or modData.towBarOriginalMass ~= nil - or modData.towBarOriginalBrakingForce ~= nil then - landtrainPendingRestoreVehicleIds[vehicle:getId()] = true - end -end - -local function tryLandtrainTowbarRestore(vehicle, playerObj) - if vehicle == nil then return true end - local modData = vehicle:getModData() - if modData == nil then return true end - - local frontVehicle = vehicle:getVehicleTowedBy() - if frontVehicle == vehicle then - ltLog("tryLandtrainTowbarRestore cleared self front link for " .. vehLabel(vehicle)) - clearLandtrainFrontLinkData(vehicle) - modData["towed"] = false - vehicle:transmitModData() - return true - end - if frontVehicle ~= nil then - if modData["isTowingByTowBar"] == true or modData["towed"] == true then - storeLandtrainFrontLinkData(frontVehicle, vehicle) - if TowBarMod and TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then - TowBarMod.Hook.setVehiclePostAttach(playerObj, vehicle) - end - if TowBarMod and TowBarMod.Hook and TowBarMod.Hook.markReapplied then - TowBarMod.Hook.markReapplied(vehicle) - end - setTowBarModelVisibleForVehicle(vehicle, true) - refreshTowBarState(frontVehicle) - refreshTowBarState(vehicle) - end - return true - end - - if modData["towed"] ~= true and not hasLandtrainFrontLinkData(vehicle) then - clearLandtrainFrontLinkData(vehicle) - return true - end - - local frontVehicleSqlId = tonumber(modData[LANDTRAIN_FRONT_SQL_ID_KEY]) - if frontVehicleSqlId == nil then - clearLandtrainFrontLinkData(vehicle) - if modData["towed"] == true then - modData["towed"] = false - vehicle:transmitModData() - end - return true - end - - local vehicleSqlId = vehicle.getSqlId and vehicle:getSqlId() or nil - if vehicleSqlId ~= nil and vehicleSqlId >= 0 and frontVehicleSqlId == vehicleSqlId then - ltLog("tryLandtrainTowbarRestore dropped self metadata sqlId for " .. vehLabel(vehicle)) - clearLandtrainFrontLinkData(vehicle) - modData["towed"] = false - vehicle:transmitModData() - return true - end - - local savedFrontVehicle = getVehicleBySqlIdSafe(frontVehicleSqlId) - if savedFrontVehicle == nil then +local function setModDataNumberIfChanged(modData, key, value) + if modData == nil or key == nil or value == nil then return false end + local current = tonumber(modData[key]) + if current ~= nil and math.abs(current - value) < 0.0001 then return false end - if savedFrontVehicle == vehicle or savedFrontVehicle:getId() == vehicle:getId() then - ltLog("tryLandtrainTowbarRestore dropped self resolved front for " .. vehLabel(vehicle)) - clearLandtrainFrontLinkData(vehicle) - modData["towed"] = false - vehicle:transmitModData() - return true - end - - local currentRear = savedFrontVehicle:getVehicleTowing() - if currentRear ~= nil and currentRear ~= vehicle then - return false - end - - local attachmentA = modData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] or savedFrontVehicle:getTowAttachmentSelf() or "trailer" - local attachmentB = modData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] or vehicle:getTowAttachmentSelf() or "trailerfront" - local scriptA = savedFrontVehicle:getScript() - local scriptB = vehicle:getScript() - if scriptA == nil or scriptB == nil then - return false - end - if scriptA:getAttachmentById(attachmentA) == nil then - attachmentA = savedFrontVehicle:getTowAttachmentSelf() or "trailer" - end - if scriptB:getAttachmentById(attachmentB) == nil then - attachmentB = vehicle:getTowAttachmentSelf() or "trailerfront" - end - if scriptA:getAttachmentById(attachmentA) == nil or scriptB:getAttachmentById(attachmentB) == nil then - return false - end - - playerObj = playerObj or getPlayer() - if playerObj == nil then - return false - end - if TowBarMod == nil or TowBarMod.Utils == nil or TowBarMod.Utils.updateAttachmentsForRigidTow == nil then - return false - end - - TowBarMod.Utils.updateAttachmentsForRigidTow(savedFrontVehicle, vehicle, attachmentA, attachmentB) - vehicle:setScriptName("notTowingA_Trailer") - local args = { - vehicleA = savedFrontVehicle:getId(), - vehicleB = vehicle:getId(), - attachmentA = attachmentA, - attachmentB = attachmentB - } - if args.vehicleA == args.vehicleB then - ltLog("tryLandtrainTowbarRestore blocked self attach args for " .. vehLabel(vehicle)) - clearLandtrainFrontLinkData(vehicle) - modData["towed"] = false - vehicle:transmitModData() - return true - end - sendClientCommand(playerObj, "vehicle", "attachTrailer", args) - if TowBarScheduleAction and TowBarMod and TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then - ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, vehicle)) - elseif TowBarMod and TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then - TowBarMod.Hook.setVehiclePostAttach(playerObj, vehicle) - end - - savedFrontVehicle:getModData()["isTowingByTowBar"] = true - modData["isTowingByTowBar"] = true - modData["towed"] = true - savedFrontVehicle:transmitModData() - vehicle:transmitModData() - storeLandtrainFrontLinkData(savedFrontVehicle, vehicle, attachmentA, attachmentB) - setTowBarModelVisibleForVehicle(vehicle, true) - refreshTowBarState(savedFrontVehicle) - refreshTowBarState(vehicle) - if TowBarMod and TowBarMod.Hook and TowBarMod.Hook.markReapplied then - TowBarMod.Hook.markReapplied(vehicle) - end + modData[key] = value return true end -local function processLandtrainPendingRestores() - landtrainReapplyTickCounter = landtrainReapplyTickCounter + 1 - if landtrainReapplyTickCounter < 15 then +local function saveLandtrainVehiclePosition(vehicle) + if vehicle == nil then return false end + local modData = vehicle:getModData() + if modData == nil then return false end + + local changed = false + changed = setModDataNumberIfChanged(modData, LANDTRAIN_SAVED_POS_X_KEY, vehicle:getX()) or changed + changed = setModDataNumberIfChanged(modData, LANDTRAIN_SAVED_POS_Y_KEY, vehicle:getY()) or changed + changed = setModDataNumberIfChanged(modData, LANDTRAIN_SAVED_POS_Z_KEY, vehicle:getZ()) or changed + if vehicle.getDirectionAngle then + local ok, angle = pcall(function() + return vehicle:getDirectionAngle() + end) + if ok and angle ~= nil then + changed = setModDataNumberIfChanged(modData, LANDTRAIN_SAVED_DIR_KEY, angle) or changed + end + end + + if changed then + vehicle:transmitModData() + end + return changed +end + +local function isLandtrainTowbarLink(frontVehicle, rearVehicle) + if frontVehicle == nil or rearVehicle == nil then return false end + if frontVehicle == rearVehicle or frontVehicle:getId() == rearVehicle:getId() then return false end + if frontVehicle:getVehicleTowing() ~= rearVehicle or rearVehicle:getVehicleTowedBy() ~= frontVehicle then + return false + end + + local frontModData = frontVehicle:getModData() + local rearModData = rearVehicle:getModData() + if frontModData == nil or rearModData == nil then return false end + return isTowbarManagedVehicle(frontVehicle) + or isTowbarManagedVehicle(rearVehicle) + or tonumber(rearModData[LANDTRAIN_FRONT_SQL_ID_KEY]) ~= nil + or tonumber(frontModData[LANDTRAIN_REAR_SQL_ID_KEY]) ~= nil +end + +local function saveLandtrainTowbarLink(frontVehicle, rearVehicle, attachmentA, attachmentB) + if not isLandtrainTowbarLink(frontVehicle, rearVehicle) then return false end + storeLandtrainFrontLinkData(frontVehicle, rearVehicle, attachmentA, attachmentB) + local changed = false + changed = saveLandtrainVehiclePosition(frontVehicle) or changed + changed = saveLandtrainVehiclePosition(rearVehicle) or changed + return changed +end + +local function saveActiveLandtrainTowbarSnapshot(vehicles) + local loaded = vehicles or getLoadedVehicles() + local savedLinks = 0 + for _, rearVehicle in ipairs(loaded) do + local frontVehicle = rearVehicle:getVehicleTowedBy() + if isLandtrainTowbarLink(frontVehicle, rearVehicle) then + local rearModData = rearVehicle:getModData() + local attachmentA, attachmentB = chooseLandtrainTowbarPair( + frontVehicle, + rearVehicle, + rearModData and rearModData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] or nil, + rearModData and rearModData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] or nil + ) + if saveLandtrainTowbarLink(frontVehicle, rearVehicle, attachmentA, attachmentB) then + savedLinks = savedLinks + 1 + end + end + end + return savedLinks +end + +local function callVehicleMethodSafe(vehicle, methodName, ...) + if vehicle == nil then return false end + local method = vehicle[methodName] + if type(method) ~= "function" then return false end + local ok = pcall(method, vehicle, ...) + return ok +end + +local function restoreSavedVehiclePosition(vehicle) + if vehicle == nil then return false end + local modData = vehicle:getModData() + if modData == nil then return false end + + local x = tonumber(modData[LANDTRAIN_SAVED_POS_X_KEY]) + local y = tonumber(modData[LANDTRAIN_SAVED_POS_Y_KEY]) + local z = tonumber(modData[LANDTRAIN_SAVED_POS_Z_KEY]) + if x == nil or y == nil or z == nil then + return false + end + + local moved = false + moved = callVehicleMethodSafe(vehicle, "setX", x) or moved + moved = callVehicleMethodSafe(vehicle, "setY", y) or moved + moved = callVehicleMethodSafe(vehicle, "setZ", z) or moved + callVehicleMethodSafe(vehicle, "setLx", x) + callVehicleMethodSafe(vehicle, "setLy", y) + callVehicleMethodSafe(vehicle, "setLz", z) + + local dir = tonumber(modData[LANDTRAIN_SAVED_DIR_KEY]) + if dir ~= nil then + callVehicleMethodSafe(vehicle, "setDirectionAngle", dir) + end + + local cell = getCell() + if cell ~= nil then + local square = cell:getGridSquare(math.floor(x), math.floor(y), math.floor(z)) + if square ~= nil then + callVehicleMethodSafe(vehicle, "setCurrent", square) + end + end + + callVehicleMethodSafe(vehicle, "transmitPosition") + return moved +end + +local function collectSavedLandtrainLinks(vehicles) + local links = {} + local seenRear = {} + local frontSqlByRearSql = {} + for _, vehicle in ipairs(vehicles) do + local modData = vehicle and vehicle:getModData() or nil + local rearSqlId = vehicle and vehicle.getSqlId and vehicle:getSqlId() or nil + local frontSqlId = modData and tonumber(modData[LANDTRAIN_FRONT_SQL_ID_KEY]) or nil + if rearSqlId ~= nil and rearSqlId >= 0 and frontSqlId ~= nil and frontSqlId >= 0 and frontSqlId ~= rearSqlId then + frontSqlByRearSql[rearSqlId] = frontSqlId + end + end + + local function getSavedDepthForRearSql(rearSqlId) + if rearSqlId == nil or rearSqlId < 0 then return 0 end + local depth = 0 + local seen = {} + local cursorSqlId = rearSqlId + while cursorSqlId ~= nil do + local frontSqlId = frontSqlByRearSql[cursorSqlId] + if frontSqlId == nil or frontSqlId < 0 then + break + end + if seen[frontSqlId] then + break + end + seen[frontSqlId] = true + depth = depth + 1 + cursorSqlId = frontSqlId + end + return depth + end + + for _, rearVehicle in ipairs(vehicles) do + if rearVehicle ~= nil then + local rearId = rearVehicle:getId() + if not seenRear[rearId] then + local rearModData = rearVehicle:getModData() + local frontSqlId = rearModData and tonumber(rearModData[LANDTRAIN_FRONT_SQL_ID_KEY]) or nil + local frontVehicle = nil + if frontSqlId ~= nil and frontSqlId >= 0 then + frontVehicle = getVehicleBySqlIdSafe(frontSqlId) + end + if frontVehicle == nil then + frontVehicle = rearVehicle:getVehicleTowedBy() + end + if frontVehicle ~= nil and frontVehicle ~= rearVehicle and frontVehicle:getId() ~= rearVehicle:getId() then + local attachmentA, attachmentB = chooseLandtrainTowbarPair( + frontVehicle, + rearVehicle, + rearModData and rearModData[LANDTRAIN_FRONT_ATTACHMENT_A_KEY] or nil, + rearModData and rearModData[LANDTRAIN_FRONT_ATTACHMENT_B_KEY] or nil + ) + table.insert(links, { + frontVehicle = frontVehicle, + rearVehicle = rearVehicle, + attachmentA = attachmentA, + attachmentB = attachmentB, + depth = getSavedDepthForRearSql(rearVehicle.getSqlId and rearVehicle:getSqlId() or nil) + }) + seenRear[rearId] = true + end + end + end + end + table.sort(links, function(a, b) + if a.depth == b.depth then + return a.rearVehicle:getId() < b.rearVehicle:getId() + end + return a.depth < b.depth + end) + return links +end + +local function detachSavedLandtrainLinks(links) + local detached = 0 + for i = #links, 1, -1 do + local link = links[i] + local frontVehicle = link.frontVehicle + local rearVehicle = link.rearVehicle + if frontVehicle ~= nil and rearVehicle ~= nil + and (frontVehicle:getVehicleTowing() == rearVehicle or rearVehicle:getVehicleTowedBy() == frontVehicle) then + if breakConstraintSafe(frontVehicle, "load-rebuild-detach") then + detached = detached + 1 + end + end + end + return detached +end + +local function attachSavedLandtrainLink(playerObj, link, delayTicks) + local frontVehicle = link.frontVehicle + local rearVehicle = link.rearVehicle + if playerObj == nil or frontVehicle == nil or rearVehicle == nil then return false end + if TowBarMod == nil or TowBarMod.Utils == nil or TowBarMod.Utils.updateAttachmentsForRigidTow == nil then + return false + end + if frontVehicle == rearVehicle or frontVehicle:getId() == rearVehicle:getId() then + return false + end + + local scriptA = frontVehicle:getScript() + local scriptB = rearVehicle:getScript() + if scriptA == nil or scriptB == nil then + return false + end + if scriptA:getAttachmentById(link.attachmentA) == nil or scriptB:getAttachmentById(link.attachmentB) == nil then + return false + end + + local originalScriptName = rearVehicle:getScriptName() + TowBarMod.Utils.updateAttachmentsForRigidTow(frontVehicle, rearVehicle, link.attachmentA, link.attachmentB) + rearVehicle:setScriptName("notTowingA_Trailer") + local args = { + vehicleA = frontVehicle:getId(), + vehicleB = rearVehicle:getId(), + attachmentA = link.attachmentA, + attachmentB = link.attachmentB + } + if args.vehicleA == args.vehicleB then + return false + end + sendClientCommand(playerObj, "vehicle", "attachTrailer", args) + + local frontModData = frontVehicle:getModData() + local rearModData = rearVehicle:getModData() + if frontModData == nil or rearModData == nil then return false end + frontModData["isTowingByTowBar"] = true + rearModData["isTowingByTowBar"] = true + rearModData["towed"] = true + if rearModData.towBarOriginalScriptName == nil and originalScriptName ~= "notTowingA_Trailer" then + rearModData.towBarOriginalScriptName = originalScriptName + end + if rearModData.towBarOriginalMass == nil then + rearModData.towBarOriginalMass = rearVehicle:getMass() + end + if rearModData.towBarOriginalBrakingForce == nil then + rearModData.towBarOriginalBrakingForce = rearVehicle:getBrakingForce() + end + frontVehicle:transmitModData() + rearVehicle:transmitModData() + storeLandtrainFrontLinkData(frontVehicle, rearVehicle, link.attachmentA, link.attachmentB) + + if TowBarScheduleAction and TowBarMod and TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then + ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, delayTicks, TowBarMod.Hook.setVehiclePostAttach, rearVehicle)) + elseif TowBarMod and TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then + TowBarMod.Hook.setVehiclePostAttach(playerObj, rearVehicle) + end + if TowBarMod and TowBarMod.Hook and TowBarMod.Hook.markReapplied then + TowBarMod.Hook.markReapplied(rearVehicle) + end + + setTowBarModelVisibleForVehicle(rearVehicle, true) + refreshTowBarState(frontVehicle) + refreshTowBarState(rearVehicle) + return true +end + +local function runLandtrainSingleLoadRestorePass() + local vehicles = sanitizeLoadedTowLinks() + if vehicles == nil or #vehicles == 0 then + ltInfo("Landtrain load rebuild: no loaded vehicles") return end - landtrainReapplyTickCounter = 0 - local resolvedVehicleIds = {} - local playerObj = getPlayer() - for vehicleId, _ in pairs(landtrainPendingRestoreVehicleIds) do - local vehicle = getVehicleByIdSafe(vehicleId) - if vehicle == nil or tryLandtrainTowbarRestore(vehicle, playerObj) then - table.insert(resolvedVehicleIds, vehicleId) + local links = collectSavedLandtrainLinks(vehicles) + if #links == 0 then + ltInfo("Landtrain load rebuild: no saved towbar links") + return + end + + local playerObj = getPlayer() or getSpecificPlayer(0) + if playerObj == nil then + ltInfo("Landtrain load rebuild skipped: no player") + return + end + if TowBarMod == nil or TowBarMod.Utils == nil or TowBarMod.Utils.updateAttachmentsForRigidTow == nil then + ltInfo("Landtrain load rebuild skipped: TowBar utilities unavailable") + return + end + + local movedVehicles = 0 + local movedSeen = {} + for _, link in ipairs(links) do + local frontId = link.frontVehicle:getId() + local rearId = link.rearVehicle:getId() + if not movedSeen[frontId] then + if restoreSavedVehiclePosition(link.frontVehicle) then + movedVehicles = movedVehicles + 1 + end + movedSeen[frontId] = true + end + if not movedSeen[rearId] then + if restoreSavedVehiclePosition(link.rearVehicle) then + movedVehicles = movedVehicles + 1 + end + movedSeen[rearId] = true end end - for _, vehicleId in ipairs(resolvedVehicleIds) do - landtrainPendingRestoreVehicleIds[vehicleId] = nil - end -end - -local function queueLandtrainLoadedTowbarRestores() - local vehicles = getLoadedVehicles() - for _, vehicle in ipairs(vehicles) do - local frontVehicle = vehicle:getVehicleTowedBy() - if frontVehicle ~= nil and isTowbarManagedVehicle(vehicle) then - storeLandtrainFrontLinkData(frontVehicle, vehicle) + local detachedCount = detachSavedLandtrainLinks(links) + local attachedCount = 0 + for index, link in ipairs(links) do + local delay = 10 + ((index - 1) * 5) + if attachSavedLandtrainLink(playerObj, link, delay) then + attachedCount = attachedCount + 1 end - queueLandtrainTowbarRestore(vehicle) end + + saveActiveLandtrainTowbarSnapshot(vehicles) + ltInfo("Landtrain load rebuild complete moved=" .. tostring(movedVehicles) + .. " detached=" .. tostring(detachedCount) + .. " attached=" .. tostring(attachedCount) + .. " links=" .. tostring(#links)) end -local function onLandtrainSpawnVehicleEnd(vehicle) - if vehicle == nil then return end - local frontVehicle = vehicle:getVehicleTowedBy() - if frontVehicle ~= nil and isTowbarManagedVehicle(vehicle) then - storeLandtrainFrontLinkData(frontVehicle, vehicle) +local landtrainLoadRebuildDone = false +local landtrainSaveSnapshotTickCounter = 0 +local function onLandtrainSaveSnapshotTick() + if not landtrainLoadRebuildDone then + return end - queueLandtrainTowbarRestore(vehicle) - queueLandtrainTowbarRestore(vehicle:getVehicleTowedBy()) - queueLandtrainTowbarRestore(vehicle:getVehicleTowing()) + landtrainSaveSnapshotTickCounter = landtrainSaveSnapshotTickCounter + 1 + if landtrainSaveSnapshotTickCounter < 60 then + return + end + landtrainSaveSnapshotTickCounter = 0 + saveActiveLandtrainTowbarSnapshot() +end + +local landtrainLoadRestoreDelayTicks = 0 +local function onLandtrainSingleLoadRestoreTick() + if landtrainLoadRestoreDelayTicks > 0 then + landtrainLoadRestoreDelayTicks = landtrainLoadRestoreDelayTicks - 1 + return + end + Events.OnTick.Remove(onLandtrainSingleLoadRestoreTick) + runLandtrainSingleLoadRestorePass() + landtrainLoadRebuildDone = true +end + +local function scheduleLandtrainSingleLoadRestore() + -- Single delayed load pass: restore saved positions, detach all saved links, reattach in chain order. + landtrainLoadRebuildDone = false + landtrainSaveSnapshotTickCounter = 0 + landtrainLoadRestoreDelayTicks = 90 + Events.OnTick.Remove(onLandtrainSingleLoadRestoreTick) + Events.OnTick.Add(onLandtrainSingleLoadRestoreTick) end local _landtrainHookPosA = Vector3f.new() @@ -700,11 +971,12 @@ local function captureTowbarFrontLink(towingVehicle) return nil end + local attachmentA, attachmentB = chooseLandtrainTowbarPair(frontVehicle, towingVehicle, nil, nil) local link = { frontVehicle = frontVehicle, towingVehicle = towingVehicle, - attachmentA = frontVehicle:getTowAttachmentSelf() or "trailer", - attachmentB = towingVehicle:getTowAttachmentSelf() or "trailerfront" + attachmentA = attachmentA, + attachmentB = attachmentB } ltLog("captureTowbarFrontLink captured front=" .. vehLabel(frontVehicle) .. " middle=" .. vehLabel(towingVehicle) .. " attA=" .. tostring(link.attachmentA) .. " attB=" .. tostring(link.attachmentB)) @@ -809,9 +1081,16 @@ end local function menuHasTowbarAttachSlice(menu) if menu == nil or menu.slices == nil then return false end + local attachAction = TowBarMod and TowBarMod.Hook and TowBarMod.Hook.attachByTowBarAction or nil + local chooseAction = TowBarMod and TowBarMod.UI and TowBarMod.UI.showChooseVehicleMenu or nil + if attachAction == nil and chooseAction == nil then + return false + end + for _, slice in ipairs(menu.slices) do local command = slice.command and slice.command[1] - if command == TowBarMod.Hook.attachByTowBarAction or command == TowBarMod.UI.showChooseVehicleMenu then + if (attachAction ~= nil and command == attachAction) + or (chooseAction ~= nil and command == chooseAction) then return true end end @@ -820,9 +1099,13 @@ end local function menuHasTowbarDetachSlice(menu) if menu == nil or menu.slices == nil then return false end + local detachAction = TowBarMod and TowBarMod.Hook and TowBarMod.Hook.deattachTowBarAction or nil + if detachAction == nil then + return false + end for _, slice in ipairs(menu.slices) do local command = slice.command and slice.command[1] - if command == TowBarMod.Hook.deattachTowBarAction then + if command == detachAction then return true end end @@ -971,7 +1254,18 @@ end local function addLandtrainHookOptionToMenu(playerObj, vehicle) if playerObj == nil or vehicle == nil then return end - if not playerObj:getInventory():getItemFromTypeRecurse("TowBar.TowBar") then + local hasTowBarItem = false + if playerObj.getInventory and playerObj:getInventory() ~= nil then + hasTowBarItem = playerObj:getInventory():getItemFromTypeRecurse("TowBar.TowBar") ~= nil + end + if not hasTowBarItem and TowBarMod and TowBarMod.Hook and TowBarMod.Hook.getTowBarInventoryItem then + hasTowBarItem = TowBarMod.Hook.getTowBarInventoryItem(playerObj) ~= nil + end + if not hasTowBarItem and TowBarMod and TowBarMod.Utils and TowBarMod.Utils.getTowBarInventoryItem then + hasTowBarItem = TowBarMod.Utils.getTowBarInventoryItem(playerObj) ~= nil + end + + if not hasTowBarItem then ltLog("addLandtrainHookOptionToMenu no TowBar item for " .. vehLabel(vehicle)) return end @@ -1093,7 +1387,7 @@ local function installLandtrainTowbarPatch() if towedVehicle then refreshTowBarState(towedVehicle:getVehicleTowing()) end - queueLandtrainTowbarRestore(towedVehicle) + saveActiveLandtrainTowbarSnapshot() end TowBarMod.Hook.performAttachTowBar = performAttachWrapper @@ -1166,8 +1460,7 @@ local function installLandtrainTowbarPatch() refreshTowBarState(resolvedTowingVehicle:getVehicleTowing()) refreshTowBarState(resolvedTowedVehicle:getVehicleTowedBy()) refreshTowBarState(resolvedTowedVehicle:getVehicleTowing()) - queueLandtrainTowbarRestore(resolvedTowingVehicle) - queueLandtrainTowbarRestore(resolvedTowedVehicle) + saveActiveLandtrainTowbarSnapshot() dumpTowState("performDeattachTowBar post-reconcile towing", resolvedTowingVehicle) dumpTowState("performDeattachTowBar post-reconcile towed", resolvedTowedVehicle) @@ -1202,7 +1495,7 @@ local function installLandtrainTowbarPatch() ltLog("installLandtrainTowbarPatch patched ISVehicleMenu.onDetachTrailer") end - -- Towbar UI only adds attach when fully unlinked; add attach option for linked vehicles too. + -- Towbar UI only adds attach when fully unlinked; add attach for linked/chain-related interactions too. if ISVehicleMenu and ISVehicleMenu.showRadialMenu and ISVehicleMenu.showRadialMenu ~= TowBarMod.UI._landtrainShowRadialWrapper then local originalShowRadialMenu = ISVehicleMenu.showRadialMenu local showRadialWrapper = function(playerObj) @@ -1228,7 +1521,15 @@ local function installLandtrainTowbarPatch() return end - if (vehicle:getVehicleTowing() or vehicle:getVehicleTowedBy()) then + local vehicleLinked = vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil + local chainRelated = isTowbarChainVehicleOrNeighbor(vehicle) + local shouldInjectAttach = vehicleLinked or chainRelated + ltLog("showRadial attach check vehicle=" .. vehLabel(vehicle) + .. " linked=" .. tostring(vehicleLinked) + .. " chainRelated=" .. tostring(chainRelated) + .. " shouldInject=" .. tostring(shouldInjectAttach)) + + if shouldInjectAttach then addLandtrainHookOptionToMenu(playerObj, vehicle) else ltLog("showRadial vehicle not linked, Landtrain attach not injected for " .. vehLabel(vehicle)) @@ -1299,7 +1600,5 @@ end Events.OnGameBoot.Add(ensureTowAttachmentsForTrailers) Events.OnGameBoot.Add(startLandtrainInstallWatchdog) Events.OnGameStart.Add(startLandtrainInstallWatchdog) -Events.OnGameStart.Add(scheduleLandtrainLoadSanity) -Events.OnGameStart.Add(queueLandtrainLoadedTowbarRestores) -Events.OnSpawnVehicleEnd.Add(onLandtrainSpawnVehicleEnd) -Events.OnTick.Add(processLandtrainPendingRestores) +Events.OnGameStart.Add(scheduleLandtrainSingleLoadRestore) +Events.OnTick.Add(onLandtrainSaveSnapshotTick)