Live, parts in testing
Legacy rescue on a live server
Rebuilding inherited systems on a server that never stops: phones and radios from scratch, every plugin’s data onto MySQL, equipment that can’t lose items, and a trunk migration that moves every player’s storage without losing a thing.
- Role
- Design and implementation, working solo
- Stack
- Lua (GLua), NutScript, MySQL, Python for tooling
- Context
- Diverge Networks, a long-running roleplay server
- Status
- Phones and data layer live; equipment, trunks and speed in testing
Screenshots and clips on this page: captured on a development server with test characters and staged data.
The approach
Most of a long-running server is code someone else wrote, code that was never finished, or code written for a server that has since changed. I enjoy that. Sometimes the job is finding the one clean fix without waking all the other monsters. Sometimes it’s turning a hedge maze into a modern apartment.
Either way, players have to keep playing while it happens, and nothing they own can go missing on the way.
Phones and radios, rebuilt from scratch
Live since 2025
The old phone and radio system was unreliable enough that the server asked for a full rewrite: calls that never ended, players stuck in calls that no longer existed, and police channels that could be listened in on.
- Numbers are assigned on first use with a check for numbers already taken, and supporters can pick custom numbers.
- Calls move through explicit states, and unanswered calls close themselves after 30 seconds.
- Leaving a payphone call runs its cleanup in a protected block, so the call is severed and its networked state cleared even if something errors halfway.
- A periodic check finds players marked as being in calls that no longer exist, and releases them.
- Radio channels with encryption and a new voice bubble. 911 frequencies are police-only, and a way around that check was closed.
- Full support for in-game languages on both phones and radios.
Screenshot coming soon
The phone interface while dialling.
assets/projects/phone-radio/phone-dial.webp (tight crop)
Video coming soon
One call from dialling to answering to hanging up, with a second client answering.
assets/projects/phone-radio/phone-call.mp4 (about 15 seconds)
Screenshot coming soon
The radio voice bubble while someone speaks.
assets/projects/phone-radio/radio-bubble.webp (tight crop)
Moving every plugin’s data onto MySQL
Live since 2025
Plugins saved their data to files on the server. I moved doors first, then every plugin, onto MySQL, with an upload command that brought the old file data across.
The new data layer guarantees each plugin’s reads and writes happen in the order they were made:
- Each plugin gets its own queue, and its database operations run strictly one at a time. A read can never overtake the write before it.
- Every call returns a promise that settles when that particular operation is done.
- The queue moves on after both successes and failures, so one bad query can never stall a plugin.
- Values are cached, so reads only hit the database when asked to refresh.
- Stored data is decoded inside a protected call, so corrupt data is rejected cleanly instead of crashing the caller.
-- Every plugin gets its own queue. Operations run strictly one at a time,
-- so a read can never overtake the write before it.
function PLUGIN:QueuePreparedCall(...)
local d = deferred.new()
self.__MySQL_queue = self.__MySQL_queue or {}
table.insert(self.__MySQL_queue, { deferred = d, args = {...} })
self:__MySQL_execute_next_in_queue()
return d -- settles when this particular operation is done
end
function PLUGIN:__MySQL_execute_next_in_queue()
if (not MYSQLOO_PREPARED or not MYSQLOO_PLUGIN_DATA_READY) then return end -- kicked again once the database is ready
if (self.__MySQL_CurrentOperation) then return end -- one at a time
if (not self.__MySQL_queue or table.IsEmpty(self.__MySQL_queue)) then return end
local op = table.remove(self.__MySQL_queue, 1)
self.__MySQL_CurrentOperation = op
-- unpack stops at the first nil, so find the real argument count:
-- a nil in the middle of the arguments becomes SQL NULL instead of cutting the call short.
local n = 1
for k in pairs(op.args) do n = math.max(n, k) end
-- Advance on success and on failure alike, so one bad query can never stall the plugin.
nut.db.preparedCallDeferred(unpack(op.args, 1, n)):next(function(...)
op.deferred:resolve(...)
self.__MySQL_CurrentOperation = nil
self:__MySQL_execute_next_in_queue()
end):catch(function(...)
op.deferred:reject(...)
self.__MySQL_CurrentOperation = nil
self:__MySQL_execute_next_in_queue()
end)
end
function PLUGIN:getDataMySQL(default, global, ignoreMap, refresh)
-- ... cached values are returned without touching the database (omitted)
return self:QueuePreparedCall("pluginDataGet", key):next(function(response)
local data = response and response.results and response.results[1] and response.results[1].data
if (not data) then return default end
-- Corrupt data is rejected cleanly instead of crashing the caller.
local ok, decoded = pcall(pon.decode, data)
if (not ok) then return deferred.reject(decoded) end
return decoded[1]
end)
endEquipment that can’t get lost
Built, in testing
Equipping clothing moves an item out of the inventory grid into a hidden state. The inherited equip code had no safe way back when something went wrong halfway, so items vanished.
I rebuilt equipping and unequipping as one promise-based API used by every item type, with safeguards at each step, and added a check on character load that finds items stuck in that hidden state, puts them back and tells the player what happened.
-- One equip API for every item type. Each step returns a promise, so callers
-- only react once the item has really moved.
local ITEM = nut.meta.item
function ITEM:equip()
local d = deferred.resolve()
if (self.usesEquipSlot) then
d = self:addToVoid() -- out of the inventory grid, into the hidden equipped state
end
return d:next(function()
local special = special_cases[self.base] -- weapons, medals and outfits handle their own visuals
if (special) then return special(self) end
self:setData("equip", true)
self:transmitEquipState()
end)
end
function ITEM:unEquip()
local d = deferred.resolve()
if (self:isInVoid()) then
d = self:removeFromVoid()
end
return d:next(function()
if (self:isEquipped()) then
self:setData("equip")
self:transmitEquipState() -- ... owner lookup omitted
end
end)
end
function ITEM:isInVoid()
return self:getData("x", -1) == -1 or self:getData("y", -1) == -1
end
-- The way back. Every failure is a rejection with a reason, never a lost item.
function ITEM:removeFromVoid()
if (not self:isInVoid()) then return deferred.resolve() end
local inv = nut.inventory.instances[self.invID]
local charID = istable(inv) and tonumber(inv:getData("char"))
if (not (charID and nut.char.loaded[charID])) then return deferred.reject("charNotFound") end
if (not inv) then return deferred.reject("invNotFound") end
if (not inv:findFreePosition(self.uniqueID)) then return deferred.reject("noFit") end
return inv:add(self)
end
-- On character load: anything sitting in the hidden state without being
-- equipped is stuck there. Put it back and tell the player what happened.
for _, item in pairs(char:getInv():getItems()) do
if (not item:isEquipped() and item:isInVoid()) then
item:unEquip():next(function()
ply:notify("Your " .. item:getName() .. " was stuck in the void of despair. It has been moved back to your inventory, hopefully, if you had space. If not clear some, then reload your character.")
end):catch(function(err)
ply:notify("Your " .. item:getName() .. " was stuck in the void of despair. We tried to move it back into your inventory but got the following error: " .. tostring(err) .. ". Try fixing it, then reloading your char. Or contact staff!")
end)
end
endEvery trunk into one, without losing an item
Built, in testing
Each vehicle used to have its own trunk. They’re being replaced by one shared trunk per character, migrated automatically the first time each character loads after the update.
- Items move one at a time, in sequence, so no two transfers ever race each other.
- If the shared trunk is full, the item goes to a lost-and-found inventory at the car dealer instead of failing.
- If a move fails, the item goes back to its original slot. If even that fails, the server raises an error naming the item.
- Each vehicle’s database record holds the ID of the inventory that is its trunk (its invID). It only switches to the shared trunk after every item has moved, so a failure halfway through leaves the old trunk as the source of truth.
- Every step is written to a dated migration log.
Getting failures to travel back up the chain meant working around a bug in the server’s promise library. More on that below.
Video coming soon
A test character with items spread across several vehicle trunks loads in. The shared trunk opens with everything consolidated, then the lost-and-found at the car dealer shows the overflow.
assets/projects/legacy-rescue/trunk-migration.mp4 (20 to 30 seconds)
Screenshot coming soon
The shared 10 by 10 trunk inventory, partly filled.
assets/projects/legacy-rescue/shared-trunk.png (tight crop to the panel)
-- Move one item between inventories. If the move fails, put it back where it
-- was; if even that fails, raise an error naming the item.
function PLUGIN:AttemptServerItemTransfer(item, target, forceAdd)
local itemID = item:getID()
local source = nut.inventory.instances[item.invID]
if (not source or not source.items[itemID]) then
return rejectAsync("Item's current inventory does not exist.")
end
local oldX, oldY = item:getData("x"), item:getData("y")
local function revert(reason)
return source:add(item, oldX, oldY):next(function()
return rejectAsync(reason) -- back in place; report the original failure
end, function(revertErr)
ErrorNoHalt("[Transfer] REVERT FAILED for item " .. itemID .. ": " .. tostring(revertErr)
.. " (original: " .. tostring(reason) .. ") - item is in no inventory!\n")
return rejectAsync("revertFailed")
end)
end
return source:removeItem(itemID, true):next(function()
if (forceAdd) then
target:addItem(item) -- overflow: no grid limits
return
end
return target:add(item):next(function(res)
if (istable(res) and res.error) then return revert(res.error) end
end, revert)
end)
end
function PLUGIN:MigrateTrunkToSharedInv(char, vehicle, sharedInv, overflowInv)
-- ... guards against migrating a trunk into itself omitted
return nut.inventory.loadByID(vehicle._invID):next(function(oldTrunk)
-- Chain the moves so items go one at a time and never race each other.
local chain = deferred.resolve()
for _, item in pairs(oldTrunk:getItems()) do
chain = chain:next(function()
return self:AttemptServerItemTransfer(item, sharedInv):catch(function()
-- Shared trunk full: fall back to the lost-and-found inventory.
return self:AttemptServerItemTransfer(item, overflowInv, true)
end)
end)
end
return chain
end):next(function()
-- Only reached if every item moved. Until now the old trunk is still
-- the source of truth, so a failure halfway loses nothing.
return nut.db.preparedCallDeferred("vehicleUpdateInvID", sharedInv:getID(), vehicle.id):next(function()
vehicle._invID = sharedInv:getID()
return { vehicle, sharedInv }
end)
end)
-- ... every step is also written to a dated migration log (omitted)
endThe trade-off
Removing an item and adding it elsewhere isn’t one database transaction, so a server crash between the two steps could still lose that one item. The revert path and the migration log keep that window small and visible, and it’s an acceptable risk for a one-time migration.
A bug in the foundation, left in place on purpose
NutScript, the framework the server runs on, ships a small promise library: deferred-lua, written by one of NutScript’s authors. A promise is an IOU: “here’s the inventory, once the database answers.” Steps are chained, and any step can hand back another IOU for the next one to wait on.
The trunk migration hit a case the library gets wrong. When a step hands back an IOU that has already failed, the chain simply stops. No error, no stack trace, nothing to step through: everything waiting on it waits forever.
The cause is two lines in the wrong order. The library first marks its own IOU as failed, then calls the function that marks it failed and tells everyone waiting. That function only acts on IOUs that are still waiting, sees this one already marked, and does nothing.
-- deferred-lua, inside Promise:_handle: a step handed back another promise,
-- so copy its state.
self.state = value.state
if (value.state == PENDING) then
-- ... wait for it, then pass the result on (this path works)
elseif (value.state == FULFILLED) then
self:_handle(value.value) -- runs everyone waiting
else
self:reject(value.reason) -- does nothing: reject() only acts while
end -- pending, and the state was just copied
-- The fix: treat a failure like the fulfilled branch does.
-- self.reason = value.reason
-- self:_handle(value.reason)- The fix is two lines and matches the Promises/A+ standard: if the IOU you were handed has failed, fail with the same reason.
- I didn’t patch the shared library. Promises run through the whole codebase, and every other system had been tested against the current behaviour; changing it under a live server risked breaking things that worked.
- Instead, the migration hands back failures that settle a moment later. A still-waiting IOU takes the library’s working path, so the failure reaches everyone it should. Only the migration needed it.
- The bug is still in the library NutScript ships. I sent both projects the fix, tests that catch it, and a way to reproduce it in game: deferred-lua #1 · NutScript #179 · the tests.
Speed, from last-writer-wins to composable modifiers
Built, in testing
Every system on the server set player speed directly. Carrying a lot of cash, being drunk and being tased all overwrote each other, and nothing restored the right value afterwards.
- Each system sets and clears its own named modifier. Setting the same name again replaces it instead of stacking.
- The final speed is recomputed from a base value and every active modifier: multipliers multiply together, flat adjustments add, and the result never drops below zero. Because order doesn’t matter, removing one effect never disturbs another.
- The API is generated in one loop for all eight movement speeds the engine has, from walking to ladder climbing.
- The engine’s old speed setters still work, but now set the base value and log a warning naming the call site. Plugins that haven’t been converted keep behaving correctly, and the warnings show exactly what’s left to migrate.
-- Instead of plugins overwriting each other's speeds, each one registers a
-- named modifier. The same API is generated for every speed the engine has.
local speeds = {
"CrouchedWalkSpeed", "DuckSpeed", "LadderClimbSpeed", "MaxSpeed",
"RunSpeed", "SlowWalkSpeed", "UnDuckSpeed", "WalkSpeed",
}
for _, name in ipairs(speeds) do
local set, rawSet = "Set" .. name, "Set" .. name .. "_Raw"
local getBase, setBase = "GetBase" .. name, "SetBase" .. name
local setMod, clearMod = "Set" .. name .. "Mod", "Clear" .. name .. "Mod"
local update = "Update" .. name
local modsVar, baseVar = name .. "_Mods", name .. "_Base"
-- Keep the engine's setter; only the recompute step calls it.
if (not isfunction(plyMeta[rawSet]) and isfunction(plyMeta[set])) then
plyMeta[rawSet] = plyMeta[set]
end
plyMeta[getBase] = function(ply) --[[ ... base from the player class, cached ... ]] end
plyMeta[setBase] = function(ply, value)
ply[baseVar] = value
ply[update](ply)
end
-- e.g. ply:SetRunSpeedMod("drag_body", -0.4) or ply:SetWalkSpeedMod("stimulant", 0.1, 20)
plyMeta[setMod] = function(ply, id, mult, add)
mult, add = mult or 0, add or 0
assert(id ~= nil and isnumber(mult) and isnumber(add), "Invalid arguments to " .. setMod)
ply[modsVar] = ply[modsVar] or {}
ply[modsVar][id] = { mult, add }
ply[update](ply)
end
plyMeta[clearMod] = function(ply, id)
if (ply[modsVar]) then ply[modsVar][id] = nil end
ply[update](ply)
end
-- Recompute from scratch every time: base, times every multiplier, plus every
-- flat bonus. Clearing a modifier always restores the right speed.
plyMeta[update] = function(ply)
local base = ply[getBase](ply)
if (not isnumber(base)) then return end
local mult, add = 1, 0
for _, m in pairs(ply[modsVar] or {}) do
mult = mult * math.max(0, 1 + m[1])
add = add + m[2]
end
ply[rawSet](ply, math.max(base * mult + add, 0))
end
-- Old code still calls SetRunSpeed(...). It now sets the base value and warns,
-- so modifiers stay applied and plugins can be migrated one at a time.
plyMeta[set] = function(ply, value)
ErrorNoHalt("Warning: " .. set .. " called, use " .. setMod .. " instead.\n")
if (isnumber(value)) then ply[setBase](ply, value) end
end
endVideo (optional) coming soon
A player slowed by carrying cash and by being drunk at the same time, then recovering from one without losing the other.
assets/projects/legacy-rescue/speed-stacking.mp4 (about 15 seconds)
Smaller rescues
- Inventory and item syncs that overflowed network buffers, moved to encoded, compressed payloads.
- MySQL 8.0 compatibility.
- A melee rework that untangled the order of combat-logging hooks.
- Item definitions merged into one file with a Python converter, and later split again.