Eigenes Job-Script für FiveM bauen: Struktur, Datenbank und serverseitige Validierung
Ein eigener Job ist für die meisten Roleplay-Server der erste Schritt weg von Standardinhalten. Die technische Hürde liegt dabei selten in der Spielmechanik, sondern in der Architektur: Wo läuft welche Logik, wie werden Daten gespeichert, und wie verhindert man, dass ein manipulierter Client sich unbegrenzt Geld auszahlt. Dieser Beitrag zeigt den Aufbau eines Job-Scripts, das sowohl unter ESX als auch unter QBCore und QBox läuft, Daten über oxmysql persistiert und die Auszahlung ausschließlich serverseitig entscheidet.
Was ein Job-Script technisch leisten muss
Ein Job besteht fast immer aus denselben Bausteinen: eine Interaktion in der Welt, eine Zustandsmaschine im Client, eine Prüfung auf dem Server und eine Belohnung. Dazu kommen Nebenaspekte wie Arbeitskleidung, Fahrzeugausgabe oder Statistiken.
Entscheidend ist die Trennung der Verantwortlichkeiten. Der Client darf Fortschritt anzeigen, Animationen abspielen und Positionen prüfen, aber er darf niemals bestimmen, wie viel Geld oder welche Items ein Spieler erhält. Jede Belohnung muss auf dem Server neu berechnet und gegen den tatsächlichen Spielerzustand geprüft werden.
Im Beispiel entsteht ein Recycling-Job: Der Spieler durchsucht Container, erhält Rohmaterial und verkauft es an einem Abgabepunkt. Das Muster lässt sich auf nahezu jeden Sammel- oder Lieferjob übertragen.
Projektstruktur und Manifest
Eine flache Struktur mit einer einzigen Datei funktioniert bei kleinen Scripts, wird aber schnell unübersichtlich. Sinnvoll sind getrennte Ordner für Client, Server und geteilte Konfiguration.
ws_recycling/
fxmanifest.lua
config.lua
bridge/framework.lua
client/main.lua
client/zones.lua
server/main.lua
sql/install.sql
Das Manifest lädt die Konfiguration als shared script, damit Client und Server identische Werte sehen. Die oxmysql-Bibliothek wird nur serverseitig eingebunden.
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
shared_scripts {
'config.lua',
'bridge/framework.lua'
}
client_scripts {
'client/zones.lua',
'client/main.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua'
}
dependencies {
'oxmysql'
}
Die Angabe lua54 'yes' ist kein Selbstzweck. Sie erlaubt unter anderem Integer-Division und bessere Fehlermeldungen, setzt aber voraus, dass alle eingebundenen Dateien damit kompatibel sind.
Eine schlanke Framework-Bridge
Wer sein Script auf mehreren Frameworks betreiben will, sollte Framework-Aufrufe an genau einer Stelle kapseln. Die Bridge erkennt beim Start, welches Framework vorhanden ist, und stellt eine einheitliche API bereit.
Bridge = {}
local core, name
if GetResourceState('es_extended') == 'started' then
core = exports['es_extended']:getSharedObject()
name = 'esx'
elseif GetResourceState('qb-core') == 'started' then
core = exports['qb-core']:GetCoreObject()
name = 'qbcore'
end
Bridge.Name = name
Bridge.Core = core
Auf dem Server folgen die Funktionen, die der Job tatsächlich braucht. Mehr nicht, denn eine Bridge, die alles abdeckt, wird selbst zum Wartungsproblem.
function Bridge.GetPlayer(src)
if Bridge.Name == 'esx' then
return Bridge.Core.GetPlayerFromId(src)
end
return Bridge.Core.Functions.GetPlayer(src)
end
function Bridge.GetIdentifier(player)
if Bridge.Name == 'esx' then
return player.identifier
end
return player.PlayerData.citizenid
end
function Bridge.GetJob(player)
if Bridge.Name == 'esx' then
return player.job.name, player.job.grade
end
return player.PlayerData.job.name, player.PlayerData.job.grade.level
end
function Bridge.AddMoney(player, account, amount)
if Bridge.Name == 'esx' then
player.addAccountMoney(account == 'cash' and 'money' or account, amount)
else
player.Functions.AddMoney(account, amount)
end
end
Für Items lohnt sich ein eigener Zweig, weil viele Server ox_inventory einsetzen. Prüfe daher zuerst auf das Inventar-Resource und falle erst danach auf die Framework-Methoden zurück. So bleibt das Script unabhängig von der Kombination aus Framework und Inventarsystem.
Datenbank: Schema und oxmysql-Zugriffe
Persistente Daten gehören in eine eigene Tabelle, nicht in Metadaten-Felder des Spielers. Das vereinfacht Auswertungen und verhindert, dass ein Framework-Update die eigenen Daten überschreibt.
CREATE TABLE IF NOT EXISTS `ws_recycling_stats` (
`identifier` VARCHAR(64) NOT NULL,
`total_items` INT NOT NULL DEFAULT 0,
`total_payout` INT NOT NULL DEFAULT 0,
`last_shift` TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Beim Zugriff gilt eine einfache Regel: Lesen beim Laden, Schreiben beim Ereignis, niemals im Loop. Ein INSERT ... ON DUPLICATE KEY UPDATE spart die vorherige Abfrage.
local function trackPayout(identifier, items, payout)
MySQL.prepare([[
INSERT INTO ws_recycling_stats (identifier, total_items, total_payout, last_shift)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
total_items = total_items + VALUES(total_items),
total_payout = total_payout + VALUES(total_payout),
last_shift = NOW()
]], { identifier, items, payout })
end
Parameterbindung ist hier Pflicht. Werte direkt in den Query-String zu schreiben, öffnet SQL-Injection auch dann, wenn die Eingabe scheinbar nur aus Zahlen besteht. MySQL.prepare ohne await läuft asynchron und blockiert den Server-Thread nicht.
Beim Lesen innerhalb eines Events ist die await-Variante bequemer, sollte aber sparsam eingesetzt werden, weil sie den aufrufenden Thread anhält.
local function getStats(identifier)
return MySQL.single.await(
'SELECT total_items, total_payout FROM ws_recycling_stats WHERE identifier = ?',
{ identifier }
)
end
Serverseitige Logik und Validierung
Der kritische Teil ist das Event, das der Client auslöst, wenn er einen Container durchsucht hat. Ein naiv geschriebener Handler nimmt Itemname und Menge vom Client entgegen und schreibt sie ins Inventar. Genau das ist die häufigste Lücke in selbstgebauten Jobs.
Sichere Handler prüfen vier Dinge: Existiert der Spieler, hat er den passenden Job, ist er physisch in der Nähe des Zielpunkts, und liegt der letzte Aufruf lange genug zurück.
local cooldown = {}
RegisterNetEvent('ws_recycling:server:finishSearch', function(zoneId)
local src = source
local zone = Config.Zones[zoneId]
if not zone then return end
local now = GetGameTimer()
if cooldown[src] and now - cooldown[src] < Config.SearchCooldown then
return
end
cooldown[src] = now
local player = Bridge.GetPlayer(src)
if not player then return end
local job = Bridge.GetJob(player)
if job ~= Config.JobName then return end
local ped = GetPlayerPed(src)
local coords = GetEntityCoords(ped)
if #(coords - zone.coords) > Config.MaxDistance then
return
end
local reward = zone.rewards[math.random(#zone.rewards)]
Bridge.AddItem(player, reward.item, reward.amount)
end)
Die Zone-ID ist der einzige Wert, den der Client liefert, und sie wird gegen die serverseitige Konfiguration geprüft. Die Belohnung selbst entsteht erst auf dem Server. Damit ist es unerheblich, ob jemand das Event manuell auslöst: Ohne passenden Job, ohne korrekte Position und ohne abgelaufenen Cooldown passiert nichts.
Die Distanzprüfung schützt nicht gegen Teleport-Cheats, aber sie verhindert das Auslösen aus der halben Karte heraus. Für den Verkauf gilt dasselbe Muster, zusätzlich muss die Menge aus dem Inventar gelesen und nicht vom Client übernommen werden.
RegisterNetEvent('ws_recycling:server:sell', function()
local src = source
local player = Bridge.GetPlayer(src)
if not player then return end
local total, items = 0, 0
for item, price in pairs(Config.Prices) do
local count = Bridge.GetItemCount(src, item)
if count > 0 then
if Bridge.RemoveItem(player, item, count) then
total = total + (price * count)
items = items + count
end
end
end
if total > 0 then
Bridge.AddMoney(player, 'cash', total)
trackPayout(Bridge.GetIdentifier(player), items, total)
end
end)
Wichtig ist die Reihenfolge: erst Item entfernen, dann Geld gutschreiben. Schlägt das Entfernen fehl, etwa weil das Item zwischenzeitlich verschoben wurde, gibt es keine Auszahlung.
Nicht vergessen: Der Cooldown-Table muss beim Verlassen des Servers aufgeräumt werden, sonst wächst er über die Laufzeit.
AddEventHandler('playerDropped', function()
cooldown[source] = nil
end)
Clientseite: Zonen statt Dauerschleifen
Der häufigste Performance-Fehler in Job-Scripts ist eine Endlosschleife, die in jedem Frame die Distanz zu jedem Interaktionspunkt berechnet. Bei zwanzig Punkten und fünfzig Spielern summiert sich das spürbar.
Besser sind Target-Systeme wie ox_target oder qb-target, die Interaktionen an Entities oder Zonen binden und selbst nur dann rechnen, wenn der Spieler in der Nähe ist.
CreateThread(function()
for id, zone in pairs(Config.Zones) do
exports.ox_target:addBoxZone({
coords = zone.coords,
size = vec3(1.5, 1.5, 2.0),
rotation = zone.heading,
options = {
{
label = 'Container durchsuchen',
icon = 'fa-solid fa-dumpster',
groups = Config.JobName,
onSelect = function()
StartSearch(id)
end
}
}
})
end
end)
Ist kein Target-System vorhanden, bleibt die klassische Schleife, dann aber mit angepasstem Wait: Weit entfernt reicht ein Intervall von 1000 Millisekunden, erst in der Nähe wird auf 0 heruntergeschaltet.
Animationen und Abbruchbedingungen
Jede Fortschrittsanzeige braucht eine saubere Abbruchlogik. Stirbt der Spieler, setzt er sich ins Fahrzeug oder verlässt er die Zone, muss die Animation beendet und das Server-Event unterdrückt werden. Progress-Bibliotheken liefern dafür meist ein Rückgabeobjekt, das den Abbruch signalisiert.
function StartSearch(zoneId)
if lib.progressCircle({
duration = Config.SearchDuration,
label = 'Durchsuche Container',
canCancel = true,
disable = { move = true, combat = true },
anim = { dict = 'amb@prop_human_bum_bin@base', clip = 'base' }
}) then
TriggerServerEvent('ws_recycling:server:finishSearch', zoneId)
else
ClearPedTasks(PlayerPedId())
end
end
Konfiguration sauber auslagern
Alles, was ein Serverbetreiber anpassen möchte, gehört in config.lua: Jobname, Koordinaten, Preise, Cooldowns, Texte. Hardcodierte Werte im Client-Code führen dazu, dass jedes Update eigene Änderungen überschreibt.
Config = {}
Config.JobName = 'recycling'
Config.SearchDuration = 6000
Config.SearchCooldown = 4000
Config.MaxDistance = 3.5
Config.Prices = {
['scrap_metal'] = 18,
['plastic'] = 9,
['glass'] = 6
}
Config.Zones = {
[1] = {
coords = vec3(-321.1, -1545.4, 31.0),
heading = 0.0,
rewards = {
{ item = 'scrap_metal', amount = 2 },
{ item = 'plastic', amount = 3 }
}
}
}
Preise sollten serverseitig bleiben, auch wenn die Konfiguration als shared script geladen wird. Der Client braucht sie nur zur Anzeige, nicht zur Berechnung.
Testen, messen, absichern
Vor dem Livegang gehören drei Prüfungen dazu. Erstens die Ressourcenlast: resmon 1 zeigt, ob das Script im Leerlauf unter 0.01 ms bleibt. Alles darüber deutet auf eine Schleife ohne passendes Wait hin.
Zweitens der Missbrauchstest. Löse die Server-Events bewusst mit falschen Parametern aus, etwa mit einer nicht existierenden Zone-ID, einem String statt einer Zahl oder aus großer Entfernung. Jeder dieser Aufrufe muss folgenlos bleiben.
Drittens die Datenbanklast. Prüfe im Log, wie viele Queries pro Minute entstehen. Ein Job, der bei jedem Suchvorgang schreibt, erzeugt bei vielen Spielern unnötig viele Schreibzugriffe. Statistiken lassen sich im Speicher sammeln und in Intervallen oder beim Dienstende gebündelt persistieren.
Ein Job-Script ist dann produktionsreif, wenn es ohne Client-Vertrauen auskommt, im Leerlauf keine messbare Last erzeugt und jede Konfigurationsänderung ohne Codeänderung möglich ist.
Erweiterungen mit Augenmaß
Ist das Grundgerüst stabil, lassen sich Dienstkleidung, Fahrzeugausgabe, Gradsystem oder eine Discord-Benachrichtigung bei Schichtende ergänzen. Jede Erweiterung sollte dabei dasselbe Prinzip befolgen: Der Server entscheidet, der Client stellt dar.
Für Benachrichtigungen an Discord reicht ein Webhook-Aufruf per PerformHttpRequest auf dem Server. Wichtig ist, ihn zu drosseln, da Discord Rate-Limits durchsetzt und blockierte Requests sonst den HTTP-Thread belasten.
Wer diese Struktur einmal aufgesetzt hat, kann sie als Vorlage für weitere Jobs nutzen. Bridge, Konfigurationsmuster und Validierungslogik bleiben gleich, nur Zonen, Items und Belohnungen ändern sich. Das spart bei jedem weiteren Job spürbar Zeit und hält die Sicherheit und Wartbarkeit über alle Ressourcen hinweg auf gleichem Niveau.
