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

387 lines
14 KiB
Lua

-- =====================================================================
-- Aether Documents — Serverlogik
-- Verwaltet Ausweise, Führerscheine und Waffenscheine. Behoerdliche
-- Dokumente kann ein Spieler sich nicht selbst ausstellen.
-- =====================================================================
local Core = exports['aether-core']
local Inventory = exports['aether-inventory']
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
--- Erzeugt eine Dokumentnummer nach einem Muster
--- @param praefix string Vorsilbe, z. B. 'LSA'
--- @return string
local function ErzeugeNummer(praefix)
return ('%s-%04d-%04d'):format(praefix, math.random(1000, 9999), math.random(1000, 9999))
end
--- Rechnet eine Anzahl Jahre in ein Ablaufdatum um
--- @param jahre number|nil Gueltigkeit in Jahren
--- @return string|nil Datum im DB-Format oder nil
local function GueltigBis(jahre)
if not jahre then return nil end
return os.date('%Y-%m-%d', os.time() + jahre * 365 * 24 * 60 * 60)
end
--- Wie viele Boersen besitzt der Spieler? (jede Boerse ist ein Item)
--- @param quelle number Server-ID
--- @return number
local function AnzahlBoersen(quelle)
local anzahl = Inventory:ZaehleItem(quelle, Dokumente.BOERSE_ITEM) or 0
return math.max(0, anzahl)
end
--- Laedt alle Dokumente eines Charakters und ergaenzt die Personendaten
--- @param charakterId number Charakter-ID
--- @return table Liste von Dokumenten
local function HoleDokumente(charakterId)
local zeilen = MySQL.query.await([[
SELECT id, type, wallet, data, issued_by, issued_at, valid_until, revoked
FROM aether_documents
WHERE `character` = ? AND revoked = 0
ORDER BY id ASC
]], { charakterId }) or {}
local liste = {}
for _, zeile in ipairs(zeilen) do
local daten = {}
if zeile.data then
local erfolg, entschluesselt = pcall(json.decode, zeile.data)
if erfolg and type(entschluesselt) == 'table' then daten = entschluesselt end
end
local typ = Dokumente.Typen[zeile.type]
liste[#liste + 1] = {
id = zeile.id,
typ = zeile.type,
name = typ and typ.name or zeile.type,
icon = typ and typ.icon or '📄',
accent = typ and typ.accent or '#888',
boerse = zeile.wallet,
daten = daten,
aussteller= zeile.issued_by,
gueltigBis= zeile.valid_until and tostring(zeile.valid_until):sub(1, 10) or nil,
}
end
return liste
end
--- Legt die automatisch vergebenen Dokumente an, falls sie fehlen
--- @param charakterId number Charakter-ID
--- @param person table Charakterdaten aus dem Core
local function ErstelleAutomatische(charakterId, person)
for typKennung, typ in pairs(Dokumente.Typen) do
if typ.automatisch then
local vorhanden = MySQL.scalar.await(
'SELECT id FROM aether_documents WHERE `character` = ? AND type = ?',
{ charakterId, typKennung })
if not vorhanden then
-- Typspezifische Daten erzeugen
local daten = {}
if typKennung == 'perso' then
daten = {
nummer = ErzeugeNummer('LSA'),
gebOrt = person.herkunft or 'Los Santos',
groesse = tostring(math.random(160, 195)) .. ' cm',
}
elseif typKennung == 'gk' then
daten = {
versicherung = 'San Andreas Gesundheit',
nummer = ErzeugeNummer('GK'),
}
elseif typKennung == 'vk' then
daten = { telefon = nil } -- wird vom Handy-Modul gefuellt
end
MySQL.insert.await([[
INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until)
VALUES (?, ?, 0, ?, ?, ?)
]], {
charakterId, typKennung, json.encode(daten),
'Einwohnermeldeamt', GueltigBis(typ.gueltigJahre),
})
end
end
end
end
--- Baut den Zustand fuer die Oberflaeche
--- @param quelle number Server-ID
--- @return table|nil
local function BaueZustand(quelle)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return nil end
local person = Core:HoleSpielerInfo(quelle)
if not person then return nil end
-- Personen in Reichweite (bewusst ohne Namen — RP-Regel)
local hier = GetEntityCoords(GetPlayerPed(quelle))
local nahe = {}
for _, andere in ipairs(GetPlayers()) do
andere = tonumber(andere)
if andere ~= quelle then
local dort = GetEntityCoords(GetPlayerPed(andere))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
nahe[#nahe + 1] = { id = andere, bekannt = false }
end
end
end
return {
person = {
vorname = person.vorname, nachname = person.nachname,
geburt = person.geburtstag, geschlecht = person.geschlecht,
},
dokumente = HoleDokumente(charakterId),
boersen = AnzahlBoersen(quelle),
maxSlots = Dokumente.MAX_SLOTS,
spieler = nahe,
}
end
--- Schickt den Zustand an einen Spieler
local function SendeZustand(quelle, hinweis)
local zustand = BaueZustand(quelle)
if not zustand then return end
zustand.hinweis = hinweis
TriggerClientEvent('aether:documents:zustand', quelle, zustand)
end
--- Lehnt eine Aktion ab
local function Ablehnen(quelle, grund)
TriggerClientEvent('aether:documents:fehler', quelle, grund)
end
-- ---------------------------------------------------------------------
-- Beim Laden des Spielers: automatische Dokumente sicherstellen
-- ---------------------------------------------------------------------
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
ErstelleAutomatische(spieler.charakterId, {
herkunft = spieler.herkunft,
})
end)
-- ---------------------------------------------------------------------
-- Boerse oeffnen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:oeffnen', function()
local quelle = source
if not Core:HoleCharakterId(quelle) then return end
if AnzahlBoersen(quelle) < 1 then
Ablehnen(quelle, 'Du hast keine Börse dabei. Es gibt welche im 24/7.')
return
end
local zustand = BaueZustand(quelle)
if zustand then
TriggerClientEvent('aether:documents:zeige', quelle, zustand)
end
end)
-- ---------------------------------------------------------------------
-- Dokument in eine andere Boerse verschieben
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:verschieben', function(dokumentId, zielBoerse)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return end
if type(dokumentId) ~= 'number' or type(zielBoerse) ~= 'number' then return end
zielBoerse = math.floor(zielBoerse)
if zielBoerse < 0 or zielBoerse >= AnzahlBoersen(quelle) then
Ablehnen(quelle, 'Diese Börse hast du nicht.')
return
end
-- Gehoert das Dokument dem Spieler?
local gehoert = MySQL.scalar.await(
'SELECT id FROM aether_documents WHERE id = ? AND `character` = ? AND revoked = 0',
{ dokumentId, charakterId })
if not gehoert then return end
-- Platz in der Zielboerse pruefen
local belegt = MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_documents WHERE `character` = ? AND wallet = ? AND revoked = 0',
{ charakterId, zielBoerse }) or 0
if belegt >= Dokumente.MAX_SLOTS then
Ablehnen(quelle, 'Diese Börse ist voll.')
return
end
MySQL.update.await('UPDATE aether_documents SET wallet = ? WHERE id = ?', { zielBoerse, dokumentId })
SendeZustand(quelle, 'Dokument verschoben.')
end)
-- ---------------------------------------------------------------------
-- Dokument vorzeigen
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:vorzeigen', function(dokumentId, zielQuelle)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or type(dokumentId) ~= 'number' then return end
local dokumente = HoleDokumente(charakterId)
local gezeigt = nil
for _, dokument in ipairs(dokumente) do
if dokument.id == dokumentId then gezeigt = dokument break end
end
if not gezeigt then return end
local person = Core:HoleSpielerInfo(quelle)
gezeigt.inhaber = (person.vorname or '') .. ' ' .. (person.nachname or '')
gezeigt.geburt = person.geburtstag
-- Gezielt einer Person zeigen oder allen in Reichweite
local hier = GetEntityCoords(GetPlayerPed(quelle))
local empfaenger = {}
if type(zielQuelle) == 'number' then
local dort = GetEntityCoords(GetPlayerPed(zielQuelle))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
empfaenger[#empfaenger + 1] = zielQuelle
else
Ablehnen(quelle, 'Die Person ist zu weit weg.')
return
end
else
for _, andere in ipairs(GetPlayers()) do
andere = tonumber(andere)
if andere ~= quelle then
local dort = GetEntityCoords(GetPlayerPed(andere))
if #(hier - dort) <= Dokumente.VORZEIG_WEITE then
empfaenger[#empfaenger + 1] = andere
end
end
end
end
if #empfaenger == 0 then
Ablehnen(quelle, 'Niemand in der Nähe.')
return
end
for _, andere in ipairs(empfaenger) do
TriggerClientEvent('aether:documents:gezeigt', andere, gezeigt)
end
TriggerClientEvent('aether:documents:erfolg', quelle,
('%s vorgezeigt (%d Person%s).'):format(gezeigt.name, #empfaenger, #empfaenger == 1 and '' or 'en'))
end)
-- ---------------------------------------------------------------------
-- Dokument verlieren
-- ---------------------------------------------------------------------
RegisterNetEvent('aether:documents:verlieren', function(dokumentId)
local quelle = source
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or type(dokumentId) ~= 'number' then return end
local geloescht = MySQL.update.await(
'DELETE FROM aether_documents WHERE id = ? AND `character` = ?',
{ dokumentId, charakterId })
if (geloescht or 0) > 0 then
SendeZustand(quelle, 'Dokument verloren.')
end
end)
-- =====================================================================
-- EXPORTS — Schnittstelle fuer andere Module
-- =====================================================================
--- Besitzt der Spieler einen gueltigen Waffenschein?
--- Wird vom Shop (Ammu-Nation) abgefragt.
--- @param quelle number Server-ID
--- @return boolean
exports('HatWaffenschein', function(quelle)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId then return false end
local treffer = MySQL.scalar.await([[
SELECT id FROM aether_documents
WHERE `character` = ? AND type = 'ws' AND revoked = 0
AND (valid_until IS NULL OR valid_until >= CURDATE())
]], { charakterId })
return treffer ~= nil
end)
--- Besitzt der Spieler ein bestimmtes gueltiges Dokument?
--- @param quelle number Server-ID
--- @param typ string Dokumenttyp
--- @return boolean
exports('HatDokument', function(quelle, typ)
local charakterId = Core:HoleCharakterId(quelle)
if not charakterId or not Dokumente.TypGueltig(typ) then return false end
local treffer = MySQL.scalar.await([[
SELECT id FROM aether_documents
WHERE `character` = ? AND type = ? AND revoked = 0
AND (valid_until IS NULL OR valid_until >= CURDATE())
]], { charakterId, typ })
return treffer ~= nil
end)
--- Stellt einem Charakter ein behoerdliches Dokument aus.
--- Gedacht fuer das Fraktions-CAD (Polizei stellt Waffenschein aus).
--- @param charakterId number Charakter-ID
--- @param typ string Dokumenttyp
--- @param daten table|nil Zusatzdaten (z. B. Fahrklassen, Waffenliste)
--- @param aussteller string|nil Ausstellende Stelle
--- @return boolean Erfolg
exports('StelleAus', function(charakterId, typ, daten, aussteller)
if not Dokumente.TypGueltig(typ) then return false end
local typDef = Dokumente.Typen[typ]
local nummer = ErzeugeNummer(typ == 'ws' and 'WA' or 'DL')
daten = type(daten) == 'table' and daten or {}
daten.nummer = daten.nummer or nummer
-- Vorhandenes Dokument auffrischen, sonst neu anlegen
MySQL.query.await([[
INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until, revoked)
VALUES (?, ?, 0, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE
data = VALUES(data), issued_by = VALUES(issued_by),
valid_until = VALUES(valid_until), revoked = 0, issued_at = NOW()
]], {
charakterId, typ, json.encode(daten),
aussteller or 'Behörde', GueltigBis(typDef.gueltigJahre),
})
-- Falls der Spieler online ist, Ansicht auffrischen
local quelle = Core:HoleQuelleNachCharakter(charakterId)
if quelle then
SendeZustand(quelle, ('%s ausgestellt.'):format(typDef.name))
end
Aether.Info(('Dokument %s fuer Charakter #%d ausgestellt.'):format(typ, charakterId))
return true
end)
--- Entzieht ein behoerdliches Dokument
--- @param charakterId number Charakter-ID
--- @param typ string Dokumenttyp
--- @return boolean Erfolg
exports('Entziehe', function(charakterId, typ)
if not Dokumente.TypGueltig(typ) then return false end
MySQL.update.await(
'UPDATE aether_documents SET revoked = 1 WHERE `character` = ? AND type = ?',
{ charakterId, typ })
local quelle = Core:HoleQuelleNachCharakter(charakterId)
if quelle then
SendeZustand(quelle, ('%s wurde entzogen.'):format(Dokumente.Typen[typ].name))
end
Aether.Info(('Dokument %s fuer Charakter #%d entzogen.'):format(typ, charakterId))
return true
end)