Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 64 additions & 172 deletions scripts/adapters/RmObjectStorageAdapter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -631,138 +624,37 @@ function RmObjectStorageAdapter:removeAbstractObjectFromStorageHook(superFunc, a
Log:trace("<<< removeAbstractObjectFromStorageHook: done")
end

--- Snapshot current bale containerIds from Manager
---@return table<string,boolean> 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<string,boolean> 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<string,boolean> 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<string,boolean> 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

-- =============================================================================
Expand Down
50 changes: 49 additions & 1 deletion scripts/core/RmFreshManager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down