- 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>
387 lines
14 KiB
Lua
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)
|