- 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>
345 lines
12 KiB
Lua
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)
|