- 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>
361 lines
15 KiB
Lua
361 lines
15 KiB
Lua
-- =====================================================================
|
||
-- 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
|