aether-framework/resources/[aether]/aether-garage/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

512 lines
20 KiB
Lua

-- =====================================================================
-- Aether Garage — Serverlogik
-- Verwaltet Fahrzeugbesitz, Ein- und Ausparken. Der Client darf ein
-- Fahrzeug erst erzeugen, nachdem der Server es freigegeben hat.
-- =====================================================================
local Core = exports['aether-core']
-- Fahrzeuge, die gerade ausgeparkt werden duerfen: [quelle] = kennzeichen
local freigaben = {}
-- Aktuell in der Welt stehende Fahrzeuge: [kennzeichen] = Entity-Handle
-- Sie werden vom Server erzeugt und ueberleben dadurch auch, wenn kein
-- Spieler in der Naehe ist.
local aktiveFahrzeuge = {}
-- ---------------------------------------------------------------------
-- Fahrzeuge in der Welt erzeugen und entfernen
-- ---------------------------------------------------------------------
--- Erzeugt ein Fahrzeug serverseitig an einer Position
--- @param datensatz table Zeile aus aether_vehicles
--- @param x number
--- @param y number
--- @param z number
--- @param heading number
--- @return number|nil Entity-Handle
local function ErzeugeFahrzeug(datensatz, x, y, z, heading)
-- Bereits vorhandenes Fahrzeug mit diesem Kennzeichen zuerst entfernen
local vorhandenes = aktiveFahrzeuge[datensatz.plate]
if vorhandenes and DoesEntityExist(vorhandenes) then
DeleteEntity(vorhandenes)
end
local fahrzeug = CreateVehicle(GetHashKey(datensatz.model), x, y, z, heading or 0.0, true, true)
if not fahrzeug or fahrzeug == 0 then return nil end
-- Warten, bis die Entitaet wirklich existiert
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
local props = {}
if datensatz.props then
local erfolg, entschluesselt = pcall(json.decode, datensatz.props)
if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end
end
-- Alles, was nur clientseitig gesetzt werden kann, ueber den State Bag
-- weitergeben. Der Client wendet es an, sobald das Fahrzeug bei ihm
-- auftaucht (siehe client/main.lua).
Entity(fahrzeug).state:set('aether', {
kennzeichen = datensatz.plate,
farbe = props.farbe,
tank = datensatz.fuel,
motor = datensatz.engine,
karosserie = datensatz.body,
}, true)
aktiveFahrzeuge[datensatz.plate] = fahrzeug
return fahrzeug
end
--- Entfernt ein Fahrzeug aus der Welt
--- @param kennzeichen string Kennzeichen
local function EntferneFahrzeug(kennzeichen)
local fahrzeug = aktiveFahrzeuge[kennzeichen]
if fahrzeug and DoesEntityExist(fahrzeug) then
DeleteEntity(fahrzeug)
end
aktiveFahrzeuge[kennzeichen] = nil
end
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Liefert die Charakter-ID eines Spielers
local function CharakterVon(quelle)
return Core:HoleCharakterId(quelle)
end
--- Prueft, ob ein Spieler Zugang zu einer Garage hat.
--- Haus-Garagen werden spaeter ueber `aether-immobilien` geprueft.
--- @param quelle number Server-ID
--- @param garageId string Kennung der Garage
--- @return boolean
local function HatZugang(quelle, garageId)
local garage = Garage.Garagen[garageId]
if not garage then return false end
if garage.typ == 'oeffentlich' then return true end
-- Optionale Abfrage beim Immobilien-Modul
local erfolg, ergebnis = pcall(function()
return exports['aether-immobilien']:HatGaragenZugang(quelle, garageId)
end)
if erfolg and type(ergebnis) == 'boolean' then return ergebnis end
-- Modul noch nicht vorhanden: Haus-Garagen bleiben verschlossen
return false
end
--- Laedt alle Fahrzeuge eines Charakters
--- @param charakterId number Charakter-ID
--- @return table Liste von Fahrzeugen
local function HoleFahrzeuge(charakterId)
local zeilen = MySQL.query.await([[
SELECT plate, model, props, garage, state, fuel, engine, body, mileage
FROM aether_vehicles WHERE owner = ?
]], { charakterId }) or {}
local liste = {}
for _, zeile in ipairs(zeilen) do
local props = {}
if zeile.props then
local erfolg, entschluesselt = pcall(json.decode, zeile.props)
if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end
end
liste[#liste + 1] = {
kennzeichen = zeile.plate,
modell = zeile.model,
name = props.name or zeile.model,
klasse = props.klasse or 'Fahrzeug',
typ = props.typ or 'sedan',
farbe = props.farbe or '#20232a',
plateStyle = props.plateStyle or 'sa',
garage = zeile.garage,
status = zeile.state, -- in | out | impound
tank = math.floor(zeile.fuel or 0),
motor = math.floor((zeile.engine or 1000) / 10),
karosserie = math.floor((zeile.body or 1000) / 10),
km = zeile.mileage or 0,
}
end
return liste
end
--- Baut den Zustand fuer die Oberflaeche
--- @param quelle number Server-ID
--- @param standort string Garage, an der der Spieler steht
--- @return table|nil
local function BaueZustand(quelle, standort)
local charakterId = CharakterVon(quelle)
if not charakterId then return nil end
-- Nur zugaengliche Garagen anzeigen
local garagen = {}
for garageId, garage in pairs(Garage.Garagen) do
if HatZugang(quelle, garageId) then
garagen[#garagen + 1] = {
id = garageId, name = garage.name, typ = garage.typ,
}
end
end
return {
garagen = garagen,
fahrzeuge = HoleFahrzeuge(charakterId),
standort = standort,
gebuehr = Garage.AUSLOESE_GEBUEHR,
}
end
--- Schickt den Zustand an einen Spieler
local function SendeZustand(quelle, standort, hinweis)
local zustand = BaueZustand(quelle, standort)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:garage:zustand', quelle, zustand)
end
--- Lehnt eine Aktion ab
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:garage:fehler', quelle, grund)
end
--- Liest ein Fahrzeug und prueft den Besitz
--- @param charakterId number Charakter-ID
--- @param kennzeichen string Kennzeichen
--- @return table|nil
local function HoleEigenes(charakterId, kennzeichen)
local treffer = MySQL.query.await(
'SELECT * FROM aether_vehicles WHERE plate = ? AND owner = ?',
{ kennzeichen, charakterId })
return treffer and treffer[1] or nil
end
-- ---------------------------------------------------------------------
-- Ausgeparkte Fahrzeuge beim Serverstart wiederherstellen
-- ---------------------------------------------------------------------
CreateThread(function()
Wait(2000) -- warten, bis Datenbank und Spielwelt bereit sind
local zeilen = MySQL.query.await([[
SELECT * FROM aether_vehicles
WHERE state = 'out' AND pos_x IS NOT NULL
]]) or {}
local wiederhergestellt = 0
for _, zeile in ipairs(zeilen) do
local entitaet = ErzeugeFahrzeug(zeile, zeile.pos_x, zeile.pos_y, zeile.pos_z, zeile.heading)
if entitaet then
wiederhergestellt = wiederhergestellt + 1
else
-- Konnte es nicht erzeugt werden (z. B. Modell entfernt),
-- wandert es zurueck in seine Garage statt verloren zu gehen.
MySQL.update("UPDATE aether_vehicles SET state = 'in' WHERE plate = ?", { zeile.plate })
Aether.Fehler(('Fahrzeug %s konnte nicht gesetzt werden — zurueck in die Garage.')
:format(zeile.plate))
end
Wait(50) -- Erzeugung entzerren
end
if wiederhergestellt > 0 then
Aether.Info(('%d ausgeparkte Fahrzeuge wiederhergestellt.'):format(wiederhergestellt))
end
end)
-- ---------------------------------------------------------------------
-- Position der ausgeparkten Fahrzeuge regelmaessig sichern
-- ---------------------------------------------------------------------
CreateThread(function()
while true do
Wait(30000) -- alle 30 Sekunden
for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do
if DoesEntityExist(fahrzeug) then
local position = GetEntityCoords(fahrzeug)
MySQL.update([[
UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen })
else
-- Entitaet ist verschwunden (z. B. zerstoert): Eintrag aufraeumen
aktiveFahrzeuge[kennzeichen] = nil
end
end
end
end)
--- Beim Stoppen der Ressource die letzten Positionen sichern
AddEventHandler('onResourceStop', function(ressource)
if ressource ~= GetCurrentResourceName() then return end
for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do
if DoesEntityExist(fahrzeug) then
local position = GetEntityCoords(fahrzeug)
MySQL.update.await([[
UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen })
end
end
Aether.Info('Fahrzeugpositionen gesichert.')
end)
-- ---------------------------------------------------------------------
-- Zustand eines fahrenden Fahrzeugs uebernehmen (vom Client gemeldet)
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:zustandMelden', function(kennzeichen, zustand)
if type(kennzeichen) ~= 'string' or type(zustand) ~= 'table' then return end
if not aktiveFahrzeuge[kennzeichen] then return end -- nur bekannte Fahrzeuge
local tank = math.max(0, math.min(100, tonumber(zustand.tank) or 100))
local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or 1000))
local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or 1000))
MySQL.update('UPDATE aether_vehicles SET fuel = ?, engine = ?, body = ? WHERE plate = ?',
{ tank, motor, body, kennzeichen })
end)
-- ---------------------------------------------------------------------
-- Startfahrzeug fuer neue Charaktere
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
if not Garage.STARTFAHRZEUG then return end
local vorhanden = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_vehicles WHERE owner = ?', { spieler.charakterId })
if (vorhanden or 0) > 0 then return end
local kennzeichen = Garage.ErzeugeKennzeichen()
MySQL.insert.await([[
INSERT INTO aether_vehicles (plate, owner, model, props, garage, state)
VALUES (?, ?, ?, ?, ?, 'in')
]], {
kennzeichen, spieler.charakterId, Garage.STARTFAHRZEUG.modell,
json.encode({ name = 'Blista', klasse = 'Kleinwagen', typ = 'compact',
farbe = '#3563a8', plateStyle = 'sa' }),
Garage.STARTFAHRZEUG.garage,
})
Aether.Info(('Startfahrzeug %s fuer Charakter #%d angelegt.')
:format(kennzeichen, spieler.charakterId))
end)
-- ---------------------------------------------------------------------
-- Garage oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:oeffnen', function(garageId)
local quelle = source
if not CharakterVon(quelle) then return end
if type(garageId) ~= 'string' or not Garage.Garagen[garageId] then return end
if not HatZugang(quelle, garageId) then
Ablehnen(quelle, 'Zu dieser Garage hast du keinen Zugang.')
return
end
-- Entfernungspruefung
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - Garage.Garagen[garageId].position) > (Garage.INTERAKTION_WEITE + 3.0) then
Ablehnen(quelle, 'Du bist nicht an dieser Garage.')
return
end
local zustand = BaueZustand(quelle, garageId)
if zustand then
TriggerClientEvent('aether:garage:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Ausparken
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:ausparken', function(kennzeichen, standort)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
local garage = Garage.Garagen[standort]
if not garage or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end
if fahrzeug.state == 'impound' then
Ablehnen(quelle, 'Das Fahrzeug ist beschlagnahmt.')
return
end
if fahrzeug.state == 'out' then
Ablehnen(quelle, 'Das Fahrzeug steht bereits draußen.')
return
end
-- Standort-Regel: nur an der Garage, wo das Fahrzeug wirklich steht
if fahrzeug.garage ~= standort then
local wo = Garage.Garagen[fahrzeug.garage]
Ablehnen(quelle, ('Das Fahrzeug steht in %s.'):format(wo and wo.name or fahrzeug.garage))
return
end
-- Fahrzeug serverseitig erzeugen, damit es unabhaengig vom Spieler
-- (und ueber Neustarts hinweg) in der Welt bleibt
local spawn = garage.spawn
local entitaet = ErzeugeFahrzeug(fahrzeug, spawn.x, spawn.y, spawn.z, spawn.w)
if not entitaet then
Ablehnen(quelle, 'Fahrzeug konnte nicht erzeugt werden.')
return
end
MySQL.update.await([[
UPDATE aether_vehicles
SET state = 'out', pos_x = ?, pos_y = ?, pos_z = ?, heading = ?
WHERE plate = ?
]], { spawn.x, spawn.y, spawn.z, spawn.w, kennzeichen })
freigaben[quelle] = kennzeichen
-- Dem Client sagen, in welches Fahrzeug er einsteigen soll
TriggerClientEvent('aether:garage:einsteigen', quelle,
NetworkGetNetworkIdFromEntity(entitaet), kennzeichen)
end)
-- ---------------------------------------------------------------------
-- Einparken
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:einparken', function(kennzeichen, standort, zustand)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
local garage = Garage.Garagen[standort]
if not garage or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end
-- Entfernung des Spielers zur Garage pruefen
local position = GetEntityCoords(GetPlayerPed(quelle))
if #(position - garage.position) > Garage.EINPARK_WEITE then
Ablehnen(quelle, 'Du bist zu weit von der Garage entfernt.')
return
end
-- Zustandswerte vom Client uebernehmen, aber begrenzen
zustand = type(zustand) == 'table' and zustand or {}
local tank = math.max(0, math.min(100, tonumber(zustand.tank) or fahrzeug.fuel or 100))
local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or fahrzeug.engine or 1000))
local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or fahrzeug.body or 1000))
-- Kilometerstand: gefahrene Meter aufaddieren (nie verringern).
-- Nach oben begrenzt, damit manipulierte Werte nichts kaputtmachen.
local meter = math.max(0, math.min(1000000, math.floor(tonumber(zustand.meter) or 0)))
local km = (fahrzeug.mileage or 0) + math.floor(meter / 1000)
MySQL.update.await([[
UPDATE aether_vehicles
SET state = 'in', garage = ?, fuel = ?, engine = ?, body = ?, mileage = ?,
pos_x = NULL, pos_y = NULL, pos_z = NULL, heading = NULL
WHERE plate = ?
]], { standort, tank, motor, body, km, kennzeichen })
-- Fahrzeug aus der Welt nehmen (serverseitig, damit es bei allen verschwindet)
EntferneFahrzeug(kennzeichen)
freigaben[quelle] = nil
SendeZustand(quelle, standort, ('Fahrzeug in %s eingelagert.'):format(garage.name))
end)
-- ---------------------------------------------------------------------
-- Beschlagnahmtes Fahrzeug ausloesen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:garage:ausloesen', function(kennzeichen, standort)
local quelle = source
local charakterId = CharakterVon(quelle)
if not charakterId then return end
if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end
if not Garage.Garagen[standort] or not HatZugang(quelle, standort) then return end
local fahrzeug = HoleEigenes(charakterId, kennzeichen)
if not fahrzeug or fahrzeug.state ~= 'impound' then return end
if not Core:EntferneGeld(quelle, 'bargeld', Garage.AUSLOESE_GEBUEHR, 'Fahrzeug ausgelöst') then
Ablehnen(quelle, ('Du brauchst %d € in bar.'):format(Garage.AUSLOESE_GEBUEHR))
return
end
MySQL.update.await(
'UPDATE aether_vehicles SET state = ?, garage = ? WHERE plate = ?',
{ 'in', standort, kennzeichen })
SendeZustand(quelle, standort, ('Fahrzeug für %d € ausgelöst.'):format(Garage.AUSLOESE_GEBUEHR))
end)
-- ---------------------------------------------------------------------
-- Aufraeumen
-- ---------------------------------------------------------------------
AddEventHandler('playerDropped', function()
freigaben[source] = nil
end)
-- =====================================================================
-- EXPORTS
-- =====================================================================
--- Gibt einem Charakter ein Fahrzeug
--- @param charakterId number Charakter-ID
--- @param modell string Spawnname des Modells
--- @param garageId string Garage, in der es steht
--- @param props table|nil Anzeigedaten (Name, Klasse, Farbe ...)
--- @return string|nil Kennzeichen
exports('GebeFahrzeug', function(charakterId, modell, garageId, props)
if type(charakterId) ~= 'number' or type(modell) ~= 'string' then return nil end
local kennzeichen = Garage.ErzeugeKennzeichen()
MySQL.insert.await([[
INSERT INTO aether_vehicles (plate, owner, model, props, garage, state)
VALUES (?, ?, ?, ?, ?, 'in')
]], {
kennzeichen, charakterId, modell,
json.encode(props or { name = modell }),
garageId or 'legion',
})
return kennzeichen
end)
--- Beschlagnahmt ein Fahrzeug (fuer das Polizei-CAD)
--- @param kennzeichen string Kennzeichen
--- @return boolean Erfolg
exports('Beschlagnahmen', function(kennzeichen)
if type(kennzeichen) ~= 'string' then return false end
MySQL.update.await(
"UPDATE aether_vehicles SET state = 'impound' WHERE plate = ?", { kennzeichen })
return true
end)
--- Sucht den Besitzer eines Kennzeichens (fuer die Kennzeichenabfrage im CAD)
--- @param kennzeichen string Kennzeichen
--- @return table|nil { charakterId, vorname, nachname, modell }
exports('HoleHalter', function(kennzeichen)
local treffer = MySQL.query.await([[
SELECT v.owner, v.model, c.firstname, c.lastname
FROM aether_vehicles v
JOIN aether_characters c ON c.id = v.owner
WHERE v.plate = ?
]], { kennzeichen })
if not treffer or not treffer[1] then return nil end
return {
charakterId = treffer[1].owner,
vorname = treffer[1].firstname,
nachname = treffer[1].lastname,
modell = treffer[1].model,
}
end)