aether-framework/resources/[aether]/aether-shop/server/main.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

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)