- 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>
263 lines
9.4 KiB
Lua
263 lines
9.4 KiB
Lua
-- =====================================================================
|
|
-- Aether Shop — Serverlogik
|
|
-- Der Client schickt nur den Warenkorb. Preise, Bestand, Lizenz,
|
|
-- Deckung und Traglast werden hier komplett neu geprueft.
|
|
-- =====================================================================
|
|
|
|
local Core = exports['aether-core']
|
|
local Inventory = exports['aether-inventory']
|
|
|
|
-- Bestaende der Laeden im Speicher: [ladenId][produktId] = Menge
|
|
local bestaende = {}
|
|
|
|
-- Merkt sich, an welchem Laden ein Spieler gerade steht
|
|
local offeneLaeden = {}
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Bestandsverwaltung
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Setzt alle Bestaende auf die Ausgangswerte des Katalogs
|
|
local function BestaendeAufbauen()
|
|
for ladenId, laden in pairs(Shop.Laeden) do
|
|
bestaende[ladenId] = {}
|
|
for _, produktId in ipairs(laden.produkte) do
|
|
local produkt = Shop.Produkte[produktId]
|
|
bestaende[ladenId][produktId] = produkt and produkt.bestand or 0
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Fuellt die Bestaende langsam wieder auf (alle 15 Minuten um 10 %)
|
|
CreateThread(function()
|
|
BestaendeAufbauen()
|
|
while true do
|
|
Wait(15 * 60 * 1000)
|
|
for ladenId, laden in pairs(Shop.Laeden) do
|
|
for _, produktId in ipairs(laden.produkte) do
|
|
local produkt = Shop.Produkte[produktId]
|
|
if produkt then
|
|
local maximum = produkt.bestand or 0
|
|
local aktuell = bestaende[ladenId][produktId] or 0
|
|
if aktuell < maximum then
|
|
local nachschub = math.max(1, math.floor(maximum * 0.1))
|
|
bestaende[ladenId][produktId] = math.min(maximum, aktuell + nachschub)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Hilfsfunktionen
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Baut den Katalog eines Ladens fuer die Anzeige (mit aktuellem Bestand)
|
|
--- @param ladenId string Kennung des Ladens
|
|
--- @return table|nil
|
|
local function BaueKatalog(ladenId)
|
|
local laden = Shop.Laeden[ladenId]
|
|
if not laden then return nil end
|
|
|
|
local produkte = {}
|
|
for _, produktId in ipairs(laden.produkte) do
|
|
local produkt = Shop.Produkte[produktId]
|
|
if produkt then
|
|
produkte[produktId] = {
|
|
name = produkt.name, kat = produkt.kat, icon = produkt.icon,
|
|
preis = produkt.preis, rabatt = produkt.rabatt, lizenz = produkt.lizenz,
|
|
beschreibung = produkt.beschreibung, menge = produkt.menge,
|
|
bestand = bestaende[ladenId] and bestaende[ladenId][produktId] or 0,
|
|
}
|
|
end
|
|
end
|
|
|
|
return {
|
|
id = ladenId, name = laden.name, logo = laden.logo, tl = laden.tl,
|
|
accent = laden.accent, ink = laden.ink, warnung = laden.warnung,
|
|
produkte = produkte,
|
|
reihenfolge = laden.produkte,
|
|
}
|
|
end
|
|
|
|
--- Prueft, ob ein Spieler einen gueltigen Waffenschein besitzt.
|
|
--- Sobald `aether-documents` migriert ist, greift die echte Pruefung.
|
|
--- @param quelle number Server-ID
|
|
--- @return boolean
|
|
local function HatWaffenschein(quelle)
|
|
if not Shop.LizenzPflicht then return true end
|
|
|
|
-- Optionale Abfrage beim Dokumente-Modul
|
|
local erfolg, ergebnis = pcall(function()
|
|
return exports['aether-documents']:HatWaffenschein(quelle)
|
|
end)
|
|
if erfolg and type(ergebnis) == 'boolean' then return ergebnis end
|
|
|
|
-- Modul noch nicht vorhanden: Rueckfallregel aus der Konfiguration
|
|
return Shop.OhneDokumenteErlauben
|
|
end
|
|
|
|
--- Lehnt eine Aktion mit Begruendung ab
|
|
local function Ablehnen(quelle, grund)
|
|
TriggerClientEvent('aether:shop:fehler', quelle, grund)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Laden oeffnen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:shop:oeffnen', function(ladenId)
|
|
local quelle = source
|
|
if not Core:HoleCharakterId(quelle) then return end
|
|
if type(ladenId) ~= 'string' then return end
|
|
|
|
local laden = Shop.Laeden[ladenId]
|
|
if not laden then return end
|
|
|
|
-- Entfernungspruefung: Der Spieler muss wirklich an einer Filiale stehen
|
|
local position = GetEntityCoords(GetPlayerPed(quelle))
|
|
local inReichweite = false
|
|
for _, standort in ipairs(laden.standorte) do
|
|
if #(position - standort) <= (Shop.INTERAKTION_WEITE + 2.0) then
|
|
inReichweite = true
|
|
break
|
|
end
|
|
end
|
|
if not inReichweite then
|
|
Ablehnen(quelle, 'Du bist nicht an diesem Laden.')
|
|
return
|
|
end
|
|
|
|
offeneLaeden[quelle] = ladenId
|
|
|
|
local daten = Core:HoleSpielerInfo(quelle)
|
|
TriggerClientEvent('aether:shop:zeige', quelle, BaueKatalog(ladenId), {
|
|
bargeld = daten and daten.bargeld or 0,
|
|
konto = daten and daten.konto or 0,
|
|
})
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Kaufen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:shop:kaufen', function(warenkorb, zahlung)
|
|
local quelle = source
|
|
local charakterId = Core:HoleCharakterId(quelle)
|
|
if not charakterId then return end
|
|
|
|
local ladenId = offeneLaeden[quelle]
|
|
if not ladenId then Ablehnen(quelle, 'Kein Laden geöffnet.') return end
|
|
if type(warenkorb) ~= 'table' then return end
|
|
|
|
zahlung = (zahlung == 'karte') and 'konto' or 'bargeld'
|
|
|
|
-- ---- Warenkorb vollstaendig neu durchrechnen ----
|
|
local gesamt = 0
|
|
local posten = {}
|
|
|
|
for produktId, menge in pairs(warenkorb) do
|
|
if type(produktId) ~= 'string' or type(menge) ~= 'number' then return end
|
|
menge = math.floor(menge)
|
|
|
|
if menge < 1 or menge > Shop.MAX_MENGE then
|
|
Ablehnen(quelle, 'Ungültige Menge.')
|
|
return
|
|
end
|
|
|
|
-- Fuehrt dieser Laden das Produkt ueberhaupt?
|
|
if not Shop.FuehrtProdukt(ladenId, produktId) then
|
|
Ablehnen(quelle, 'Dieses Produkt gibt es hier nicht.')
|
|
return
|
|
end
|
|
|
|
local produkt = Shop.Produkte[produktId]
|
|
if not produkt then Ablehnen(quelle, 'Unbekanntes Produkt.') return end
|
|
|
|
-- Bestand pruefen
|
|
local vorrat = bestaende[ladenId][produktId] or 0
|
|
if menge > vorrat then
|
|
Ablehnen(quelle, ('%s ist nicht so oft vorrätig.'):format(produkt.name))
|
|
return
|
|
end
|
|
|
|
-- Waffenschein pruefen
|
|
if produkt.lizenz and not HatWaffenschein(quelle) then
|
|
Ablehnen(quelle, ('Für %s brauchst du einen gültigen Waffenschein.'):format(produkt.name))
|
|
return
|
|
end
|
|
|
|
-- Preis kommt IMMER vom Server, nie aus dem Warenkorb des Clients
|
|
local einzelpreis = Shop.Preis(produkt)
|
|
gesamt = gesamt + einzelpreis * menge
|
|
|
|
posten[#posten + 1] = {
|
|
produktId = produktId,
|
|
name = produkt.name,
|
|
menge = menge,
|
|
summe = einzelpreis * menge,
|
|
item = produkt.item,
|
|
stueck = (produkt.menge or 1) * menge, -- tatsaechliche Stueckzahl im Inventar
|
|
}
|
|
end
|
|
|
|
if #posten == 0 then Ablehnen(quelle, 'Der Warenkorb ist leer.') return end
|
|
|
|
-- ---- Passt alles ins Inventar? ----
|
|
for _, eintrag in ipairs(posten) do
|
|
if not Inventory:PasstNoch(quelle, eintrag.item, eintrag.stueck) then
|
|
Ablehnen(quelle, 'Du kannst nicht so viel tragen.')
|
|
return
|
|
end
|
|
end
|
|
|
|
-- ---- Bezahlen (prueft die Deckung) ----
|
|
if not Core:EntferneGeld(quelle, zahlung, gesamt, 'Einkauf ' .. Shop.Laeden[ladenId].name) then
|
|
Ablehnen(quelle, zahlung == 'bargeld' and 'Nicht genug Bargeld.' or 'Konto nicht gedeckt.')
|
|
return
|
|
end
|
|
|
|
-- ---- Waren aushaendigen ----
|
|
local fehlgeschlagen = {}
|
|
for _, eintrag in ipairs(posten) do
|
|
if Inventory:GebeItem(quelle, eintrag.item, eintrag.stueck) then
|
|
bestaende[ladenId][eintrag.produktId] =
|
|
(bestaende[ladenId][eintrag.produktId] or 0) - eintrag.menge
|
|
else
|
|
fehlgeschlagen[#fehlgeschlagen + 1] = eintrag
|
|
end
|
|
end
|
|
|
|
-- Konnte etwas nicht uebergeben werden, den Anteil zurueckerstatten
|
|
if #fehlgeschlagen > 0 then
|
|
local erstattung = 0
|
|
for _, eintrag in ipairs(fehlgeschlagen) do erstattung = erstattung + eintrag.summe end
|
|
Core:FuegeGeldHinzu(quelle, zahlung, erstattung, 'Erstattung Einkauf')
|
|
Ablehnen(quelle, ('%d Artikel passten nicht — %d € wurden erstattet.')
|
|
:format(#fehlgeschlagen, erstattung))
|
|
end
|
|
|
|
Aether.Info(('Einkauf: Charakter #%d bei %s für %d €')
|
|
:format(charakterId, Shop.Laeden[ladenId].name, gesamt))
|
|
|
|
-- Beleg und aktualisierten Katalog zurueckschicken
|
|
local daten = Core:HoleSpielerInfo(quelle)
|
|
TriggerClientEvent('aether:shop:gekauft', quelle, {
|
|
posten = posten,
|
|
gesamt = gesamt,
|
|
zahlung = zahlung,
|
|
katalog = BaueKatalog(ladenId),
|
|
geld = { bargeld = daten and daten.bargeld or 0, konto = daten and daten.konto or 0 },
|
|
})
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Aufraeumen
|
|
-- ---------------------------------------------------------------------
|
|
RegisterNetEvent('aether:shop:schliessen', function()
|
|
offeneLaeden[source] = nil
|
|
end)
|
|
|
|
AddEventHandler('playerDropped', function()
|
|
offeneLaeden[source] = nil
|
|
end)
|