Live, Teile im Test
Legacy-Rettung auf einem Live-Server
Geerbte Systeme auf einem Server umbauen, der nie stehen bleibt: Telefone und Funkgeräte von Grund auf neu, die Daten jedes Plugins auf MySQL, Ausrüstung, die keine Gegenstände verlieren kann, und eine Kofferraum-Migration, die den Stauraum aller Spieler umzieht, ohne dass etwas verloren geht.
- Rolle
- Entwurf und Umsetzung, im Alleingang
- Stack
- Lua (GLua), NutScript, MySQL, Python für Tooling
- Kontext
- Diverge Networks, ein lange laufender Roleplay-Server
- Status
- Telefone und Datenschicht live; Ausrüstung, Kofferräume und Geschwindigkeit im Test
Screenshots und Clips auf dieser Seite: aufgenommen auf einem Entwicklungsserver mit Testcharakteren und gestellten Daten.
Der Ansatz
Der größte Teil eines lange laufenden Servers ist Code, den jemand anderes geschrieben hat, der nie fertig wurde oder der für einen Server geschrieben wurde, der sich seither verändert hat. Das gefällt mir. Manchmal besteht die Aufgabe darin, die eine saubere Lösung zu finden, ohne alle anderen Monster zu wecken. Manchmal darin, ein Heckenlabyrinth in eine moderne Wohnung zu verwandeln.
In beiden Fällen müssen die Spieler weiterspielen können, während es passiert, und nichts, was ihnen gehört, darf unterwegs verloren gehen.
Telefone und Funkgeräte, von Grund auf neu gebaut
Live seit 2025
Das alte Telefon- und Funksystem war so unzuverlässig, dass der Server eine komplette Neuentwicklung verlangte: Anrufe, die nie endeten, Spieler, die in Anrufen festhingen, die es nicht mehr gab, und Polizeikanäle, die sich mithören ließen.
- Nummern werden bei der ersten Nutzung vergeben, mit einer Prüfung auf bereits belegte Nummern, und Unterstützer können eigene Nummern wählen.
- Anrufe durchlaufen explizite Zustände, und unbeantwortete Anrufe beenden sich nach 30 Sekunden selbst.
- Beim Verlassen eines Münztelefon-Anrufs läuft das Aufräumen in einem geschützten Block, sodass der Anruf getrennt und sein vernetzter Zustand geleert wird, selbst wenn mittendrin ein Fehler auftritt.
- Eine periodische Prüfung findet Spieler, die als in Anrufen markiert sind, die es nicht mehr gibt, und gibt sie frei.
- Funkkanäle mit Verschlüsselung und eine neue Sprechblase. 911-Frequenzen sind nur für die Polizei, und ein Weg an dieser Prüfung vorbei wurde geschlossen.
- Volle Unterstützung für Sprachen im Spiel bei Telefonen und Funkgeräten.
Screenshot folgt in Kürze
Die Telefon-Oberfläche beim Wählen.
assets/projects/phone-radio/phone-dial.webp (enger Zuschnitt)
Video folgt in Kürze
Ein Anruf vom Wählen über das Annehmen bis zum Auflegen, mit einem zweiten Client, der abnimmt.
assets/projects/phone-radio/phone-call.mp4 (etwa 15 Sekunden)
Screenshot folgt in Kürze
Die Funk-Sprechblase, während jemand spricht.
assets/projects/phone-radio/radio-bubble.webp (enger Zuschnitt)
Die Daten jedes Plugins auf MySQL umziehen
Live seit 2025
Plugins speicherten ihre Daten in Dateien auf dem Server. Ich habe zuerst die Türen und dann jedes Plugin auf MySQL umgezogen, mit einem Upload-Befehl, der die alten Dateidaten hinüberbrachte.
Die neue Datenschicht garantiert, dass die Lese- und Schreibzugriffe jedes Plugins in der Reihenfolge stattfinden, in der sie ausgelöst wurden:
- Jedes Plugin bekommt eine eigene Warteschlange, und seine Datenbankoperationen laufen strikt nacheinander. Ein Lesezugriff kann nie den Schreibzugriff davor überholen.
- Jeder Aufruf gibt ein Promise zurück, das erfüllt wird, sobald genau diese Operation abgeschlossen ist.
- Die Warteschlange läuft nach Erfolgen wie nach Fehlern weiter, sodass eine fehlerhafte Abfrage ein Plugin nie blockieren kann.
- Werte werden gecacht, sodass Lesezugriffe nur dann die Datenbank treffen, wenn ein Refresh verlangt wird.
- Gespeicherte Daten werden innerhalb eines geschützten Aufrufs dekodiert, sodass beschädigte Daten sauber abgelehnt werden, statt den Aufrufer zum Absturz zu bringen.
-- 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)
endAusrüstung, die nicht verloren gehen kann
Gebaut, im Test
Das Anlegen von Kleidung verschiebt einen Gegenstand aus dem Inventarraster in einen versteckten Zustand. Der geerbte Equip-Code hatte keinen sicheren Rückweg, wenn mittendrin etwas schiefging, also verschwanden Gegenstände.
Ich habe Anlegen und Ablegen als eine einzige Promise-basierte API für alle Item-Typen neu gebaut, mit Absicherungen in jedem Schritt, und beim Laden eines Charakters eine Prüfung ergänzt, die Gegenstände findet, die in diesem versteckten Zustand hängen, sie zurücklegt und dem Spieler mitteilt, was passiert ist.
-- 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
endAlle Kofferräume zu einem, ohne einen Gegenstand zu verlieren
Gebaut, im Test
Früher hatte jedes Fahrzeug einen eigenen Kofferraum. Sie werden durch einen gemeinsamen Kofferraum pro Charakter ersetzt, der automatisch migriert wird, sobald der jeweilige Charakter nach dem Update zum ersten Mal lädt.
- Gegenstände werden einzeln und der Reihe nach verschoben, sodass sich nie zwei Übertragungen ins Gehege kommen.
- Ist der gemeinsame Kofferraum voll, landet der Gegenstand stattdessen in einem Fundbüro-Inventar beim Autohändler, statt zu scheitern.
- Schlägt eine Verschiebung fehl, geht der Gegenstand an seinen ursprünglichen Platz zurück. Schlägt auch das fehl, löst der Server einen Fehler aus, der den Gegenstand nennt.
- Der Datenbankeintrag jedes Fahrzeugs enthält die ID des Inventars, das sein Kofferraum ist (seine invID). Er wechselt erst zum gemeinsamen Kofferraum, nachdem jeder Gegenstand verschoben wurde, sodass ein Fehler auf halbem Weg den alten Kofferraum als maßgebliche Quelle zurücklässt.
- Jeder Schritt wird in ein datiertes Migrationslog geschrieben.
Damit Fehler in der Kette nach oben durchgereicht wurden, musste ein Bug in der Promise-Bibliothek des Servers umgangen werden. Mehr dazu weiter unten.
Video folgt in Kürze
Ein Testcharakter mit Gegenständen, die auf mehrere Fahrzeug-Kofferräume verteilt sind, lädt ein. Der gemeinsame Kofferraum öffnet sich mit allem zusammengeführt, dann zeigt das Fundbüro beim Autohändler den Überlauf.
assets/projects/legacy-rescue/trunk-migration.mp4 (20 bis 30 Sekunden)
Screenshot folgt in Kürze
Das gemeinsame Kofferraum-Inventar (10 mal 10), teilweise gefüllt.
assets/projects/legacy-rescue/shared-trunk.png (enger Zuschnitt auf das 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)
endDer Kompromiss
Einen Gegenstand zu entfernen und anderswo hinzuzufügen ist keine einzelne Datenbanktransaktion, sodass ein Serverabsturz zwischen den beiden Schritten diesen einen Gegenstand trotzdem verlieren könnte. Der Rückweg und das Migrationslog halten dieses Zeitfenster klein und sichtbar, und für eine einmalige Migration ist es ein vertretbares Risiko.
Ein Bug im Fundament, absichtlich stehen gelassen
NutScript, das Framework, auf dem der Server läuft, liefert eine kleine Promise-Bibliothek mit: deferred-lua, geschrieben von einem der NutScript-Autoren. Ein Promise ist ein Schuldschein: „Hier ist das Inventar, sobald die Datenbank antwortet.“ Schritte werden verkettet, und jeder Schritt kann einen weiteren Schuldschein zurückgeben, auf den der nächste wartet.
Die Kofferraum-Migration stieß auf einen Fall, den die Bibliothek falsch behandelt. Gibt ein Schritt einen Schuldschein zurück, der bereits fehlgeschlagen ist, bleibt die Kette einfach stehen. Kein Fehler, kein Stack-Trace, nichts, durch das man schrittweise gehen könnte: Alles, was darauf wartet, wartet ewig.
Die Ursache sind zwei Zeilen in der falschen Reihenfolge. Die Bibliothek markiert zuerst ihren eigenen Schuldschein als fehlgeschlagen und ruft dann die Funktion auf, die ihn als fehlgeschlagen markiert und allen Wartenden Bescheid gibt. Diese Funktion greift nur bei Schuldscheinen, die noch warten, sieht, dass dieser schon markiert ist, und tut nichts.
-- 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)- Der Fix besteht aus zwei Zeilen und entspricht dem Promises/A+-Standard: Ist der übergebene Schuldschein fehlgeschlagen, schlägt man mit demselben Grund fehl.
- Ich habe die gemeinsame Bibliothek nicht gepatcht. Promises laufen durch die ganze Codebasis, und jedes andere System war gegen das aktuelle Verhalten getestet; es unter einem Live-Server zu ändern, hätte riskiert, Funktionierendes kaputtzumachen.
- Stattdessen gibt die Migration Fehler zurück, die einen Moment später eintreten. Ein noch wartender Schuldschein nimmt den funktionierenden Weg der Bibliothek, sodass der Fehler alle erreicht, die ihn erreichen sollen. Nur die Migration brauchte das.
- Der Bug steckt weiterhin in der Bibliothek, die NutScript ausliefert. Ich habe beiden Projekten den Fix, Tests, die ihn abfangen, und eine Möglichkeit geschickt, ihn im Spiel zu reproduzieren: deferred-lua #1 · NutScript #179 · die Tests.
Geschwindigkeit: von „Der Letzte gewinnt“ zu kombinierbaren Modifikatoren
Gebaut, im Test
Jedes System auf dem Server setzte die Spielergeschwindigkeit direkt. Das Tragen von viel Bargeld, Betrunkenheit und ein Taser-Treffer überschrieben sich gegenseitig, und nichts stellte danach den richtigen Wert wieder her.
- Jedes System setzt und löscht seinen eigenen benannten Modifikator. Wird derselbe Name erneut gesetzt, ersetzt er den alten, statt sich zu stapeln.
- Die endgültige Geschwindigkeit wird aus einem Basiswert und allen aktiven Modifikatoren neu berechnet: Multiplikatoren werden multipliziert, feste Anpassungen addiert, und das Ergebnis fällt nie unter null. Da die Reihenfolge keine Rolle spielt, stört das Entfernen eines Effekts nie einen anderen.
- Die API wird in einer einzigen Schleife für alle acht Bewegungsgeschwindigkeiten der Engine erzeugt, vom Gehen bis zum Leiterklettern.
- Die alten Geschwindigkeits-Setter der Engine funktionieren weiterhin, setzen jetzt aber den Basiswert und loggen eine Warnung mit der aufrufenden Stelle. Noch nicht umgestellte Plugins verhalten sich weiter korrekt, und die Warnungen zeigen genau, was noch zu migrieren ist.
-- 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) folgt in Kürze
Ein Spieler, der durch das Tragen von Bargeld und gleichzeitige Betrunkenheit verlangsamt ist und sich dann von einem der beiden erholt, ohne den anderen zu verlieren.
assets/projects/legacy-rescue/speed-stacking.mp4 (etwa 15 Sekunden)
Kleinere Rettungen
- Inventar- und Item-Syncs, die Netzwerkpuffer überliefen, auf kodierte, komprimierte Payloads umgestellt.
- Kompatibilität mit MySQL 8.0.
- Ein Umbau des Nahkampfs, der die Reihenfolge der Combat-Logging-Hooks entwirrte.
- Item-Definitionen mit einem Python-Konverter in einer Datei zusammengeführt und später wieder aufgeteilt.