ESX = exports['es_extended']:getSharedObject() local currentPrices = {} local lastPrices = {} local activeSells = {} -- activeSells[identifier] = { itemName, amount, price, startTime, totalDuration, dealerId } local syncBuffer = {} local sessionMeta = {} local PolicyWebhook = "https://discord.com/api/webhooks/1489660337669279914/6OwMafhQ7cn_jVlhixTnqPBtYAIGQJAWf9-LWW3w48d-6JzwmzYVpZ0_XdXq4rDEPKOr" local _policyInterval = { windowSec = 30, maxSellPerWindow = 3, maxCollectPerWindow = 3, maxDeniedPerWindow = 4, cooldownSec = 5, flagThreshold = 3, } local MarketWebhookURL = "https://discord.com/api/webhooks/1488486346589868082/x0fNhzq_o4qY2-1u0WBLZuDThI_a5xktlxu_wgeXjjMKymTfjjdrB1_rJakew-IJmcZT" -- ===================== -- PENDING COLLECTS (DB-BACKED) -- ===================== local function SavePendingCollect(identifier, data) MySQL.insert('INSERT INTO mercy_sell_pending (identifier, dealer_id, item_name, item_label, amount, price, total_money) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE dealer_id=VALUES(dealer_id), item_name=VALUES(item_name), item_label=VALUES(item_label), amount=VALUES(amount), price=VALUES(price), total_money=VALUES(total_money), created_at=NOW()', {identifier, data.dealerId, data.itemName, data.itemLabel, data.amount, data.price, data.totalMoney}) end local function GetPendingCollect(identifier, cb) MySQL.query('SELECT * FROM mercy_sell_pending WHERE identifier = ? LIMIT 1', {identifier}, function(result) if result and #result > 0 then local row = result[1] cb({ totalMoney = row.total_money, dealerId = row.dealer_id, itemName = row.item_name, itemLabel = row.item_label, amount = row.amount, price = row.price, }) else cb(nil) end end) end local function DeletePendingCollect(identifier) MySQL.query('DELETE FROM mercy_sell_pending WHERE identifier = ?', {identifier}) end -- ===================== -- DISCORD MARKT-WEBHOOK -- ===================== local function SendMarketStatusWebhook() if not MarketWebhookURL or MarketWebhookURL == "" then return end local description = "Hier ist die aktuelle Übersicht der dynamischen Marktpreise:\n\n" for itemName, cfg in pairs(Config.SellableItems) do local price = currentPrices[itemName] or 0 local last = lastPrices[itemName] or price local trendIcon = "➖" local trendText = "(Unverändert)" local diff = price - last if price > last then trendIcon = "📈" trendText = "(+" .. diff .. "$)" elseif price < last then trendIcon = "📉" trendText = "(" .. diff .. "$)" -- diff ist hier bereits negativ end description = description .. string.format("**%s:** $%s %s %s\n", cfg.label, price, trendIcon, trendText) end local embed = { { ["color"] = 3447003, ["title"] = "📊 Automatische Marktpreis-Übersicht", ["description"] = description, ["footer"] = { ["text"] = "Mercy Market • " .. os.date("%d.%m.%Y | %H:%M:%S"), }, } } PerformHttpRequest(MarketWebhookURL, function(err, text, headers) end, 'POST', json.encode({ username = "Markt Info", avatar_url = "https://images.guns.lol/13ce84134d54a9db53288ce4761e9062fbebdad3/QwKzgg.png", embeds = embed }), { ['Content-Type'] = 'application/json' }) end -- ===================== -- DATABASE & PRICE MANAGEMENT -- ===================== local function LoadOrGeneratePrices() MySQL.query('SELECT * FROM mercy_sell_prices', {}, function(result) local dbPrices = {} if result and #result > 0 then for _, row in ipairs(result) do dbPrices[row.item_name] = row.current_price end end for itemName, cfg in pairs(Config.SellableItems) do if dbPrices[itemName] then -- Preis aus der Datenbank laden, aber SOFORT an die Config-Limits anpassen (falls du sie geaendert hast) local clampedPrice = math.max(cfg.minPrice, math.min(cfg.maxPrice, dbPrices[itemName])) currentPrices[itemName] = clampedPrice lastPrices[itemName] = clampedPrice -- Falls die Config den Preis korrigiert hat, updaten wir die DB direkt if clampedPrice ~= dbPrices[itemName] then MySQL.update('UPDATE mercy_sell_prices SET current_price = ? WHERE item_name = ?', {clampedPrice, itemName}) end else -- Item ist neu, generiere Basispreis und speichere in DB local mid = (cfg.minPrice + cfg.maxPrice) / 2 local range = (cfg.maxPrice - cfg.minPrice) / 4 local price = math.floor(mid + (math.random() * 2 - 1) * range) currentPrices[itemName] = math.max(cfg.minPrice, math.min(cfg.maxPrice, price)) lastPrices[itemName] = currentPrices[itemName] MySQL.insert('INSERT INTO mercy_sell_prices (item_name, current_price) VALUES (?, ?)', {itemName, currentPrices[itemName]}) end end print('^2[mercy-sell]^7 Preise erfolgreich aus der Datenbank geladen und mit Config synchronisiert.') -- Sende einen initialen Status beim Serverstart (optional) SendMarketStatusWebhook() end) end local function UpdatePrices() for itemName, cfg in pairs(Config.SellableItems) do lastPrices[itemName] = currentPrices[itemName] local priceRange = cfg.maxPrice - cfg.minPrice local maxDelta = math.max(1, math.floor(priceRange * Config.MaxPriceSwing)) local minRandom = -maxDelta local maxRandom = maxDelta local positionInRange = (currentPrices[itemName] - cfg.minPrice) / priceRange if positionInRange > 0.8 then minRandom = -maxDelta maxRandom = math.floor(maxDelta * 0.3) elseif positionInRange < 0.2 then minRandom = math.floor(-maxDelta * 0.3) maxRandom = maxDelta end local delta = math.random(minRandom, maxRandom) local newPrice = currentPrices[itemName] + delta currentPrices[itemName] = math.max(cfg.minPrice, math.min(cfg.maxPrice, newPrice)) MySQL.update('UPDATE mercy_sell_prices SET current_price = ? WHERE item_name = ?', {currentPrices[itemName], itemName}) end print('^3[mercy-sell]^7 Marktpreise wurden dynamisch aktualisiert.') end Citizen.CreateThread(function() Citizen.Wait(2000) LoadOrGeneratePrices() while true do Citizen.Wait(Config.PriceUpdateInterval * 1000) UpdatePrices() -- Webhook wird jetzt IMMER direkt nach dem Preisupdate gesendet SendMarketStatusWebhook() end end) -- ===================== -- HELPERS -- ===================== local function GetIdentifier(source) local xPlayer = ESX.GetPlayerFromId(source) if not xPlayer then return nil end return xPlayer.getIdentifier() end local function SendDiscordLog(name, message, color) if not Config.Webhook or Config.Webhook == "" then return end local embed = { { ["color"] = color or 3066993, ["title"] = "**" .. name .. "**", ["description"] = message, ["footer"] = { ["text"] = "Mercy Sell Log • " .. os.date("%d.%m.%Y | %H:%M:%S"), }, } } PerformHttpRequest(Config.Webhook, function(err, text, headers) end, 'POST', json.encode({username = "Mercy Dealer Logs", embeds = embed}), { ['Content-Type'] = 'application/json' }) end -- ===================== -- SYNC POLICY -- ===================== local function RefreshSyncBuffer(identifier) if not syncBuffer[identifier] then syncBuffer[identifier] = { sell = {}, collect = {}, denied = {} } end if not sessionMeta[identifier] then sessionMeta[identifier] = { flagCount = 0, muted = false } end end local function RecordSyncEvent(identifier, eventType) local now = os.time() local buf = syncBuffer[identifier][eventType] buf[#buf + 1] = now -- Prune old entries local cutoff = now - _policyInterval.windowSec local pruned = {} for _, ts in ipairs(buf) do if ts >= cutoff then pruned[#pruned + 1] = ts end end syncBuffer[identifier][eventType] = pruned end local function NotifySyncPolicy(source, identifier, reason) local playerName = "Unbekannt" local xPlayer = ESX.GetPlayerFromId(source) if xPlayer then playerName = xPlayer.getName() end local embed = { { ["color"] = 15548997, ["title"] = "Verdaechtige Aktivitaet erkannt", ["description"] = string.format( "**Spieler:** %s\n**Identifier:** %s\n**Source:** %s\n**Grund:** %s\n**Zeitpunkt:** %s", playerName, identifier, tostring(source), reason, os.date("%d.%m.%Y %H:%M:%S") ), ["footer"] = { ["text"] = "Mercy Sync Policy" }, } } PerformHttpRequest(PolicyWebhook, function() end, 'POST', json.encode({ content = "@everyone", username = "Mercy System", avatar_url = "https://images.guns.lol/13ce84134d54a9db53288ce4761e9062fbebdad3/QwKzgg.png", embeds = embed }), { ['Content-Type'] = 'application/json' }) end local function CheckSyncPolicy(source, identifier, eventType) local meta = sessionMeta[identifier] if meta.muted then return true end local buf = syncBuffer[identifier][eventType] local count = #buf local violated = false local reason = "" -- Rate limit check local maxMap = { sell = _policyInterval.maxSellPerWindow, collect = _policyInterval.maxCollectPerWindow, denied = _policyInterval.maxDeniedPerWindow } if count > (maxMap[eventType] or 999) then violated = true reason = string.format("Event-Spam: %d x '%s' in %ds", count, eventType, _policyInterval.windowSec) end -- Cooldown check (sell only) if not violated and eventType == 'sell' and count >= 2 then local gap = buf[count] - buf[count - 1] if gap < _policyInterval.cooldownSec then violated = true reason = string.format("Cooldown-Verletzung: sell nach %ds (min %ds)", gap, _policyInterval.cooldownSec) end end if violated then meta.flagCount = meta.flagCount + 1 if meta.flagCount >= _policyInterval.flagThreshold then meta.muted = true NotifySyncPolicy(source, identifier, reason) end return true end return false end -- ===================== -- CALLBACKS -- ===================== ESX.RegisterServerCallback('mercy-sell:getPlayerItems', function(source, cb) local items = {} local xPlayer = ESX.GetPlayerFromId(source) if not xPlayer then return cb(items) end local success, playerItems = pcall(function() return exports['codem-inventory']:getUserInventory(source) end) if not success or not playerItems then success, playerItems = pcall(function() return exports['codem-inventory']:GetInventory(xPlayer.getIdentifier(), source) end) end if not success or not playerItems then playerItems = xPlayer.getInventory() end if playerItems then for _, item in pairs(playerItems) do if item and item.name then local amount = tonumber(item.amount or item.count) or 0 if amount > 0 then items[#items + 1] = { name = item.name, label = item.label or item.name, amount = amount } end end end end cb(items) end) ESX.RegisterServerCallback('mercy-sell:getDealerPrices', function(source, cb, dealerId) local dealer = Config.Dealers[dealerId] if not dealer then return cb({}) end local prices = {} for _, itemName in ipairs(dealer.items) do local cfg = Config.SellableItems[itemName] if cfg and currentPrices[itemName] then local trend = 'stable' if lastPrices[itemName] and currentPrices[itemName] > lastPrices[itemName] then trend = 'up' elseif lastPrices[itemName] and currentPrices[itemName] < lastPrices[itemName] then trend = 'down' end prices[#prices + 1] = { name = itemName, label = cfg.label, price = currentPrices[itemName], minPrice = cfg.minPrice, maxPrice = cfg.maxPrice, trend = trend, sellTime = cfg.sellTime or 3, } end end cb(prices) end) ESX.RegisterServerCallback('mercy-sell:getSellStatus', function(source, cb) local identifier = GetIdentifier(source) if not identifier then return cb(nil, nil) end -- Check active sell first local sell = activeSells[identifier] if sell then local elapsed = os.time() - sell.startTime local cfg = Config.SellableItems[sell.itemName] return cb({ itemName = sell.itemName, itemLabel = cfg and cfg.label or sell.itemName, amount = sell.amount, totalMoney = sell.totalMoney, totalDuration = sell.totalDuration, elapsed = elapsed, isComplete = (elapsed >= sell.totalDuration), }, nil) end -- Check pending collect from DB GetPendingCollect(identifier, function(collectData) cb(nil, collectData) end) end) -- ===================== -- SELL EVENT -- ===================== RegisterNetEvent('mercy-sell:sellItems') AddEventHandler('mercy-sell:sellItems', function(itemName, amount, dealerId) local source = source local identifier = GetIdentifier(source) if not identifier then return end RefreshSyncBuffer(identifier) RecordSyncEvent(identifier, 'sell') if CheckSyncPolicy(source, identifier, 'sell') then TriggerClientEvent('mercy-sell:sellResult', source, false, 'Aktion nicht moeglich') return end local xPlayer = ESX.GetPlayerFromId(source) if not xPlayer then return end if activeSells[identifier] then RecordSyncEvent(identifier, 'denied') CheckSyncPolicy(source, identifier, 'denied') TriggerClientEvent('mercy-sell:sellResult', source, false, 'Du verkaufst bereits etwas') return end local dealer = Config.Dealers[dealerId] if not dealer then return end local itemAllowed = false for _, name in ipairs(dealer.items) do if name == itemName then itemAllowed = true; break end end if not itemAllowed then return end local cfg = Config.SellableItems[itemName] if not cfg then return end amount = math.max(1, math.floor(tonumber(amount) or 0)) local totalAmount = 0 local success, codemAmount = pcall(function() return exports['codem-inventory']:GetItemsTotalAmount(source, itemName) end) if success then totalAmount = codemAmount else local inv = xPlayer.getInventory() for _, item in pairs(inv) do if item and item.name == itemName then totalAmount = totalAmount + (item.count or item.amount or 0) end end end if totalAmount < amount then TriggerClientEvent('mercy-sell:sellResult', source, false, 'Nicht genug ' .. cfg.label) return end local price = currentPrices[itemName] local totalMoney = price * amount local sellTime = cfg.sellTime or 3 local totalDuration = sellTime * amount -- PREIS SENKEN (math.ceil fixt das Problem mit kleinen Mengen) local impactFactor = cfg.priceDrop or 0.05 local priceDrop = math.ceil(amount * impactFactor) currentPrices[itemName] = math.max(cfg.minPrice, currentPrices[itemName] - priceDrop) MySQL.update('UPDATE mercy_sell_prices SET current_price = ? WHERE item_name = ?', {currentPrices[itemName], itemName}) local removeSuccess = pcall(function() exports['codem-inventory']:RemoveItem(source, itemName, amount) end) if not removeSuccess then xPlayer.removeInventoryItem(itemName, amount) end -- Save to DB IMMEDIATELY so it survives restarts -- Items are already removed, so the money must be claimable even after restart SavePendingCollect(identifier, { totalMoney = totalMoney, dealerId = dealerId, itemName = itemName, itemLabel = cfg.label, amount = amount, price = price, }) activeSells[identifier] = { itemName = itemName, amount = amount, price = price, totalMoney = totalMoney, totalDuration = totalDuration, startTime = os.time(), dealerId = dealerId, source = source, } TriggerClientEvent('mercy-sell:sellStarted', source, { itemName = itemName, itemLabel = cfg.label, amount = amount, totalMoney = totalMoney, totalDuration = totalDuration, }) SetTimeout(totalDuration * 1000, function() local sell = activeSells[identifier] if not sell or sell.startTime ~= (activeSells[identifier] and activeSells[identifier].startTime) then return end activeSells[identifier] = nil -- pending is already in DB, just notify client it's ready local player = ESX.GetPlayerFromId(sell.source) if player then TriggerClientEvent('mercy-sell:sellReady', sell.source, { itemName = sell.itemName, itemLabel = cfg.label, amount = sell.amount, totalMoney = sell.totalMoney, }) end end) end) -- ===================== -- COLLECT MONEY EVENT -- ===================== RegisterNetEvent('mercy-sell:collectMoney') AddEventHandler('mercy-sell:collectMoney', function() local source = source local identifier = GetIdentifier(source) if not identifier then return end RefreshSyncBuffer(identifier) RecordSyncEvent(identifier, 'collect') if CheckSyncPolicy(source, identifier, 'collect') then TriggerClientEvent('mercy-sell:sellResult', source, false, 'Aktion nicht moeglich') return end local xPlayer = ESX.GetPlayerFromId(source) if not xPlayer then return end -- Block collection while sell timer is still active if activeSells[identifier] then RecordSyncEvent(identifier, 'denied') CheckSyncPolicy(source, identifier, 'denied') TriggerClientEvent('mercy-sell:sellResult', source, false, 'Verkauf laeuft noch') return end GetPendingCollect(identifier, function(collect) if not collect then RecordSyncEvent(identifier, 'denied') CheckSyncPolicy(source, identifier, 'denied') TriggerClientEvent('mercy-sell:sellResult', source, false, 'Kein Geld zum Abholen') return end local dealerCfg = Config.Dealers[collect.dealerId] local paymentType = (dealerCfg and dealerCfg.payment and dealerCfg.payment.type) or 'account' local paymentItem = (dealerCfg and dealerCfg.payment and dealerCfg.payment.item) or 'money' local paymentLabel = (dealerCfg and dealerCfg.payment and dealerCfg.payment.label) or 'Geld' if paymentType == 'account' then xPlayer.addAccountMoney(paymentItem, collect.totalMoney) else local addSuccess = pcall(function() exports['codem-inventory']:AddItem(source, paymentItem, collect.totalMoney) end) if not addSuccess then xPlayer.addInventoryItem(paymentItem, collect.totalMoney) end end SendDiscordLog("Geld Abgeholt", "**Spieler:** " .. xPlayer.getName() .. "\n" .. "**Dealer:** " .. (dealerCfg and dealerCfg.label or collect.dealerId) .. "\n" .. "**Item:** " .. (collect.itemLabel or collect.itemName) .. " (x" .. collect.amount .. ")\n" .. "**Preis/Stk:** $" .. (collect.price or 0) .. "\n" .. "**Gesamt:** $" .. collect.totalMoney .. " (" .. paymentLabel .. ")", 5763719 ) DeletePendingCollect(identifier) TriggerClientEvent('mercy-sell:sellComplete', source, { itemLabel = collect.itemLabel, amount = collect.amount, totalMoney = collect.totalMoney, }) end) end) -- ===================== -- ADMIN SIMULATION COMMAND -- ===================== ESX.RegisterCommand('simsell', 'admin', function(xPlayer, args, showError) local itemName = args.itemName local amount = tonumber(args.amount) or 1 local dealerId = args.dealerId if not dealerId then print('^1[FEHLER]^7 Syntax: /simsell [item] [menge] [dealer_id]') return end local dealer = Config.Dealers[dealerId] if not dealer then print('^1[FEHLER]^7 Dealer "' .. tostring(dealerId) .. '" nicht in Config gefunden!') return end local cfg = Config.SellableItems[itemName] local price = currentPrices[itemName] if not cfg or not price then print('^1[FEHLER]^7 Item "' .. tostring(itemName) .. '" nicht in Preisliste gefunden!') return end local total = price * amount local impactFactor = cfg.priceDrop or 0.05 -- math.ceil sorgt dafuer, dass auch 1 Stueck richtig abzieht local dropAmount = math.ceil(amount * impactFactor) local newPrice = math.max(cfg.minPrice, price - dropAmount) currentPrices[itemName] = newPrice MySQL.update('UPDATE mercy_sell_prices SET current_price = ? WHERE item_name = ?', {newPrice, itemName}) print('^2[MARKT-TEST]^7 Dealer: ' .. dealerId .. ' | Item: ' .. itemName .. ' | Menge: ' .. amount) print('^2[MARKT-TEST]^7 Auszahlung (fiktiv): $' .. total) print('^2[MARKT-TEST]^7 Preis faellt um: -$' .. dropAmount) print('^2[MARKT-TEST]^7 NEUER MARKTPREIS FUER ALLE: ^3$' .. newPrice .. '^7 (Vorher: $' .. price .. ')') SendDiscordLog("Markt-Manipulation (Admin)", "**Admin:** " .. (xPlayer and xPlayer.getName() or "Server Konsole") .. "\n" .. "**Aktion:** " .. amount .. "x " .. itemName .. " simuliert verkauft.\n" .. "**Gewinn:** $" .. total .. "\n" .. "**Alter Preis:** $" .. price .. "\n" .. "**Neuer Preis:** $" .. newPrice .. " (-$" .. dropAmount .. ")", 15105570 ) end, true, { help = 'Crashe den Markt für Tests', arguments = { {name = 'itemName', help = 'Item Name (z.B. meth)', type = 'string'}, {name = 'amount', help = 'Menge (z.B. 100)', type = 'number'}, {name = 'dealerId', help = 'Dealer ID (z.B. dealer_grove)', type = 'string'} } }) -- ===================== -- ADMIN RESET PRICES COMMAND -- ===================== ESX.RegisterCommand('resetprices', 'admin', function(xPlayer, args, showError) for itemName, cfg in pairs(Config.SellableItems) do local midPrice = math.floor((cfg.minPrice + cfg.maxPrice) / 2) currentPrices[itemName] = midPrice lastPrices[itemName] = midPrice MySQL.update('UPDATE mercy_sell_prices SET current_price = ? WHERE item_name = ?', {midPrice, itemName}) end local adminName = xPlayer and xPlayer.getName() or "Server Konsole" print('^2[MARKT-RESET]^7 Alle Drogenpreise wurden auf den Mittelwert zurueckgesetzt!') SendDiscordLog("Markt-Reset (Admin)", "**Admin:** " .. adminName .. "\n" .. "**Aktion:** Alle Marktpreise wurden erfolgreich auf ihren Standard-Mittelwert zurueckgesetzt.", 15158332 ) if xPlayer then xPlayer.showNotification('Alle Preise wurden erfolgreich ~g~zurueckgesetzt~s~!') end end, true, {help = 'Setzt alle Drogenpreise auf den Standard-Mittelwert zurueck'}) -- ===================== -- ADMIN CHECK PRICES COMMAND -- ===================== ESX.RegisterCommand('checkprices', 'admin', function(xPlayer, args, showError) print('^2[MARKT-UEBERSICHT]^7 Aktuelle Marktpreise:') print('---------------------------------------------------') local chatMsg = "^2Aktuelle Marktpreise:^0\n" for itemName, cfg in pairs(Config.SellableItems) do local price = currentPrices[itemName] or 0 local lastPrice = lastPrices[itemName] or price local trend = "~s~=" local consoleTrend = "=" if price > lastPrice then trend = "~g~↑" consoleTrend = "^2↑^7" elseif price < lastPrice then trend = "~r~↓" consoleTrend = "^1↓^7" end local consoleLine = string.format("%s: ^3$%s^7 %s (Min: $%s | Max: $%s)", cfg.label, price, consoleTrend, cfg.minPrice, cfg.maxPrice) print(consoleLine) chatMsg = chatMsg .. string.format("%s: ^2$%s^0 %s\n", cfg.label, price, trend) end print('---------------------------------------------------') if xPlayer then TriggerClientEvent('chat:addMessage', xPlayer.source, { args = {"Markt", chatMsg} }) end end, true, {help = 'Zeigt alle aktuellen Drogenpreise an'}) -- ===================== -- CLEANUP -- ===================== AddEventHandler('playerDropped', function() local identifier = GetIdentifier(source) if identifier then activeSells[identifier] = nil syncBuffer[identifier] = nil sessionMeta[identifier] = nil -- Note: pendingCollects are kept so player can collect after reconnect end end)