aether-framework/resources/[aether]/aether-admin/server/module/spieler.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

361 lines
15 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- =====================================================================
-- aether-admin / Modul: Spieler
--
-- Die Liste der Online-Spieler und alle Eingriffe an ihnen. Nichts
-- davon vertraut der Oberfläche: jeder Aufruf ist serverseitig
-- rechtegeprüft, Zielspieler werden über ihre echte Server-ID
-- aufgelöst, und alles Eingreifende landet im Prüfprotokoll.
-- =====================================================================
Admin = Admin or {}
Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} }
-- Grobe Ortsangabe aus Koordinaten (Annäherung, rein informativ)
local ORTE = {
{ name = 'Legion Square', x = 195.0, y = -930.0 },
{ name = 'Mission Row', x = 428.0, y = -984.0 },
{ name = 'Vinewood', x = 300.0, y = 200.0 },
{ name = 'Vespucci', x = -1230.0, y = -1500.0 },
{ name = 'Del Perro', x = -1600.0, y = -300.0 },
{ name = 'Sandy Shores', x = 1960.0, y = 3740.0 },
{ name = 'Paleto Bay', x = -100.0, y = 6330.0 },
{ name = 'LS Airport', x = -1030.0, y = -2730.0 },
{ name = 'Grove Street', x = 90.0, y = -1930.0 },
}
--- Nächstgelegener benannter Ort zu einer Position
--- @param pos table|nil { x, y }
--- @return string
local function OrtVon(pos)
if not pos or not pos.x then return '—' end
local beste, abstand = '—', math.huge
for _, ort in ipairs(ORTE) do
local d = (ort.x - pos.x) ^ 2 + (ort.y - pos.y) ^ 2
if d < abstand then abstand, beste = d, ort.name end
end
return beste
end
--- Verwarnungen eines Benutzers zählen
--- @param lizenz string|nil
--- @return number
local function WarnAnzahl(lizenz)
if not lizenz then return 0 end
return tonumber(MySQL.scalar.await(
'SELECT COUNT(*) FROM aether_admin_warns WHERE license = ?', { lizenz })) or 0
end
--- Baut den Anzeigedatensatz eines Online-Spielers
--- @param serverId number
--- @param daten table Spielerdaten aus aether-core
--- @return table
local function SpielerZeile(serverId, daten)
local lizenz = Admin.Auth.Lizenz(serverId)
return {
id = serverId,
name = (daten.vorname or '?') .. ' ' .. (daten.nachname or ''),
charakterId = daten.charakterId,
job = daten.job or 'unemployed',
jobRang = daten.jobRang or 0,
fraktion = daten.fraktion,
cash = math.floor(daten.bargeld or 0),
bank = math.floor(daten.konto or 0),
ping = GetPlayerPing(serverId) or 0,
region = OrtVon(daten.position),
leben = daten.leben or 200,
istTot = daten.istTot and true or false,
warns = WarnAnzahl(lizenz),
god = Admin.Live.god[serverId] and true or false,
frozen = Admin.Live.frozen[serverId] and true or false,
muted = Admin.Live.muted[serverId] and true or false,
}
end
--- Sammelt alle Online-Spieler
--- @return table Liste
function Admin.SpielerListe()
local alle = exports['aether-core']:HoleAlleSpieler() or {}
local liste = {}
for serverId, daten in pairs(alle) do
liste[#liste + 1] = SpielerZeile(tonumber(serverId), daten)
end
table.sort(liste, function(a, b) return a.id < b.id end)
return liste
end
-- =====================================================================
-- LISTE & DETAILS
-- =====================================================================
Admin.Kern.Registriere('spieler', 'liste', function(handelnder, daten)
return {
spieler = Admin.SpielerListe(),
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' },
}
end)
Admin.Kern.Registriere('spieler', 'details', function(handelnder, daten)
local serverId = tonumber(daten.id)
if not serverId then return nil, 'Ungültige Spieler-ID.' end
local info = exports['aether-core']:HoleSpielerInfo(serverId)
if not info then return nil, 'Spieler ist nicht (mehr) online.' end
local lizenz = Admin.Auth.Lizenz(serverId)
local warns = lizenz and MySQL.query.await([[
SELECT id, grund, admin_name, erstellt_am FROM aether_admin_warns
WHERE license = ? ORDER BY id DESC LIMIT 25
]], { lizenz }) or {}
local notizen = lizenz and MySQL.query.await([[
SELECT id, notiz, admin_name, erstellt_am FROM aether_admin_notes
WHERE license = ? ORDER BY id DESC LIMIT 25
]], { lizenz }) or {}
return {
spieler = SpielerZeile(serverId, info),
lizenz = lizenz,
gruppe = Admin.Auth.Gruppe(serverId),
spielzeit = info.spielzeit,
warns = warns,
notizen = notizen,
rechte = Admin.Kern.RechteAbbild(handelnder.quelle),
jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' },
}
end)
-- =====================================================================
-- TELEPORT & ANSICHT
-- =====================================================================
--- Auflösung: Position eines Spielers (aus dem Live-Cache)
local function PosVon(serverId)
return Admin.Live.pos[serverId]
end
Admin.Kern.Registriere('spieler', 'goto', function(handelnder, daten)
local ziel = tonumber(daten.id)
local pos = PosVon(ziel)
if not pos then return nil, 'Position des Ziels ist noch nicht bekannt.' end
TriggerClientEvent('aether-admin:client:teleport', handelnder.quelle, pos)
Admin.Log.Schreibe(handelnder, 'Zu Spieler teleportiert', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'bring', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local pos = PosVon(handelnder.quelle)
if not pos then return nil, 'Deine eigene Position ist noch nicht bekannt.' end
TriggerClientEvent('aether-admin:client:teleport', ziel, pos)
Admin.Log.Schreibe(handelnder, 'Spieler hergeholt', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'spectate', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
TriggerClientEvent('aether-admin:client:spectate', handelnder.quelle, {
ziel = ziel, pos = PosVon(ziel),
})
Admin.Log.Schreibe(handelnder, 'Beobachtet', GetPlayerName(ziel))
return { ok = true }
end)
-- =====================================================================
-- ZUSTAND
-- =====================================================================
--- Kleiner Baukasten für einfache „an Ziel weiterreichen"-Aktionen
--- @param aktion string Registrierter Aktionsname
--- @param event string Client-Event am Ziel
--- @param protokoll string Protokolltext
local function ZielAktion(aktion, event, protokoll)
Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
TriggerClientEvent(event, ziel)
Admin.Log.Schreibe(handelnder, protokoll, GetPlayerName(ziel))
return { ok = true }
end)
end
ZielAktion('heal', 'aether-admin:client:heal', 'Spieler geheilt')
ZielAktion('revive', 'aether-admin:client:revive', 'Spieler wiederbelebt')
ZielAktion('armor', 'aether-admin:client:armor', 'Rüstung gegeben')
--- Umschalt-Aktion mit gemerktem Zustand (Freeze/God/Mute)
--- @param aktion string
--- @param feld string Schlüssel in Admin.Live
--- @param event string|nil Client-Event am Ziel (nil = nur serverseitig, z. B. Mute)
--- @param nomen string Für das Protokoll
local function UmschaltAktion(aktion, feld, event, nomen)
Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local neu = not Admin.Live[feld][ziel]
Admin.Live[feld][ziel] = neu or nil
if event then TriggerClientEvent(event, ziel, neu) end
Admin.Log.Schreibe(handelnder, nomen .. (neu and ' an' or ' aus'), GetPlayerName(ziel))
return { ok = true, aktiv = neu }
end)
end
UmschaltAktion('freeze', 'frozen', 'aether-admin:client:freeze', 'Einfrieren')
UmschaltAktion('god', 'god', 'aether-admin:client:god', 'Godmode')
UmschaltAktion('mute', 'muted', nil, 'Stummschaltung')
-- =====================================================================
-- GEBEN & SETZEN
-- =====================================================================
Admin.Kern.Registriere('spieler', 'geld', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local konto = daten.konto == 'cash' and 'cash' or 'bank'
local betrag = math.floor(tonumber(daten.betrag) or 0)
if betrag == 0 then return nil, 'Bitte einen Betrag angeben.' end
local ok
if betrag > 0 then
ok = exports['aether-core']:FuegeGeldHinzu(ziel, konto, betrag, 'Admin: ' .. handelnder.name)
else
ok = exports['aether-core']:EntferneGeld(ziel, konto, -betrag, 'Admin: ' .. handelnder.name)
end
if not ok then return nil, 'Nicht möglich (Deckung?).' end
Admin.Log.Schreibe(handelnder, 'Geld ' .. (betrag > 0 and '+' or '') .. betrag,
GetPlayerName(ziel), konto)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'item', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local item = tostring(daten.item or '')
local menge = math.max(1, math.floor(tonumber(daten.menge) or 1))
if item == '' then return nil, 'Kein Item gewählt.' end
-- Falls ein Inventar-Resource läuft, dorthin geben; sonst nur vermerken
local ok = pcall(function()
return exports['aether-inventory']:GebeItem(ziel, item, menge)
end)
Admin.Log.Schreibe(handelnder, 'Item gegeben', GetPlayerName(ziel), menge .. '× ' .. item)
return { ok = true, zugestellt = ok }
end)
Admin.Kern.Registriere('spieler', 'fahrzeug', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local modell = tostring(daten.modell or ''):gsub('%s', '')
if modell == '' then return nil, 'Kein Modell gewählt.' end
TriggerClientEvent('aether-admin:client:fahrzeug', ziel, modell)
Admin.Log.Schreibe(handelnder, 'Fahrzeug gespawnt', GetPlayerName(ziel), modell)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'job', function(handelnder, daten)
local ziel = tonumber(daten.id)
local charakterId = tonumber(daten.charakterId)
if not charakterId then return nil, 'Charakter unbekannt.' end
local job = tostring(daten.job or 'unemployed')
local fraktion = daten.fraktion ~= '' and tostring(daten.fraktion) or nil
MySQL.update.await(
'UPDATE aether_characters SET job = ?, faction = ? WHERE id = ?',
{ job, fraktion, charakterId })
-- Falls online, den Client informieren (greift spätestens beim Nachladen)
if ziel and GetPlayerName(ziel) then
TriggerClientEvent('aether-admin:client:hinweis', ziel,
'Deine Tätigkeit wurde vom Team angepasst: ' .. job)
end
Admin.Log.Schreibe(handelnder, 'Job/Fraktion gesetzt',
GetPlayerName(ziel) or ('Charakter #' .. charakterId),
job .. (fraktion and (' / ' .. fraktion) or ''))
return { ok = true }
end)
-- =====================================================================
-- MODERATION AM SPIELER
-- =====================================================================
Admin.Kern.Registriere('spieler', 'warn', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or ''):sub(1, 255)
if #grund < 2 then return nil, 'Bitte einen Grund angeben.' end
local lizenz = Admin.Auth.Lizenz(ziel)
local charakterId = exports['aether-core']:HoleCharakterId(ziel)
MySQL.insert.await([[
INSERT INTO aether_admin_warns (license, `character`, name, grund, admin_license, admin_name)
VALUES (?, ?, ?, ?, ?, ?)
]], { lizenz, charakterId, GetPlayerName(ziel), grund, handelnder.lizenz, handelnder.name })
TriggerClientEvent('aether-admin:client:hinweis', ziel, '⚠️ Verwarnung: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Verwarnung', GetPlayerName(ziel), grund)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'notiz', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local text = tostring(daten.text or ''):sub(1, 1000)
if #text < 2 then return nil, 'Bitte einen Text eingeben.' end
MySQL.insert.await([[
INSERT INTO aether_admin_notes (license, name, notiz, admin_name)
VALUES (?, ?, ?, ?)
]], { Admin.Auth.Lizenz(ziel), GetPlayerName(ziel), text, handelnder.name })
Admin.Log.Schreibe(handelnder, 'Notiz', GetPlayerName(ziel))
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'kick', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or 'Kein Grund angegeben'):sub(1, 255)
local name = GetPlayerName(ziel)
DropPlayer(ziel, 'Vom Team gekickt: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Kick', name, grund)
return { ok = true }
end)
Admin.Kern.Registriere('spieler', 'ban', function(handelnder, daten)
local ziel = tonumber(daten.id)
if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end
local grund = tostring(daten.grund or 'Regelbruch'):sub(1, 255)
local dauer = tostring(daten.dauer or 'permanent')
local name = GetPlayerName(ziel)
local lizenz = Admin.Auth.Lizenz(ziel)
-- Permanente Bans darf nur, wer 'ban' als owner-Recht hat — hier reicht das
-- Aktionsrecht; die Rechte-Matrix regelt, ob überhaupt gebannt werden darf.
local bis = Admin.BerechneBanEnde(dauer)
Admin.DB.Sperre(lizenz, name, grund, bis, handelnder.lizenz, handelnder.name)
DropPlayer(ziel, 'Gebannt: ' .. grund)
Admin.Log.Schreibe(handelnder, 'Ban (' .. dauer .. ')', name, grund)
return { ok = true }
end)
--- Rechnet eine Dauer-Angabe in ein DATETIME um (oder nil = permanent)
--- @param dauer string
--- @return string|nil
function Admin.BerechneBanEnde(dauer)
local tage = ({ ['1 Tag'] = 1, ['3 Tage'] = 3, ['7 Tage'] = 7, ['30 Tage'] = 30 })[dauer]
if not tage then return nil end
return os.date('%Y-%m-%d %H:%M:%S', os.time() + tage * 86400)
end