From 19ad4b57552496e887cfb75bb2f8c20c5873efa1 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Fri, 13 Feb 2026 11:17:31 -0500 Subject: [PATCH] Loading works --- .../LandtrainTowbarChainsRebuild.lua | 598 +++++++++++++++++- .../Landtrain/LandtrainTowSyncServer.lua | 307 +++++++++ 2 files changed, 900 insertions(+), 5 deletions(-) diff --git a/42.13/media/lua/client/Landtrain/LandtrainTowbarChainsRebuild.lua b/42.13/media/lua/client/Landtrain/LandtrainTowbarChainsRebuild.lua index 371273d..5ecd3c3 100644 --- a/42.13/media/lua/client/Landtrain/LandtrainTowbarChainsRebuild.lua +++ b/42.13/media/lua/client/Landtrain/LandtrainTowbarChainsRebuild.lua @@ -12,6 +12,13 @@ local MAX_DIST_SQ = 3.25 local MAX_DZ = 0.9 local tmpA = Vector3f.new() local tmpB = Vector3f.new() +local PERSIST_FRONT_SQL_KEY = "landtrainFrontTowSqlId" +local PERSIST_FRONT_ID_KEY = "landtrainFrontTowId" +local PERSIST_ATTACHMENT_A_KEY = "landtrainFrontTowAttachmentA" +local PERSIST_ATTACHMENT_B_KEY = "landtrainFrontTowAttachmentB" +local PERSIST_CHAIN_HEAD_SQL_KEY = "landtrainChainHeadSql" +local PERSIST_CHAIN_ORDER_KEY = "landtrainChainOrder" +local PERSIST_CHAIN_TOKEN_KEY = "landtrainChainToken" local function log(msg) print("[Landtrain] " .. tostring(msg)) @@ -177,12 +184,258 @@ local function refreshAround(vehicle) refreshTowBarState(vehicle:getVehicleTowing()) end +local function clearPersistedFrontLinkData(rearVehicle) + if not rearVehicle then return end + local md = rearVehicle:getModData() + if not md then return end + + local changed = false + if md[PERSIST_FRONT_SQL_KEY] ~= nil then + md[PERSIST_FRONT_SQL_KEY] = nil + changed = true + end + if md[PERSIST_FRONT_ID_KEY] ~= nil then + md[PERSIST_FRONT_ID_KEY] = nil + changed = true + end + if md[PERSIST_ATTACHMENT_A_KEY] ~= nil then + md[PERSIST_ATTACHMENT_A_KEY] = nil + changed = true + end + if md[PERSIST_ATTACHMENT_B_KEY] ~= nil then + md[PERSIST_ATTACHMENT_B_KEY] = nil + changed = true + end + + if changed then + rearVehicle:transmitModData() + end +end + +local function storePersistedFrontLinkData(frontVehicle, rearVehicle, attachmentA, attachmentB) + if not frontVehicle or not rearVehicle then return end + local md = rearVehicle:getModData() + if not md then return end + + local frontSql = frontVehicle.getSqlId and tonumber(frontVehicle:getSqlId()) or nil + local frontId = frontVehicle:getId() + local resolvedAttachmentA = attachmentA or "trailer" + local resolvedAttachmentB = attachmentB or "trailerfront" + local changed = false + + if frontSql and frontSql > 0 then + if tonumber(md[PERSIST_FRONT_SQL_KEY]) ~= frontSql then + md[PERSIST_FRONT_SQL_KEY] = frontSql + changed = true + end + end + + if tonumber(md[PERSIST_FRONT_ID_KEY]) ~= frontId then + md[PERSIST_FRONT_ID_KEY] = frontId + changed = true + end + if md[PERSIST_ATTACHMENT_A_KEY] ~= resolvedAttachmentA then + md[PERSIST_ATTACHMENT_A_KEY] = resolvedAttachmentA + changed = true + end + if md[PERSIST_ATTACHMENT_B_KEY] ~= resolvedAttachmentB then + md[PERSIST_ATTACHMENT_B_KEY] = resolvedAttachmentB + changed = true + end + + if changed then + rearVehicle:transmitModData() + end +end + +local function storePersistedChainOrderData(vehicle, headSql, orderIndex, chainToken) + if not vehicle then return end + local md = vehicle:getModData() + if not md then return end + + local resolvedHeadSql = headSql and tonumber(headSql) or nil + local resolvedOrder = orderIndex and tonumber(orderIndex) or nil + local resolvedToken = chainToken and tostring(chainToken) or nil + if resolvedToken == "" then resolvedToken = nil end + local changed = false + + if resolvedHeadSql and resolvedHeadSql > 0 then + if tonumber(md[PERSIST_CHAIN_HEAD_SQL_KEY]) ~= resolvedHeadSql then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = resolvedHeadSql + changed = true + end + elseif md[PERSIST_CHAIN_HEAD_SQL_KEY] ~= nil then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = nil + changed = true + end + + if resolvedOrder and resolvedOrder > 0 then + if tonumber(md[PERSIST_CHAIN_ORDER_KEY]) ~= resolvedOrder then + md[PERSIST_CHAIN_ORDER_KEY] = resolvedOrder + changed = true + end + elseif md[PERSIST_CHAIN_ORDER_KEY] ~= nil then + md[PERSIST_CHAIN_ORDER_KEY] = nil + changed = true + end + + if resolvedToken then + if tostring(md[PERSIST_CHAIN_TOKEN_KEY]) ~= resolvedToken then + md[PERSIST_CHAIN_TOKEN_KEY] = resolvedToken + changed = true + end + elseif md[PERSIST_CHAIN_TOKEN_KEY] ~= nil then + md[PERSIST_CHAIN_TOKEN_KEY] = nil + changed = true + end + + if changed then + vehicle:transmitModData() + end +end + +local function clearPersistedChainOrderData(vehicle) + if not vehicle then return end + local md = vehicle:getModData() + if not md then return end + + local changed = false + if md[PERSIST_CHAIN_HEAD_SQL_KEY] ~= nil then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = nil + changed = true + end + if md[PERSIST_CHAIN_ORDER_KEY] ~= nil then + md[PERSIST_CHAIN_ORDER_KEY] = nil + changed = true + end + if md[PERSIST_CHAIN_TOKEN_KEY] ~= nil then + md[PERSIST_CHAIN_TOKEN_KEY] = nil + changed = true + end + + if changed then + vehicle:transmitModData() + end +end + +local function getLoadedVehicles() + local results = {} + local cell = getCell() + if not cell then return results end + local list = cell:getVehicles() + if not list then return results end + + for i = 0, list:size() - 1 do + local vehicle = list:get(i) + if vehicle then + table.insert(results, vehicle) + end + end + return results +end + +local function indexVehiclesBySql(vehicles) + local bySql = {} + for _, vehicle in ipairs(vehicles) do + local sqlId = vehicle and vehicle.getSqlId and tonumber(vehicle:getSqlId()) or nil + if sqlId and sqlId > 0 then + bySql[sqlId] = vehicle + end + end + return bySql +end + +local function hasValidTowLink(vehicle) + if not vehicle then return false end + local rear = vehicle:getVehicleTowing() + if rear and rear:getVehicleTowedBy() == vehicle then + return true + end + local front = vehicle:getVehicleTowedBy() + if front and front:getVehicleTowing() == vehicle then + return true + end + return false +end + +local function snapshotChainFromHead(headVehicle, visitedIds) + if not headVehicle then return end + + local headSql = headVehicle.getSqlId and tonumber(headVehicle:getSqlId()) or nil + if not (headSql and headSql > 0) then + headSql = nil + end + local headMd = headVehicle:getModData() + local chainToken = headMd and tostring(headMd[PERSIST_CHAIN_TOKEN_KEY] or "") or "" + if chainToken == "" then + if headSql then + chainToken = "sql:" .. tostring(headSql) + else + chainToken = "veh:" .. tostring(headVehicle:getId()) + end + end + if headVehicle:getVehicleTowedBy() == nil then + clearPersistedFrontLinkData(headVehicle) + end + + local cursor = headVehicle + local order = 1 + local localVisited = {} + while cursor do + local cursorId = cursor:getId() + if visitedIds[cursorId] or localVisited[cursorId] then + break + end + + visitedIds[cursorId] = true + localVisited[cursorId] = true + storePersistedChainOrderData(cursor, headSql, order, chainToken) + + local rearVehicle = cursor:getVehicleTowing() + if rearVehicle and rearVehicle:getVehicleTowedBy() == cursor then + local attachmentA, attachmentB = resolvePair(cursor, rearVehicle) + storePersistedFrontLinkData(cursor, rearVehicle, attachmentA, attachmentB) + end + + cursor = rearVehicle + order = order + 1 + end +end + +local function snapshotActiveTowLinks() + local vehicles = getLoadedVehicles() + if #vehicles == 0 then return end + + local visitedIds = {} + + for _, vehicle in ipairs(vehicles) do + local vehicleId = vehicle and vehicle:getId() or nil + if vehicleId and not visitedIds[vehicleId] and vehicle:getVehicleTowedBy() == nil then + local rearVehicle = vehicle:getVehicleTowing() + if rearVehicle and rearVehicle:getVehicleTowedBy() == vehicle then + snapshotChainFromHead(vehicle, visitedIds) + end + end + end + + for _, vehicle in ipairs(vehicles) do + local vehicleId = vehicle and vehicle:getId() or nil + if vehicleId and not visitedIds[vehicleId] and hasValidTowLink(vehicle) then + snapshotChainFromHead(vehicle, visitedIds) + end + end +end + local function restoreDefaultsIfLeadLost(vehicle) if not vehicle then return end if vehicle:getVehicleTowedBy() ~= nil then return end clearChangedOffset(vehicle) restoreTowBarDefaults(vehicle) + clearPersistedFrontLinkData(vehicle) + if vehicle:getVehicleTowing() == nil then + clearPersistedChainOrderData(vehicle) + end local md = vehicle:getModData() if md then @@ -234,23 +487,41 @@ local function markTowBarPair(towingVehicle, towedVehicle) local towedMd = towedVehicle and towedVehicle:getModData() or nil if towingMd then - towingMd["isTowingByTowBar"] = true - towingVehicle:transmitModData() + local towingChanged = false + if towingMd["isTowingByTowBar"] ~= true then + towingMd["isTowingByTowBar"] = true + towingChanged = true + end + if towingChanged then + towingVehicle:transmitModData() + end end if towedMd then + local towedChanged = false local currentScript = towedVehicle and towedVehicle:getScriptName() or nil if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then towedMd.towBarOriginalScriptName = currentScript + towedChanged = true end if towedMd.towBarOriginalMass == nil and towedVehicle then towedMd.towBarOriginalMass = towedVehicle:getMass() + towedChanged = true end if towedMd.towBarOriginalBrakingForce == nil and towedVehicle then towedMd.towBarOriginalBrakingForce = towedVehicle:getBrakingForce() + towedChanged = true + end + if towedMd["isTowingByTowBar"] ~= true then + towedMd["isTowingByTowBar"] = true + towedChanged = true + end + if towedMd["towed"] ~= true then + towedMd["towed"] = true + towedChanged = true + end + if towedChanged then + towedVehicle:transmitModData() end - towedMd["isTowingByTowBar"] = true - towedMd["towed"] = true - towedVehicle:transmitModData() end end @@ -278,6 +549,7 @@ local function ensurePairAttached(playerObj, towingVehicle, towedVehicle, prefer applyRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB) towedVehicle:setScriptName("notTowingA_Trailer") markTowBarPair(towingVehicle, towedVehicle) + storePersistedFrontLinkData(towingVehicle, towedVehicle, attachmentA, attachmentB) local linked = isLinkedPair(towingVehicle, towedVehicle) log("ensurePairAttached pair=" .. tostring(vehicleId(towingVehicle)) .. "->" .. tostring(vehicleId(towedVehicle)) @@ -297,6 +569,7 @@ local function ensurePairAttached(playerObj, towingVehicle, towedVehicle, prefer setTowBarModelVisible(towedVehicle, true) refreshAround(towingVehicle) refreshAround(towedVehicle) + snapshotActiveTowLinks() return true end @@ -564,6 +837,7 @@ LT.performDetachWrapper = function(playerObj, towingVehicle, towedVehicle) queueAction(playerObj, 12, function(_, v) restoreDefaultsIfLeadLost(v); refreshAround(v) end, resolvedTowed) setTowBarModelVisible(resolvedTowed, false) + snapshotActiveTowLinks() end LT.showRadialWrapper = function(playerObj) @@ -611,6 +885,319 @@ LT.onDetachTrailerWrapper = function(playerObj, vehicle, ...) end end +local function collectPersistedLinks(vehicles) + local links = {} + + for _, rearVehicle in ipairs(vehicles) do + local md = rearVehicle and rearVehicle:getModData() or nil + local frontSql = md and tonumber(md[PERSIST_FRONT_SQL_KEY]) or nil + local headSql = md and tonumber(md[PERSIST_CHAIN_HEAD_SQL_KEY]) or nil + local rearOrder = md and tonumber(md[PERSIST_CHAIN_ORDER_KEY]) or nil + local chainToken = md and tostring(md[PERSIST_CHAIN_TOKEN_KEY] or "") or "" + if not (headSql and headSql > 0) then headSql = nil end + if not (rearOrder and rearOrder >= 2) then rearOrder = nil end + if chainToken == "" then chainToken = nil end + if (frontSql and frontSql > 0) or ((headSql or chainToken) and rearOrder) then + local rearSql = rearVehicle.getSqlId and tonumber(rearVehicle:getSqlId()) or nil + local link = { + rear = rearVehicle, + rearSql = rearSql, + frontSql = (frontSql and frontSql > 0) and frontSql or nil, + headSql = headSql, + rearOrder = rearOrder, + chainToken = chainToken, + attachmentA = md[PERSIST_ATTACHMENT_A_KEY] or "trailer", + attachmentB = md[PERSIST_ATTACHMENT_B_KEY] or "trailerfront" + } + table.insert(links, link) + end + end + + return links +end + +local function computePersistedDepth(link, byRearSql, visitedSql) + if not link or not link.frontSql then return 0 end + visitedSql = visitedSql or {} + if visitedSql[link.frontSql] then return 0 end + + local frontLink = byRearSql[link.frontSql] + if not frontLink then return 0 end + + visitedSql[link.frontSql] = true + return 1 + computePersistedDepth(frontLink, byRearSql, visitedSql) +end + +local function filterAmbiguousPersistedLinks(links) + if #links <= 1 then return links end + + local frontCounts = {} + for _, link in ipairs(links) do + if link.frontSql then + frontCounts[link.frontSql] = (frontCounts[link.frontSql] or 0) + 1 + end + end + + local filtered = {} + local dropped = 0 + for _, link in ipairs(links) do + if link.frontSql and frontCounts[link.frontSql] and frontCounts[link.frontSql] > 1 then + dropped = dropped + 1 + else + table.insert(filtered, link) + end + end + + if dropped > 0 then + log("load rebuild skipped ambiguous links dropped=" .. tostring(dropped)) + end + + return filtered +end + +local function linkOrderKey(link) + if not link then return nil end + if link.headSql and link.headSql > 0 then + return "sql:" .. tostring(link.headSql) + end + if link.chainToken and tostring(link.chainToken) ~= "" then + return "tok:" .. tostring(link.chainToken) + end + return nil +end + +local function filterConflictingOrderedLinks(links) + if #links <= 1 then return links end + + local orderSlots = {} + for _, link in ipairs(links) do + local orderKey = linkOrderKey(link) + if orderKey and link.rearOrder and link.rearOrder >= 2 then + local key = orderKey .. ":" .. tostring(link.rearOrder) + orderSlots[key] = (orderSlots[key] or 0) + 1 + end + end + + local filtered = {} + local dropped = 0 + for _, link in ipairs(links) do + local orderKey = linkOrderKey(link) + if orderKey and link.rearOrder and link.rearOrder >= 2 then + local key = orderKey .. ":" .. tostring(link.rearOrder) + if (orderSlots[key] or 0) > 1 then + dropped = dropped + 1 + else + table.insert(filtered, link) + end + else + table.insert(filtered, link) + end + end + + if dropped > 0 then + log("load rebuild skipped conflicting ordered links dropped=" .. tostring(dropped)) + end + + return filtered +end + +local function indexPersistedLinksByRearSql(links) + local byRearSql = {} + for _, link in ipairs(links) do + if link.rearSql and link.rearSql > 0 then + byRearSql[link.rearSql] = link + end + end + return byRearSql +end + +local function indexVehiclesByHeadAndOrder(vehicles) + local byHead = {} + for _, vehicle in ipairs(vehicles) do + local md = vehicle and vehicle:getModData() or nil + local headSql = md and tonumber(md[PERSIST_CHAIN_HEAD_SQL_KEY]) or nil + local order = md and tonumber(md[PERSIST_CHAIN_ORDER_KEY]) or nil + local chainToken = md and tostring(md[PERSIST_CHAIN_TOKEN_KEY] or "") or "" + if chainToken == "" then chainToken = nil end + local key = nil + if headSql and headSql > 0 then + key = "sql:" .. tostring(headSql) + elseif chainToken then + key = "tok:" .. tostring(chainToken) + end + if key and order and order > 0 then + byHead[key] = byHead[key] or {} + if byHead[key][order] == nil then + byHead[key][order] = vehicle + else + byHead[key][order] = false + end + end + end + return byHead +end + +local function sortPersistedLinks(links, byRearSql) + table.sort(links, function(a, b) + local orderKeyA = linkOrderKey(a) + local orderKeyB = linkOrderKey(b) + if orderKeyA and orderKeyB then + if orderKeyA ~= orderKeyB then + return orderKeyA < orderKeyB + end + if a.rearOrder and b.rearOrder and a.rearOrder ~= b.rearOrder then + return a.rearOrder < b.rearOrder + end + end + + local depthA = computePersistedDepth(a, byRearSql, {}) + local depthB = computePersistedDepth(b, byRearSql, {}) + if depthA == depthB then + return vehicleId(a.rear) < vehicleId(b.rear) + end + return depthA < depthB + end) +end + +local function isTowbarPairStateComplete(frontVehicle, rearVehicle) + if not isLinkedPair(frontVehicle, rearVehicle) then return false end + return rearVehicle:getScriptName() == "notTowingA_Trailer" +end + +local function restoreViaAttachWrapper(playerObj, frontVehicle, rearVehicle, attachmentA, attachmentB) + if not playerObj or not frontVehicle or not rearVehicle then return false end + if not LT.performAttachWrapper then return false end + if not TowBarMod or not TowBarMod.Hook or TowBarMod.Hook._ltOriginalPerformAttach == nil then + return false + end + + local ok, err = pcall(LT.performAttachWrapper, playerObj, frontVehicle, rearVehicle, attachmentA, attachmentB) + if not ok then + log("load rebuild wrapper attach failed pair=" .. tostring(vehicleId(frontVehicle)) .. "->" .. tostring(vehicleId(rearVehicle)) + .. " err=" .. tostring(err)) + return false + end + return true +end + +local function restorePersistedLink(playerObj, link, bySql, byHeadOrder) + if not playerObj or not link then return false end + + local rearVehicle = link.rear + local frontVehicle = nil + if link.frontSql and bySql then + frontVehicle = bySql[link.frontSql] + end + local orderKey = linkOrderKey(link) + if not frontVehicle and orderKey and link.rearOrder and link.rearOrder >= 2 and byHeadOrder then + local byOrder = byHeadOrder[orderKey] + if byOrder then + local candidate = byOrder[link.rearOrder - 1] + if candidate and candidate ~= false then + frontVehicle = candidate + end + end + end + if not rearVehicle or not frontVehicle then return false end + if rearVehicle == frontVehicle or rearVehicle:getId() == frontVehicle:getId() then return false end + + if isTowbarPairStateComplete(frontVehicle, rearVehicle) then + return false + end + + if rearVehicle:getVehicleTowedBy() ~= nil and rearVehicle:getVehicleTowedBy() ~= frontVehicle then + return false + end + if frontVehicle:getVehicleTowing() ~= nil and frontVehicle:getVehicleTowing() ~= rearVehicle then + return false + end + if orderKey and link.rearOrder and link.rearOrder >= 2 then + local frontMd = frontVehicle and frontVehicle:getModData() or nil + local frontHeadSql = frontMd and tonumber(frontMd[PERSIST_CHAIN_HEAD_SQL_KEY]) or nil + local frontOrder = frontMd and tonumber(frontMd[PERSIST_CHAIN_ORDER_KEY]) or nil + local frontChainToken = frontMd and tostring(frontMd[PERSIST_CHAIN_TOKEN_KEY] or "") or "" + if frontChainToken == "" then frontChainToken = nil end + local frontOrderKey = nil + if frontHeadSql and frontHeadSql > 0 then + frontOrderKey = "sql:" .. tostring(frontHeadSql) + elseif frontChainToken then + frontOrderKey = "tok:" .. tostring(frontChainToken) + end + if frontOrderKey ~= orderKey or frontOrder ~= (link.rearOrder - 1) then + return false + end + end + if wouldCreateTowLoop(frontVehicle, rearVehicle) then return false end + + if not isLinkedPair(frontVehicle, rearVehicle) then + if not canTowByLandtrain(frontVehicle, rearVehicle, link.attachmentA, link.attachmentB, true) then + return false + end + if restoreViaAttachWrapper(playerObj, frontVehicle, rearVehicle, link.attachmentA, link.attachmentB) then + return true + end + end + + return ensurePairAttached(playerObj, frontVehicle, rearVehicle, link.attachmentA, link.attachmentB, true) +end + +local function runPersistedLoadRebuildPass() + if not TowBarMod or not TowBarMod.Hook or TowBarMod.Hook._ltOriginalPerformAttach == nil then + return + end + + local playerObj = getPlayer() or getSpecificPlayer(0) + if not playerObj then return end + + local vehicles = getLoadedVehicles() + if #vehicles == 0 then return end + + local bySql = indexVehiclesBySql(vehicles) + local byHeadOrder = indexVehiclesByHeadAndOrder(vehicles) + local links = collectPersistedLinks(vehicles) + links = filterAmbiguousPersistedLinks(links) + links = filterConflictingOrderedLinks(links) + if #links == 0 then return end + local byRearSql = indexPersistedLinksByRearSql(links) + sortPersistedLinks(links, byRearSql) + + local attempted = 0 + local restored = 0 + for _, link in ipairs(links) do + attempted = attempted + 1 + if restorePersistedLink(playerObj, link, bySql, byHeadOrder) then + restored = restored + 1 + end + end + + if restored > 0 then + log("load rebuild pass restored=" .. tostring(restored) .. "/" .. tostring(attempted)) + end +end + +local loadRebuildWarmupTicks = 0 +local loadRebuildIntervalTicks = 0 + +local function loadRebuildTick() + if loadRebuildWarmupTicks > 0 then + loadRebuildWarmupTicks = loadRebuildWarmupTicks - 1 + return + end + + loadRebuildIntervalTicks = loadRebuildIntervalTicks + 1 + if loadRebuildIntervalTicks < 180 then return end + loadRebuildIntervalTicks = 0 + + runPersistedLoadRebuildPass() +end + +local function startLoadRebuildWatch() + loadRebuildWarmupTicks = 120 + loadRebuildIntervalTicks = 0 + Events.OnTick.Remove(loadRebuildTick) + Events.OnTick.Add(loadRebuildTick) +end + local function ensureTrailerAttachments() local sm = getScriptManager() if sm == nil then return end @@ -708,6 +1295,7 @@ local function startWatchdog() Events.OnTick.Remove(watchdogTick) Events.OnTick.Add(watchdogTick) installPatch() + startLoadRebuildWatch() end Events.OnGameBoot.Add(ensureTrailerAttachments) diff --git a/42.13/media/lua/server/Landtrain/LandtrainTowSyncServer.lua b/42.13/media/lua/server/Landtrain/LandtrainTowSyncServer.lua index 8043bb1..6cf52e2 100644 --- a/42.13/media/lua/server/Landtrain/LandtrainTowSyncServer.lua +++ b/42.13/media/lua/server/Landtrain/LandtrainTowSyncServer.lua @@ -6,12 +6,193 @@ if TowBarMod.Landtrain._towSyncServerLoaded then return end TowBarMod.Landtrain._towSyncServerLoaded = true local SYNC_DELAY_TICKS = 2 +local SNAPSHOT_INTERVAL_TICKS = 120 local pending = {} +local snapshotTickCounter = 0 +local PERSIST_FRONT_SQL_KEY = "landtrainFrontTowSqlId" +local PERSIST_FRONT_ID_KEY = "landtrainFrontTowId" +local PERSIST_ATTACHMENT_A_KEY = "landtrainFrontTowAttachmentA" +local PERSIST_ATTACHMENT_B_KEY = "landtrainFrontTowAttachmentB" +local PERSIST_CHAIN_HEAD_SQL_KEY = "landtrainChainHeadSql" +local PERSIST_CHAIN_ORDER_KEY = "landtrainChainOrder" +local PERSIST_CHAIN_TOKEN_KEY = "landtrainChainToken" local function log(msg) print("[Landtrain][TowSyncServer] " .. tostring(msg)) end +local function storePersistedFrontLinkData(frontVehicle, rearVehicle, attachmentA, attachmentB) + if not frontVehicle or not rearVehicle then return end + local md = rearVehicle:getModData() + if not md then return end + + local frontSql = frontVehicle.getSqlId and tonumber(frontVehicle:getSqlId()) or nil + local frontId = frontVehicle:getId() + local resolvedAttachmentA = attachmentA or "trailer" + local resolvedAttachmentB = attachmentB or "trailerfront" + local changed = false + + if frontSql and frontSql > 0 then + if tonumber(md[PERSIST_FRONT_SQL_KEY]) ~= frontSql then + md[PERSIST_FRONT_SQL_KEY] = frontSql + changed = true + end + end + + if tonumber(md[PERSIST_FRONT_ID_KEY]) ~= frontId then + md[PERSIST_FRONT_ID_KEY] = frontId + changed = true + end + if md[PERSIST_ATTACHMENT_A_KEY] ~= resolvedAttachmentA then + md[PERSIST_ATTACHMENT_A_KEY] = resolvedAttachmentA + changed = true + end + if md[PERSIST_ATTACHMENT_B_KEY] ~= resolvedAttachmentB then + md[PERSIST_ATTACHMENT_B_KEY] = resolvedAttachmentB + changed = true + end + + if changed then + rearVehicle:transmitModData() + end +end + +local function storePersistedChainOrderData(vehicle, headSql, orderIndex, chainToken) + if not vehicle then return end + local md = vehicle:getModData() + if not md then return end + + local resolvedHeadSql = headSql and tonumber(headSql) or nil + local resolvedOrder = orderIndex and tonumber(orderIndex) or nil + local resolvedToken = chainToken and tostring(chainToken) or nil + if resolvedToken == "" then resolvedToken = nil end + local changed = false + + if resolvedHeadSql and resolvedHeadSql > 0 then + if tonumber(md[PERSIST_CHAIN_HEAD_SQL_KEY]) ~= resolvedHeadSql then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = resolvedHeadSql + changed = true + end + elseif md[PERSIST_CHAIN_HEAD_SQL_KEY] ~= nil then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = nil + changed = true + end + + if resolvedOrder and resolvedOrder > 0 then + if tonumber(md[PERSIST_CHAIN_ORDER_KEY]) ~= resolvedOrder then + md[PERSIST_CHAIN_ORDER_KEY] = resolvedOrder + changed = true + end + elseif md[PERSIST_CHAIN_ORDER_KEY] ~= nil then + md[PERSIST_CHAIN_ORDER_KEY] = nil + changed = true + end + + if resolvedToken then + if tostring(md[PERSIST_CHAIN_TOKEN_KEY]) ~= resolvedToken then + md[PERSIST_CHAIN_TOKEN_KEY] = resolvedToken + changed = true + end + elseif md[PERSIST_CHAIN_TOKEN_KEY] ~= nil then + md[PERSIST_CHAIN_TOKEN_KEY] = nil + changed = true + end + + if changed then + vehicle:transmitModData() + end +end + +local function clearPersistedChainOrderData(vehicle) + if not vehicle then return end + local md = vehicle:getModData() + if not md then return end + + local changed = false + if md[PERSIST_CHAIN_HEAD_SQL_KEY] ~= nil then + md[PERSIST_CHAIN_HEAD_SQL_KEY] = nil + changed = true + end + if md[PERSIST_CHAIN_ORDER_KEY] ~= nil then + md[PERSIST_CHAIN_ORDER_KEY] = nil + changed = true + end + if md[PERSIST_CHAIN_TOKEN_KEY] ~= nil then + md[PERSIST_CHAIN_TOKEN_KEY] = nil + changed = true + end + + if changed then + vehicle:transmitModData() + end +end + +local function clearPersistedFrontLinkData(rearVehicle) + if not rearVehicle then return end + local md = rearVehicle:getModData() + if not md then return end + + local changed = false + if md[PERSIST_FRONT_SQL_KEY] ~= nil then + md[PERSIST_FRONT_SQL_KEY] = nil + changed = true + end + if md[PERSIST_FRONT_ID_KEY] ~= nil then + md[PERSIST_FRONT_ID_KEY] = nil + changed = true + end + if md[PERSIST_ATTACHMENT_A_KEY] ~= nil then + md[PERSIST_ATTACHMENT_A_KEY] = nil + changed = true + end + if md[PERSIST_ATTACHMENT_B_KEY] ~= nil then + md[PERSIST_ATTACHMENT_B_KEY] = nil + changed = true + end + + if changed then + rearVehicle:transmitModData() + end +end + +local function resolveDetachPair(vehicleA, vehicleB) + local front = vehicleA + local rear = vehicleB + + if front and rear then + if front:getVehicleTowing() == rear and rear:getVehicleTowedBy() == front then + return front, rear + end + if rear:getVehicleTowing() == front and front:getVehicleTowedBy() == rear then + return rear, front + end + end + + if front and not rear then + local directRear = front:getVehicleTowing() + if directRear and directRear:getVehicleTowedBy() == front then + return front, directRear + end + local directFront = front:getVehicleTowedBy() + if directFront and directFront:getVehicleTowing() == front then + return directFront, front + end + end + + if rear and not front then + local directFront = rear:getVehicleTowedBy() + if directFront and directFront:getVehicleTowing() == rear then + return directFront, rear + end + local directRear = rear:getVehicleTowing() + if directRear and directRear:getVehicleTowedBy() == rear then + return rear, directRear + end + end + + return nil, nil +end + local function queueSync(kind, player, args) if not args then return end table.insert(pending, { @@ -33,6 +214,19 @@ local function isLinked(vehicleA, vehicleB) return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA end +local function hasValidTowLink(vehicle) + if not vehicle then return false end + local rear = vehicle:getVehicleTowing() + if rear and rear:getVehicleTowedBy() == vehicle then + return true + end + local front = vehicle:getVehicleTowedBy() + if front and front:getVehicleTowing() == vehicle then + return true + end + return false +end + local function resolveAttachmentA(args, vehicleA) if args and args.attachmentA then return args.attachmentA end if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end @@ -45,6 +239,99 @@ local function resolveAttachmentB(args, vehicleB) return "trailerfront" end +local function resolveSnapshotAttachmentPair(vehicleA, vehicleB) + local selfA = vehicleA and vehicleA:getTowAttachmentSelf() or nil + local selfB = vehicleB and vehicleB:getTowAttachmentSelf() or nil + local candidates = { + { selfA, selfB }, + { "trailer", "trailerfront" }, + { "trailerfront", "trailer" } + } + + for _, pair in ipairs(candidates) do + local attachmentA = pair[1] + local attachmentB = pair[2] + if attachmentA and attachmentB and attachmentA ~= attachmentB + and vehicleHasAttachment(vehicleA, attachmentA) and vehicleHasAttachment(vehicleB, attachmentB) then + return attachmentA, attachmentB + end + end + + return "trailer", "trailerfront" +end + +local function snapshotChainFromHead(headVehicle, visitedIds) + if not headVehicle then return end + + local headSql = headVehicle.getSqlId and tonumber(headVehicle:getSqlId()) or nil + if not (headSql and headSql > 0) then + headSql = nil + end + local headMd = headVehicle:getModData() + local chainToken = headMd and tostring(headMd[PERSIST_CHAIN_TOKEN_KEY] or "") or "" + if chainToken == "" then + if headSql then + chainToken = "sql:" .. tostring(headSql) + else + chainToken = "veh:" .. tostring(headVehicle:getId()) + end + end + if headVehicle:getVehicleTowedBy() == nil then + clearPersistedFrontLinkData(headVehicle) + end + + local cursor = headVehicle + local order = 1 + local localVisited = {} + while cursor do + local cursorId = cursor:getId() + if visitedIds[cursorId] or localVisited[cursorId] then + break + end + + visitedIds[cursorId] = true + localVisited[cursorId] = true + storePersistedChainOrderData(cursor, headSql, order, chainToken) + + local rearVehicle = cursor:getVehicleTowing() + if rearVehicle and rearVehicle:getVehicleTowedBy() == cursor then + local attachmentA, attachmentB = resolveSnapshotAttachmentPair(cursor, rearVehicle) + storePersistedFrontLinkData(cursor, rearVehicle, attachmentA, attachmentB) + end + + cursor = rearVehicle + order = order + 1 + end +end + +local function snapshotActiveTowLinksServer() + local cell = getCell() + if not cell then return end + local list = cell:getVehicles() + if not list then return end + + local visitedIds = {} + + for i = 0, list:size() - 1 do + local vehicle = list:get(i) + local vehicleId = vehicle and vehicle:getId() or nil + if vehicleId and not visitedIds[vehicleId] and vehicle:getVehicleTowedBy() == nil then + local rearVehicle = vehicle:getVehicleTowing() + if rearVehicle and rearVehicle:getVehicleTowedBy() == vehicle then + snapshotChainFromHead(vehicle, visitedIds) + end + end + end + + for i = 0, list:size() - 1 do + local vehicle = list:get(i) + local vehicleId = vehicle and vehicle:getId() or nil + if vehicleId and not visitedIds[vehicleId] and hasValidTowLink(vehicle) then + snapshotChainFromHead(vehicle, visitedIds) + end + end +end + local function processAttach(item) local args = item.args or {} local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil @@ -74,6 +361,9 @@ local function processAttach(item) log("attach sync already linked A=" .. tostring(vehicleA:getId()) .. " B=" .. tostring(vehicleB:getId())) end + storePersistedFrontLinkData(vehicleA, vehicleB, attachmentA, attachmentB) + snapshotActiveTowLinksServer() + log("attach sync broadcast A=" .. tostring(vehicleA:getId()) .. " B=" .. tostring(vehicleB:getId()) .. " attachmentA=" .. tostring(attachmentA) .. " attachmentB=" .. tostring(attachmentB)) sendServerCommand("landtrain", "forceAttachSync", { @@ -88,14 +378,31 @@ local function processDetach(item) local args = item.args or {} local vehicleAId = args.towingVehicle or args.vehicleA or args.vehicle local vehicleBId = args.vehicleB + local vehicleA = vehicleAId and getVehicleById(vehicleAId) or nil + local vehicleB = vehicleBId and getVehicleById(vehicleBId) or nil + local _, resolvedRear = resolveDetachPair(vehicleA, vehicleB) + if resolvedRear then + clearPersistedFrontLinkData(resolvedRear) + clearPersistedChainOrderData(resolvedRear) + elseif vehicleB then + clearPersistedFrontLinkData(vehicleB) + clearPersistedChainOrderData(vehicleB) + end sendServerCommand("landtrain", "forceDetachSync", { vehicleA = vehicleAId, vehicleB = vehicleBId }) + snapshotActiveTowLinksServer() end local function processPending() + snapshotTickCounter = snapshotTickCounter + 1 + if snapshotTickCounter >= SNAPSHOT_INTERVAL_TICKS then + snapshotTickCounter = 0 + snapshotActiveTowLinksServer() + end + if #pending == 0 then return end local remaining = {}