- 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>
222 lines
8.7 KiB
Lua
222 lines
8.7 KiB
Lua
-- =====================================================================
|
||
-- Aether Character — Serverlogik
|
||
-- Liefert die Charakterliste, legt Charaktere an, loescht sie und
|
||
-- uebergibt die Auswahl an den Core.
|
||
--
|
||
-- Grundsatz: Alles, was vom Client kommt, wird hier geprueft.
|
||
-- =====================================================================
|
||
|
||
local Core = exports['aether-core']
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Hilfsfunktionen
|
||
-- ---------------------------------------------------------------------
|
||
|
||
--- Liest die Rockstar-Lizenz eines Spielers aus
|
||
--- @param quelle number Server-ID
|
||
--- @return string|nil
|
||
local function HoleLizenz(quelle)
|
||
local lizenz = GetPlayerIdentifierByType(quelle, 'license')
|
||
if not lizenz then return nil end
|
||
return lizenz:gsub('license:', '')
|
||
end
|
||
|
||
--- Wandelt ein Datum von TT.MM.JJJJ nach JJJJ-MM-TT (Datenbankformat)
|
||
--- @param text string Eingabe des Spielers
|
||
--- @return string|nil Datum im DB-Format oder nil bei ungueltiger Eingabe
|
||
local function DatumFuerDatenbank(text)
|
||
if type(text) ~= 'string' then return nil end
|
||
local tag, monat, jahr = text:match('^(%d%d)%.(%d%d)%.(%d%d%d%d)$')
|
||
if not tag then return nil end
|
||
|
||
tag, monat, jahr = tonumber(tag), tonumber(monat), tonumber(jahr)
|
||
-- Grobe Plausibilitaetspruefung
|
||
if monat < 1 or monat > 12 or tag < 1 or tag > 31 then return nil end
|
||
if jahr < 1900 or jahr > 2010 then return nil end
|
||
|
||
return ('%04d-%02d-%02d'):format(jahr, monat, tag)
|
||
end
|
||
|
||
--- Wandelt ein Datum von JJJJ-MM-TT nach TT.MM.JJJJ (Anzeige)
|
||
--- @param wert any Wert aus der Datenbank
|
||
--- @return string
|
||
local function DatumFuerAnzeige(wert)
|
||
local text = tostring(wert or '')
|
||
local jahr, monat, tag = text:match('^(%d%d%d%d)-(%d%d)-(%d%d)')
|
||
if not jahr then return '—' end
|
||
return ('%s.%s.%s'):format(tag, monat, jahr)
|
||
end
|
||
|
||
--- Kuerzt und saeubert einen Namen (nur Buchstaben, Leerzeichen, Bindestrich)
|
||
--- @param text any Eingabe
|
||
--- @return string|nil Bereinigter Name oder nil, wenn ungueltig
|
||
local function SaeubereName(text)
|
||
if type(text) ~= 'string' then return nil end
|
||
local name = text:gsub('^%s+', ''):gsub('%s+$', '')
|
||
if #name < 2 or #name > 32 then return nil end
|
||
-- Nur Buchstaben (inkl. Umlaute), Leerzeichen und Bindestriche zulassen
|
||
if name:find('[^%a%sÄÖÜäöüß%-]') then return nil end
|
||
return name
|
||
end
|
||
|
||
--- Baut die Charakterliste in dem Format, das die Oberflaeche erwartet
|
||
--- @param lizenz string Rockstar-Lizenz
|
||
--- @return table Liste fuer die NUI
|
||
local function BaueListe(lizenz)
|
||
local datensaetze = MySQL.query.await([[
|
||
SELECT id, firstname, lastname, gender, dob, nationality,
|
||
job, cash, bank, playtime, appearance, last_played
|
||
FROM aether_characters
|
||
WHERE license = ? AND deleted = 0
|
||
ORDER BY last_played DESC
|
||
]], { lizenz }) or {}
|
||
|
||
local liste = {}
|
||
for _, datensatz in ipairs(datensaetze) do
|
||
-- Aussehen aus JSON zurueckwandeln
|
||
local aussehen = nil
|
||
if datensatz.appearance then
|
||
local erfolg, entschluesselt = pcall(json.decode, datensatz.appearance)
|
||
if erfolg then aussehen = entschluesselt end
|
||
end
|
||
|
||
liste[#liste + 1] = {
|
||
id = datensatz.id,
|
||
vorname = datensatz.firstname,
|
||
nachname = datensatz.lastname,
|
||
geschlecht = datensatz.gender,
|
||
geburt = DatumFuerAnzeige(datensatz.dob),
|
||
herkunft = datensatz.nationality,
|
||
job = datensatz.job,
|
||
geld = (datensatz.cash or 0) + (datensatz.bank or 0),
|
||
stunden = math.floor((datensatz.playtime or 0) / 60),
|
||
zuletzt = tostring(datensatz.last_played or ''):sub(1, 16),
|
||
aussehen = aussehen,
|
||
}
|
||
end
|
||
return liste
|
||
end
|
||
|
||
--- Schickt die aktuelle Liste an einen Spieler
|
||
--- @param quelle number Server-ID
|
||
--- @param hinweis string|nil Kurze Rueckmeldung fuer die Oberflaeche
|
||
local function SendeListe(quelle, hinweis)
|
||
local lizenz = HoleLizenz(quelle)
|
||
if not lizenz then return end
|
||
TriggerClientEvent('aether:character:liste', quelle,
|
||
BaueListe(lizenz), Config.MaxCharaktere, hinweis)
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Auswahl anzeigen (wird vom Core ausgeloest)
|
||
-- ---------------------------------------------------------------------
|
||
AddEventHandler('aether:server:charakterAuswahlNoetig', function(quelle)
|
||
local lizenz = HoleLizenz(quelle)
|
||
if not lizenz then return end
|
||
TriggerClientEvent('aether:character:zeigeAuswahl', quelle,
|
||
BaueListe(lizenz), Config.MaxCharaktere)
|
||
end)
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Charakter waehlen
|
||
-- ---------------------------------------------------------------------
|
||
RegisterNetEvent('aether:character:spielen', function(charakterId)
|
||
local quelle = source
|
||
local lizenz = HoleLizenz(quelle)
|
||
if not lizenz or type(charakterId) ~= 'number' then return end
|
||
|
||
-- Gehoert der Charakter wirklich diesem Spieler?
|
||
local treffer = MySQL.query.await(
|
||
'SELECT id FROM aether_characters WHERE id = ? AND license = ? AND deleted = 0',
|
||
{ charakterId, lizenz })
|
||
|
||
if not treffer or not treffer[1] then
|
||
TriggerClientEvent('aether:character:fehler', quelle, 'Dieser Charakter gehört dir nicht.')
|
||
return
|
||
end
|
||
|
||
-- Core laedt den Charakter und spawnt den Spieler
|
||
local erfolg = Core:LadeCharakter(quelle, charakterId)
|
||
if not erfolg then
|
||
TriggerClientEvent('aether:character:fehler', quelle, 'Charakter konnte nicht geladen werden.')
|
||
end
|
||
end)
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Charakter erstellen
|
||
-- ---------------------------------------------------------------------
|
||
RegisterNetEvent('aether:character:erstellen', function(daten)
|
||
local quelle = source
|
||
local lizenz = HoleLizenz(quelle)
|
||
if not lizenz or type(daten) ~= 'table' then return end
|
||
|
||
-- Slotgrenze pruefen
|
||
local vorhandene = MySQL.scalar.await(
|
||
'SELECT COUNT(*) FROM aether_characters WHERE license = ? AND deleted = 0', { lizenz })
|
||
if (vorhandene or 0) >= Config.MaxCharaktere then
|
||
TriggerClientEvent('aether:character:fehler', quelle,
|
||
('Du hast bereits %d Charaktere.'):format(Config.MaxCharaktere))
|
||
return
|
||
end
|
||
|
||
-- Namen pruefen
|
||
local vorname = SaeubereName(daten.vorname)
|
||
local nachname = SaeubereName(daten.nachname)
|
||
if not vorname or not nachname then
|
||
TriggerClientEvent('aether:character:fehler', quelle,
|
||
'Bitte gültige Vor- und Nachnamen angeben (nur Buchstaben, 2–32 Zeichen).')
|
||
return
|
||
end
|
||
|
||
-- Geburtsdatum pruefen
|
||
local geburt = DatumFuerDatenbank(daten.geburt)
|
||
if not geburt then
|
||
TriggerClientEvent('aether:character:fehler', quelle,
|
||
'Ungültiges Geburtsdatum. Format: TT.MM.JJJJ (Jahr 1900–2010).')
|
||
return
|
||
end
|
||
|
||
-- Geschlecht auf erlaubte Werte begrenzen
|
||
local geschlecht = daten.geschlecht
|
||
if geschlecht ~= 'männlich' and geschlecht ~= 'weiblich' and geschlecht ~= 'divers' then
|
||
geschlecht = 'männlich'
|
||
end
|
||
|
||
-- Charakter anlegen (eigene Abfrage, da `Aether.DB` im Core-State liegt)
|
||
local neueId = MySQL.insert.await([[
|
||
INSERT INTO aether_characters
|
||
(license, firstname, lastname, dob, gender, nationality, appearance,
|
||
cash, bank, iban, job, job_grade)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
]], {
|
||
lizenz, vorname, nachname, geburt, geschlecht,
|
||
tostring(daten.herkunft or 'Los Santos'):sub(1, 48),
|
||
type(daten.aussehen) == 'table' and json.encode(daten.aussehen) or nil,
|
||
Config.StartWerte.bargeld,
|
||
Config.StartWerte.konto,
|
||
Aether.ErzeugeIban(),
|
||
Config.StartWerte.job,
|
||
Config.StartWerte.jobRang,
|
||
})
|
||
|
||
Aether.Info(('Charakter #%d erstellt: %s %s'):format(neueId, vorname, nachname))
|
||
SendeListe(quelle, ('%s %s wurde erstellt.'):format(vorname, nachname))
|
||
end)
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Charakter loeschen
|
||
-- ---------------------------------------------------------------------
|
||
RegisterNetEvent('aether:character:loeschen', function(charakterId)
|
||
local quelle = source
|
||
local lizenz = HoleLizenz(quelle)
|
||
if not lizenz or type(charakterId) ~= 'number' then return end
|
||
|
||
-- Weich loeschen; die Lizenzpruefung in der Abfrage sichert das Eigentum ab
|
||
MySQL.update.await(
|
||
'UPDATE aether_characters SET deleted = 1 WHERE id = ? AND license = ?',
|
||
{ charakterId, lizenz })
|
||
Aether.Info(('Charakter #%d geloescht (Lizenz %s).'):format(charakterId, lizenz))
|
||
|
||
SendeListe(quelle, 'Charakter gelöscht.')
|
||
end)
|