diff --git a/scripts/adapters/RmObjectStorageAdapter.lua b/scripts/adapters/RmObjectStorageAdapter.lua index a30842f..64c76c6 100644 --- a/scripts/adapters/RmObjectStorageAdapter.lua +++ b/scripts/adapters/RmObjectStorageAdapter.lua @@ -525,7 +525,7 @@ end -- ============================================================================= --- Hook for removeAbstractObjectFromStorage - transfers batches when object spawns ---- Strategy: Snapshot existing containers -> superFunc -> find new container -> transfer +--- Uses the game's spawn callback to keep the transfer tied to the exact object ---@param superFunc function Original function ---@param abstractObject table The abstract object being spawned ---@param x number Spawn X position @@ -563,62 +563,55 @@ function RmObjectStorageAdapter:removeAbstractObjectFromStorageHook(superFunc, a storedContainerId, sourceBatches and #sourceBatches or 0) end - -- Snapshot existing bale/vehicle containerIds BEFORE spawn - -- The spawned entity will register during superFunc - local existingContainerIds = {} - if className == "Bale" then - existingContainerIds = RmObjectStorageAdapter.snapshotBaleContainerIds() - elseif className == "Vehicle" then - existingContainerIds = RmObjectStorageAdapter.snapshotVehicleContainerIds() - end - Log:trace(" snapshot: %d existing containers", RmObjectStorageAdapter.tableCount(existingContainerIds)) - - -- Call original (spawns entity, which registers with its adapter) - superFunc(self, abstractObject, x, y, z, rx, ry, rz) - - -- Find newly registered containerId (not in snapshot) - local destContainerId = nil - if className == "Bale" then - destContainerId = RmObjectStorageAdapter.findNewBaleContainerId(existingContainerIds) - elseif className == "Vehicle" then - destContainerId = RmObjectStorageAdapter.findNewVehicleContainerId(existingContainerIds) + local transfer = nil + if storedContainerId and sourceBatches and #sourceBatches > 0 then + local batches = {} + for _, batch in ipairs(sourceBatches) do + batches[#batches + 1] = { + amount = batch.amount, + ageInPeriods = batch.ageInPeriods + } + end + transfer = { + sourceContainerId = storedContainerId, + batches = batches, + className = className + } end - if destContainerId and sourceBatches and #sourceBatches > 0 then - -- Clear BaleAdapter's initial batch before adding stored batches - -- (BaleAdapter creates age=0 batch on spawn, we replace with aged batch) - RmFreshManager:clearBatches(destContainerId) + if transfer then + -- removeFromStorage receives this callback by value, so the original can be + -- restored as soon as superFunc returns even when the spawn finishes later. + local originalSpawnCallback = PlaceableObjectStorage.onObjectFromStorageSpawned + PlaceableObjectStorage.onObjectFromStorageSpawned = function(placeable, spawnedObject) + originalSpawnCallback(placeable, spawnedObject) + RmObjectStorageAdapter.attachPendingExitTransfer(spawnedObject, transfer) + end - -- Transfer batches from stored -> spawned - local batchCount = 0 - local totalAmount = 0 + local success, errorMessage = pcall(superFunc, self, abstractObject, x, y, z, rx, ry, rz) + PlaceableObjectStorage.onObjectFromStorageSpawned = originalSpawnCallback - for _, batch in ipairs(sourceBatches) do - RmFreshManager:addBatch(destContainerId, batch.amount, batch.ageInPeriods) - batchCount = batchCount + 1 - totalAmount = totalAmount + batch.amount + if not success then + error(errorMessage, 0) end - - Log:info("OBJECTSTORAGE_EXIT: stored -> %s (%d batches, %.0fL transferred)", - className, batchCount, totalAmount) - Log:debug("OBJECTSTORAGE_EXIT_DETAIL: source=%s -> dest=%s", - storedContainerId, destContainerId) - elseif destContainerId then - Log:debug("OBJECTSTORAGE_EXIT: %s spawned (no batches to transfer)", className) else - -- Spawn might be async - schedule deferred check - if storedContainerId and sourceBatches and #sourceBatches > 0 then - Log:trace(" spawn async, scheduling deferred transfer") - RmObjectStorageAdapter.scheduleDeferredExitTransfer( - self, abstractObject, storedContainerId, sourceBatches, className) - end + superFunc(self, abstractObject, x, y, z, rx, ry, rz) end - -- Cleanup stored container + -- The abstract object has left storage, so remove its local lookup entries. if storedContainerId then spec.abstractObjectContainers[abstractObject] = nil - RmFreshManager:unregisterContainer(storedContainerId) - Log:trace(" unregistered stored container: %s", storedContainerId) + for key, containerId in pairs(spec.containerIds) do + if containerId == storedContainerId then + spec.containerIds[key] = nil + end + end + + -- Empty containers have no batch handoff to claim later. + if transfer == nil then + RmFreshManager:unregisterContainer(storedContainerId) + Log:trace(" unregistered empty stored container: %s", storedContainerId) + end else -- Log warning if we expected to find a containerId (perishable class) -- Non-perishable items won't have containers, so only warn for perishables @@ -631,138 +624,37 @@ function RmObjectStorageAdapter:removeAbstractObjectFromStorageHook(superFunc, a Log:trace("<<< removeAbstractObjectFromStorageHook: done") end ---- Snapshot current bale containerIds from Manager ----@return table Set of existing containerIds -function RmObjectStorageAdapter.snapshotBaleContainerIds() - local snapshot = {} - local containers = RmFreshManager.containers or {} - for containerId, container in pairs(containers) do - if container.entityType == "bale" then - snapshot[containerId] = true - end +--- Attach saved batches to the exact object returned by the game's spawn callback +---@param spawnedObject table|nil Spawned bale or pallet +---@param transfer table Pending transfer data +---@return boolean claimed True when the destination was already registered +function RmObjectStorageAdapter.attachPendingExitTransfer(spawnedObject, transfer) + if spawnedObject == nil then + Log:warning("OBJECTSTORAGE_EXIT: spawn callback returned no object; batches remain recoverable") + return false end - return snapshot -end ---- Snapshot current vehicle containerIds from Manager ----@return table Set of existing containerIds -function RmObjectStorageAdapter.snapshotVehicleContainerIds() - local snapshot = {} - local containers = RmFreshManager.containers or {} - for containerId, container in pairs(containers) do - if container.entityType == "vehicle" then - snapshot[containerId] = true - end - end - return snapshot -end + spawnedObject.rmFreshPendingObjectStorageTransfer = transfer ---- Find bale containerId that wasn't in snapshot (newly created) ----@param snapshot table Previous containerIds ----@return string|nil containerId New containerId or nil -function RmObjectStorageAdapter.findNewBaleContainerId(snapshot) - local containers = RmFreshManager.containers or {} - for containerId, container in pairs(containers) do - if container.entityType == "bale" and not snapshot[containerId] then - return containerId - end + local destinationContainerId = nil + if transfer.className == "Bale" then + destinationContainerId = RmBaleAdapter:getContainerIdForBale(spawnedObject) + elseif transfer.className == "Vehicle" + and spawnedObject.getFillUnitFillType + and spawnedObject.spec_fillUnit then + local fillTypeIndex = spawnedObject:getFillUnitFillType(1) + destinationContainerId = + RmVehicleAdapter:getContainerIdForFillUnit(spawnedObject, 1, fillTypeIndex) end - return nil -end - ---- Find vehicle containerId that wasn't in snapshot (newly created) ----@param snapshot table Previous containerIds ----@return string|nil containerId New containerId or nil -function RmObjectStorageAdapter.findNewVehicleContainerId(snapshot) - local containers = RmFreshManager.containers or {} - for containerId, container in pairs(containers) do - if container.entityType == "vehicle" and not snapshot[containerId] then - return containerId - end - end - return nil -end - ---- Count table entries ----@param t table|nil ----@return number -function RmObjectStorageAdapter.tableCount(t) - if t == nil then return 0 end - local count = 0 - for _ in pairs(t) do count = count + 1 end - return count -end ---- Schedule deferred exit transfer (for async spawns) ---- Polls for newly registered containers matching className ----@param placeable table The placeable ----@param abstractObject table The abstract object that was spawned ----@param storedContainerId string The stored container ID (already unregistered) ----@param sourceBatches table Array of batches to transfer ----@param className string "Bale" or "Vehicle" -function RmObjectStorageAdapter.scheduleDeferredExitTransfer(placeable, abstractObject, storedContainerId, sourceBatches, - className) - Log:trace(">>> scheduleDeferredExitTransfer(className=%s, batches=%d)", - className, sourceBatches and #sourceBatches or 0) - - -- Snapshot current containers - local existingContainerIds = {} - if className == "Bale" then - existingContainerIds = RmObjectStorageAdapter.snapshotBaleContainerIds() - elseif className == "Vehicle" then - existingContainerIds = RmObjectStorageAdapter.snapshotVehicleContainerIds() + if destinationContainerId then + return RmFreshManager:claimPendingObjectStorageTransfer( + spawnedObject, destinationContainerId, true) end - local startTime = g_currentMission.time - local maxWaitMs = 2000 -- 2 second timeout - - g_currentMission:addUpdateable({ - update = function(self, _dt) - -- Guard: mission teardown - if g_currentMission == nil then - g_currentMission:removeUpdateable(self) - return - end - - -- Look for newly registered container - local destContainerId = nil - if className == "Bale" then - destContainerId = RmObjectStorageAdapter.findNewBaleContainerId(existingContainerIds) - elseif className == "Vehicle" then - destContainerId = RmObjectStorageAdapter.findNewVehicleContainerId(existingContainerIds) - end - - if destContainerId then - -- Clear BaleAdapter's initial batch before adding stored batches - RmFreshManager:clearBatches(destContainerId) - - -- Transfer batches to spawned entity - local batchCount = 0 - local totalAmount = 0 - - for _, batch in ipairs(sourceBatches) do - RmFreshManager:addBatch(destContainerId, batch.amount, batch.ageInPeriods) - batchCount = batchCount + 1 - totalAmount = totalAmount + batch.amount - end - - Log:info("OBJECTSTORAGE_EXIT_DEFERRED: -> %s (%d batches, %.0fL transferred)", - className, batchCount, totalAmount) - Log:debug("OBJECTSTORAGE_EXIT_DEFERRED_DETAIL: dest=%s", destContainerId) - - g_currentMission:removeUpdateable(self) - return - end - - -- Timeout check - if (g_currentMission.time - startTime) > maxWaitMs then - Log:warning("OBJECTSTORAGE_EXIT_TIMEOUT: %s spawn not detected after %dms (batches lost)", - className, maxWaitMs) - g_currentMission:removeUpdateable(self) - return - end - end - }) + Log:trace("OBJECTSTORAGE_EXIT: waiting for spawned %s to register", + transfer.className or "object") + return false end -- ============================================================================= diff --git a/scripts/core/RmFreshManager.lua b/scripts/core/RmFreshManager.lua index ea73c14..0c79f08 100644 --- a/scripts/core/RmFreshManager.lua +++ b/scripts/core/RmFreshManager.lua @@ -441,6 +441,46 @@ RmFreshManager.bulkTransfer = nil -- CONTAINER LIFECYCLE API -- ============================================================================= +--- Apply batches carried by an object that has just left object storage +---@param runtimeEntity table|nil Spawned bale or pallet +---@param destinationContainerId string Newly registered container ID +---@param broadcastUpdate boolean|nil Send an update when the container was already registered +---@return boolean claimed True when a pending transfer was applied +function RmFreshManager:claimPendingObjectStorageTransfer(runtimeEntity, destinationContainerId, broadcastUpdate) + local transfer = runtimeEntity and runtimeEntity.rmFreshPendingObjectStorageTransfer + if transfer == nil then return false end + + local destination = self.containers[destinationContainerId] + if destination == nil then return false end + + destination.batches = {} + + local batchCount = 0 + local totalAmount = 0 + for _, batch in ipairs(transfer.batches or {}) do + destination.batches[#destination.batches + 1] = RmBatch.create(batch.amount, batch.ageInPeriods) + batchCount = batchCount + 1 + totalAmount = totalAmount + (batch.amount or 0) + end + + runtimeEntity.rmFreshPendingObjectStorageTransfer = nil + + local sourceContainerId = transfer.sourceContainerId + if sourceContainerId and sourceContainerId ~= destinationContainerId then + self:unregisterContainer(sourceContainerId) + end + + if broadcastUpdate then + self:broadcastContainerUpdate(destinationContainerId, RmFreshUpdateEvent.OP_UPDATE, { + batches = destination.batches + }) + end + + Log:info("OBJECTSTORAGE_EXIT: restored %d batches (%.0fL) to %s", + batchCount, totalAmount, destinationContainerId) + return true +end + --- Register a container in the Manager --- Called by adapters during their load lifecycle (onLoad, onPostLoad, etc.) --- Reconciles with reconciliationPool OR creates new container @@ -455,6 +495,7 @@ RmFreshManager.bulkTransfer = nil --- - playerCanFill: Can player/vehicles ADD to this container? (Step 4) --- - playerCanEmpty: Can player/vehicles REMOVE from this container? (Step 4) ---@return string|nil containerId The generated or reconciled container ID, nil on error +---@return boolean|nil hasExistingBatches True when saved or transferred batches are already present function RmFreshManager:registerContainer(entityType, identityMatch, runtimeEntity, metadata) -- TRACE: Function entry for AI debugging Log:trace(">>> registerContainer(entityType=%s, hasIdentityMatch=%s, hasRuntimeEntity=%s)", @@ -558,6 +599,7 @@ function RmFreshManager:registerContainer(entityType, identityMatch, runtimeEnti matchedId, entityType, identityMatch.storage.fillTypeName, tostring(playerCanFill), tostring(playerCanEmpty)) + self:claimPendingObjectStorageTransfer(runtimeEntity, matchedId, false) return matchedId, true -- wasReconciled = true end @@ -622,10 +664,16 @@ function RmFreshManager:registerContainer(entityType, identityMatch, runtimeEnti containerId, entityType, identityMatch.storage.fillTypeName, tostring(playerCanFill), tostring(playerCanEmpty)) + -- A bale or pallet leaving object storage may already carry its saved batches. + -- Claim them before the registration event so clients receive the right ages immediately. + local claimedObjectStorageTransfer = + self:claimPendingObjectStorageTransfer(runtimeEntity, containerId, false) + -- Broadcast new container to clients self:broadcastContainerUpdate(containerId, RmFreshUpdateEvent.OP_REGISTER, self.containers[containerId]) - return containerId, false -- wasReconciled = false + -- Adapters use this flag to decide whether to create a new age-zero batch. + return containerId, claimedObjectStorageTransfer end --- Unregister a container from the Manager