aether-framework/resources/[aether]/aether-jobs/server/main.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

345 lines
12 KiB
Lua

-- =====================================================================
-- Aether Jobs — Serverlogik
-- Der Server vergibt die Auftraege, bestimmt den Lohn und prueft die
-- Zeit je Arbeitsschritt. Der Client kann nichts davon beeinflussen.
-- =====================================================================
local Core = exports['aether-core']
-- Laufende Schichten: [quelle] = {
-- jobId, auftrag, schrittIdx, schrittSeit, dienstBeginn, verdient, auftraege, fahrzeug
-- }
local schichten = {}
-- ---------------------------------------------------------------------
-- Dienstfahrzeuge
-- ---------------------------------------------------------------------
--- Stellt das Dienstfahrzeug eines Jobs bereit
--- @param quelle number Server-ID
--- @param job table Jobdefinition
--- @return number|nil Entity-Handle
local function StelleFahrzeug(quelle, job)
if not job.fahrzeug then return nil end
local spawn = job.fahrzeug.spawn
local fahrzeug = CreateVehicle(GetHashKey(job.fahrzeug.modell),
spawn.x, spawn.y, spawn.z, spawn.w, true, true)
local versuche = 0
while not DoesEntityExist(fahrzeug) and versuche < 50 do
Wait(10)
versuche = versuche + 1
end
if not DoesEntityExist(fahrzeug) then return nil end
-- Kennzeichen als Dienstfahrzeug markieren
Entity(fahrzeug).state:set('aetherJob', {
job = job.id, besitzer = quelle,
}, true)
return fahrzeug
end
--- Entfernt das Dienstfahrzeug einer Schicht
local function EntferneFahrzeug(schicht)
if schicht and schicht.fahrzeug and DoesEntityExist(schicht.fahrzeug) then
DeleteEntity(schicht.fahrzeug)
end
end
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Laedt den Fortschritt aller Jobs eines Charakters
--- @param charakterId number Charakter-ID
--- @return table [jobId] = { level, xp, verdient, auftraege }
local function HoleFortschritt(charakterId)
local zeilen = MySQL.query.await(
'SELECT job, level, xp, verdient, auftraege FROM aether_jobs WHERE `character` = ?',
{ charakterId }) or {}
local fortschritt = {}
-- Standardwerte fuer alle bekannten Jobs
for _, job in ipairs(Jobs.Liste) do
fortschritt[job.id] = { level = 1, xp = 0, verdient = 0, auftraege = 0 }
end
for _, zeile in ipairs(zeilen) do
if fortschritt[zeile.job] then
fortschritt[zeile.job] = {
level = zeile.level, xp = zeile.xp,
verdient = zeile.verdient, auftraege = zeile.auftraege,
}
end
end
return fortschritt
end
--- Schreibt den Fortschritt eines Jobs zurueck
local function SpeichereFortschritt(charakterId, jobId, werte)
MySQL.query([[
INSERT INTO aether_jobs (`character`, job, level, xp, verdient, auftraege)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
level = VALUES(level), xp = VALUES(xp),
verdient = VALUES(verdient), auftraege = VALUES(auftraege)
]], { charakterId, jobId, werte.level, werte.xp, werte.verdient, werte.auftraege })
end
--- Baut den Zustand fuer die Oberflaeche
local function BaueZustand(quelle)
local charakterId = CharakterVon(quelle)
if not charakterId then return nil end
local schicht = schichten[quelle]
local aktiv = nil
if schicht then
local job = Jobs.Hole(schicht.jobId)
aktiv = {
jobId = schicht.jobId,
schrittIdx = schicht.schrittIdx,
aktuell = schicht.auftrag,
verdient = schicht.verdient,
auftraege = schicht.auftraege,
dienstzeit = math.floor((os.time() - schicht.dienstBeginn)),
schritte = job and job.schritte or {},
}
end
local person = Core:HoleSpielerInfo(quelle)
return {
jobs = Jobs.Liste,
fortschritt = HoleFortschritt(charakterId),
aktiv = aktiv,
xpProLevel = Jobs.XP_PRO_LEVEL,
schrittDauer= Jobs.SCHRITT_DAUER,
geld = {
bargeld = person and person.bargeld or 0,
konto = person and person.konto or 0,
},
}
end
local function SendeZustand(quelle, hinweis)
local zustand = BaueZustand(quelle)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:jobs:zustand', quelle, zustand)
end
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:jobs:fehler', quelle, grund)
end
-- ---------------------------------------------------------------------
-- Jobcenter oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:oeffnen', function()
local quelle = source
if not CharakterVon(quelle) then return end
-- Am Jobcenter oder bereits im Dienst (dann von ueberall einsehbar)
if not schichten[quelle] then
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - Jobs.JOBCENTER) > (Jobs.INTERAKTION_WEITE + 3.0) then
Ablehnen(quelle, 'Du bist nicht am Jobcenter.')
return
end
end
local zustand = BaueZustand(quelle)
if zustand then
TriggerClientEvent('aether:jobs:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Dienst antreten
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:antreten', function(jobId)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId or type(jobId) ~= 'string' then return end
local job = Jobs.Hole(jobId)
if not job then return end
if schichten[quelle] then
Ablehnen(quelle, 'Du bist bereits im Dienst. Mach erst Feierabend.')
return
end
local fortschritt = HoleFortschritt(charakterId)
local level = fortschritt[jobId].level
schichten[quelle] = {
jobId = jobId,
auftrag = Jobs.ErzeugeAuftrag(jobId, level),
schrittIdx = 0,
schrittSeit = 0,
dienstBeginn = os.time(),
verdient = 0,
auftraege = 0,
fahrzeug = nil,
}
-- Dienstfahrzeug bereitstellen, sofern der Job eines vorsieht
if job.fahrzeug then
schichten[quelle].fahrzeug = StelleFahrzeug(quelle, job)
if schichten[quelle].fahrzeug then
TriggerClientEvent('aether:jobs:fahrzeugBereit', quelle,
NetworkGetNetworkIdFromEntity(schichten[quelle].fahrzeug),
job.fahrzeug.spawn)
end
end
SendeZustand(quelle, 'Dienst angetreten: ' .. job.name)
end)
-- ---------------------------------------------------------------------
-- Arbeitsschritt ausfuehren
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:schritt', function()
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
local schicht = schichten[quelle]
if not schicht then Ablehnen(quelle, 'Du bist nicht im Dienst.') return end
local job = Jobs.Hole(schicht.jobId)
if not job then return end
local schrittDef = job.schritte[schicht.schrittIdx + 1]
if not schrittDef then return end
-- ---- Ortspruefung: Der Spieler muss wirklich dort stehen ----
local ziel = schicht.auftrag.orte and schicht.auftrag.orte[schicht.schrittIdx + 1]
if not ziel then return end
local position = GetEntityCoords(GetPlayerPed(quelle))
local abstand = #(position - vector3(ziel.position.x, ziel.position.y, ziel.position.z))
if abstand > (Jobs.ORT_RADIUS + 2.0) then
Ablehnen(quelle, ('Du bist nicht am Arbeitsort (%s).'):format(ziel.name))
return
end
-- ---- Zeitpruefung: Die Handlung braucht ihre vorgesehene Dauer ----
local dauer = schrittDef.dauer or 4000
local jetzt = GetGameTimer()
if schicht.schrittSeit > 0 and (jetzt - schicht.schrittSeit) < (dauer - 500) then
Ablehnen(quelle, 'Zu schnell — arbeite den Schritt ab.')
return
end
schicht.schrittSeit = jetzt
schicht.schrittIdx = schicht.schrittIdx + 1
-- Noch nicht der letzte Schritt? Dann nur weiterzaehlen.
if schicht.schrittIdx < #job.schritte then
SendeZustand(quelle)
return
end
-- ---- Auftrag abgeschlossen: bezahlen und Erfahrung gutschreiben ----
local auftrag = schicht.auftrag
local fortschritt = HoleFortschritt(charakterId)
local werte = fortschritt[schicht.jobId]
-- Lohn kommt aus dem serverseitig erzeugten Auftrag
Core:FuegeGeldHinzu(quelle, 'bargeld', auftrag.geld, 'Lohn ' .. job.name)
werte.xp = werte.xp + auftrag.xp
werte.verdient = werte.verdient + auftrag.geld
werte.auftraege = werte.auftraege + 1
local aufgestiegen = false
while werte.xp >= Jobs.XP_PRO_LEVEL and werte.level < Jobs.MAX_LEVEL do
werte.xp = werte.xp - Jobs.XP_PRO_LEVEL
werte.level = werte.level + 1
aufgestiegen = true
end
-- Auf Hoechstlevel keine Erfahrung mehr ansammeln
if werte.level >= Jobs.MAX_LEVEL then werte.xp = 0 end
SpeichereFortschritt(charakterId, schicht.jobId, werte)
schicht.verdient = schicht.verdient + auftrag.geld
schicht.auftraege = schicht.auftraege + 1
schicht.schrittIdx = 0
schicht.auftrag = Jobs.ErzeugeAuftrag(schicht.jobId, werte.level)
local hinweis = ('Auftrag erledigt · +%d €'):format(auftrag.geld)
if aufgestiegen then
hinweis = hinweis .. (' · Level %d erreicht!'):format(werte.level)
end
SendeZustand(quelle, hinweis)
end)
-- ---------------------------------------------------------------------
-- Feierabend
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:feierabend', function()
local quelle = source
local schicht = schichten[quelle]
if not schicht then return end
local job = Jobs.Hole(schicht.jobId)
local dauer = math.floor((os.time() - schicht.dienstBeginn) / 60)
EntferneFahrzeug(schicht)
schichten[quelle] = nil
SendeZustand(quelle, ('Feierabend · %d € in %d Minuten verdient.')
:format(schicht.verdient, dauer))
if job then
Aether.Debug(('%s hat den Dienst als %s beendet (%d €).')
:format(GetPlayerName(quelle), job.name, schicht.verdient))
end
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:jobs:schliessen', function()
-- Die Schicht laeuft weiter, nur die Oberflaeche wird geschlossen
end)
AddEventHandler('playerDropped', function()
EntferneFahrzeug(schichten[source])
schichten[source] = nil
end)
--- Beim Stoppen der Ressource alle Dienstfahrzeuge entfernen
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for _, schicht in pairs(schichten) do
EntferneFahrzeug(schicht)
end
end)
-- =====================================================================
-- EXPORTS
-- =====================================================================
--- Liefert den aktuellen Job eines Spielers (oder nil)
exports('HoleAktivenJob', function(quelle)
local schicht = schichten[quelle]
return schicht and schicht.jobId or nil
end)
--- Liefert das Level eines Spielers in einem Job
exports('HoleLevel', function(quelle, jobId)
local charakterId = CharakterVon(quelle)
if not charakterId then return 0 end
local fortschritt = HoleFortschritt(charakterId)
return fortschritt[jobId] and fortschritt[jobId].level or 1
end)