All work

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.

Screenshot coming soon

The phone interface while dialling.

assets/projects/phone-radio/phone-dial.webp (tight crop)

Dialling.

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)

A call, start to finish.

Screenshot coming soon

The radio voice bubble while someone speaks.

assets/projects/phone-radio/radio-bubble.webp (tight crop)

Talking on the radio.

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:

-- 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)
end
Adapted from production code, lightly edited for readability. Never enough to rebuild the system.

Equipment 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
end
Adapted from production code, lightly edited for readability. Never enough to rebuild the system.

Every 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.

Getting failures to travel back up the chain meant working around a bug in the server’s promise library. More on that below.

Trunk migration flow When a character loads, the shared trunk and overflow inventory are loaded. Items from each old trunk move one at a time into the shared trunk, or into overflow if it is full. A failed move puts the item back. Only after every item has moved is the vehicle's invID switched to the shared trunk, and then the player is notified. Character loads Load the shared trunk and the overflowinventory, creating them if missing For each old vehicle trunk, move itemsone at a time, in sequence Fits: move it intothe shared trunk Full: move it to overflow,collected at the car dealer A move fails: put the item back, and leavethe vehicle on its old trunk for now Every item moved: switch the vehicle'sinvID to the shared trunk, notify the player The database only changes once the last item is safe.
The migration runs automatically the first time each character loads after the update.
Try it: step through a migration and restart the server halfway. Moving items first loses nothing; assign the new invID first and try the same thing. Tick “Restart can land mid-move” to see the one rare loss the trade-off below describes. A simulation of the idea, not the server code.

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)

Migration on first load.

Screenshot coming soon

The shared 10 by 10 trunk inventory, partly filled.

assets/projects/legacy-rescue/shared-trunk.png (tight crop to the panel)

One shared trunk.
-- 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)
end
Adapted from production code, lightly edited for readability. Never enough to rebuild the system.

The 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)
From the open-source library, trimmed. The fix is the two commented lines.
Try it: pick what step 1 hands back, then click Next. “Already failed” is the bug; switch to the fix and run it again. A narrated walk through the library’s logic, not the library itself.

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.

Try it: switch on two effects, then switch one off. Before, whichever system wrote last wins and removals reset to the default; after, the speed is recomputed from every active modifier. The white line is what the speed should be. Example values and example behaviour, not the server code.
-- 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
end
Adapted from production code, lightly edited for readability. Never enough to rebuild the system.

Video (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)

Two effects, one speed.

Smaller rescues