-- ===================================================================== -- 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)