2026-04-14 15:54:53 +02:00

1014 lines
38 KiB
Lua

ESX = exports['es_extended']:getSharedObject()
-- In-memory caches
local coinCache = {} -- identifier -> coin_count
local stockCache = {} -- item_model -> sold_count
local redeemedCodes = {} -- code -> true (to prevent double-redeem)
local imageCache = {} -- model -> base64 data url
-- =====================
-- HELPERS
-- =====================
local function GetIdentifier(source)
local xPlayer = ESX.GetPlayerFromId(source)
if not xPlayer then return nil end
return xPlayer.getIdentifier()
end
local function GetPlayerName(source)
local xPlayer = ESX.GetPlayerFromId(source)
if not xPlayer then return 'Unbekannt' end
return xPlayer.getName()
end
local function IsAdmin(source)
return IsPlayerAceAllowed(source, Config.Admin.acePermission)
end
local function GetPlayerCoins(identifier)
return coinCache[identifier] or 0
end
local function SetPlayerCoins(identifier, amount)
coinCache[identifier] = amount
MySQL.query('UPDATE mercy_coinshop_coins SET coins = ? WHERE identifier = ?', { amount, identifier })
end
local function AddPlayerCoins(identifier, amount)
local current = GetPlayerCoins(identifier)
local newAmount = current + amount
coinCache[identifier] = newAmount
MySQL.query('INSERT INTO mercy_coinshop_coins (identifier, coins) VALUES (?, ?) ON DUPLICATE KEY UPDATE coins = ?', { identifier, newAmount, newAmount })
end
local function DeductPlayerCoins(identifier, amount)
local current = GetPlayerCoins(identifier)
local newAmount = math.max(0, current - amount)
coinCache[identifier] = newAmount
MySQL.query('UPDATE mercy_coinshop_coins SET coins = ? WHERE identifier = ?', { newAmount, identifier })
return newAmount
end
local function LogTransaction(identifier, playerName, txType, itemName, price)
MySQL.query('INSERT INTO mercy_coinshop_transactions (identifier, player_name, type, item_name, price) VALUES (?, ?, ?, ?, ?)', {
identifier, playerName, txType, itemName, price
})
end
local function GetStockCount(model)
return stockCache[model] or 0
end
local function IncrementStock(model)
local current = GetStockCount(model)
stockCache[model] = current + 1
MySQL.query('INSERT INTO mercy_coinshop_stock (item_model, sold_count) VALUES (?, 1) ON DUPLICATE KEY UPDATE sold_count = sold_count + 1', { model })
end
-- =====================
-- WEBHOOK
-- =====================
local function SendWebhook(title, message, color)
if not Config.WebhookURL or Config.WebhookURL == "" then return end
local embed = {
{
["color"] = color or 16761600,
["title"] = "**" .. title .. "**",
["description"] = message,
["footer"] = { ["text"] = os.date("%d.%m.%Y | %H:%M:%S") },
}
}
PerformHttpRequest(Config.WebhookURL, function() end, 'POST', json.encode({username = "Coinshop Log", embeds = embed}), { ['Content-Type'] = 'application/json' })
end
-- =====================
-- WEEKLY VEHICLE
-- =====================
local function GetCurrentWeeklyVehicles()
if not Config.WeeklyVehicles.enabled or not Config.WeeklyVehicles.pool or #Config.WeeklyVehicles.pool == 0 then
return {}
end
local weekNum = tonumber(os.date('%W')) or 0
local amount = Config.WeeklyVehicles.amountPerWeek or 3
local pool = Config.WeeklyVehicles.pool
local result = {}
for i = 1, math.min(amount, #pool) do
local poolIndex = ((weekNum + i - 1) % #pool) + 1
result[#result + 1] = pool[poolIndex]
end
return result
end
-- Legacy single vehicle (for FindWeeklyVehicle)
local function GetCurrentWeeklyVehicle()
local vehicles = GetCurrentWeeklyVehicles()
if #vehicles > 0 then return vehicles[1] end
return nil
end
local function GetWeeklyPrice(vehicle)
if not vehicle then return 0 end
local discount = Config.WeeklyVehicles.discount or 0
if discount <= 0 then return vehicle.price end
return math.floor(vehicle.price * (1 - discount / 100))
end
-- =====================
-- FIND ITEM IN CONFIG
-- =====================
local function FindVehicleInConfig(model)
for _, cat in ipairs(Config.Categories) do
if cat.type == 'vehicle' and cat.items then
for _, item in ipairs(cat.items) do
if item.model == model then
return item, cat
end
end
end
end
return nil, nil
end
local function FindWeeklyVehicle(model)
local weeklies = GetCurrentWeeklyVehicles()
for _, weekly in ipairs(weeklies) do
if weekly.model == model then
return weekly
end
end
return nil
end
local function FindItemInConfig(categoryId, itemIndex)
for _, cat in ipairs(Config.Categories) do
if cat.id == categoryId and cat.type == 'item' and cat.items then
if cat.items[itemIndex] then
return cat.items[itemIndex], cat
end
end
end
return nil, nil
end
local function FindPackInConfig(categoryId, itemIndex)
for _, cat in ipairs(Config.Categories) do
if cat.id == categoryId and cat.type == 'pack' and cat.items then
if cat.items[itemIndex] then
return cat.items[itemIndex], cat
end
end
end
return nil, nil
end
-- =====================
-- DATABASE SETUP
-- =====================
MySQL.ready(function()
MySQL.query.await([[CREATE TABLE IF NOT EXISTS mercy_coinshop_coins (
identifier VARCHAR(60) NOT NULL PRIMARY KEY,
coins INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)]])
MySQL.query.await([[CREATE TABLE IF NOT EXISTS mercy_coinshop_transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(60) NOT NULL,
player_name VARCHAR(100) NOT NULL,
type VARCHAR(30) NOT NULL,
item_name VARCHAR(100) NOT NULL,
price INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_identifier (identifier),
INDEX idx_created (created_at)
)]])
MySQL.query.await([[CREATE TABLE IF NOT EXISTS mercy_coinshop_stock (
item_model VARCHAR(60) NOT NULL PRIMARY KEY,
sold_count INT NOT NULL DEFAULT 0
)]])
MySQL.query.await([[CREATE TABLE IF NOT EXISTS mercy_coinshop_redeemed (
code VARCHAR(100) NOT NULL PRIMARY KEY,
identifier VARCHAR(60) NOT NULL,
redeemed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)]])
MySQL.query.await([[CREATE TABLE IF NOT EXISTS mercy_coinshop_images (
model VARCHAR(60) NOT NULL PRIMARY KEY,
image LONGTEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)]])
-- Load coin cache
local coinRows = MySQL.query.await('SELECT * FROM mercy_coinshop_coins')
if coinRows then
for _, row in ipairs(coinRows) do
coinCache[row.identifier] = row.coins
end
end
-- Load stock cache
local stockRows = MySQL.query.await('SELECT * FROM mercy_coinshop_stock')
if stockRows then
for _, row in ipairs(stockRows) do
stockCache[row.item_model] = row.sold_count
end
end
-- Load redeemed codes
local redeemRows = MySQL.query.await('SELECT code FROM mercy_coinshop_redeemed')
if redeemRows then
for _, row in ipairs(redeemRows) do
redeemedCodes[row.code] = true
end
end
-- Load image cache
local imageRows = MySQL.query.await('SELECT model, image FROM mercy_coinshop_images')
if imageRows then
for _, row in ipairs(imageRows) do
imageCache[row.model] = row.image
end
end
print('[mercy-coinshop] System gestartet (' .. #(coinRows or {}) .. ' Spieler-Coins, ' .. #(imageRows or {}) .. ' Fahrzeugbilder geladen)')
end)
-- =====================
-- CALLBACKS
-- =====================
ESX.RegisterServerCallback('mercy-coinshop:getShopData', function(source, cb)
local identifier = GetIdentifier(source)
if not identifier then return cb(nil) end
-- Ensure player has a coin entry
if coinCache[identifier] == nil then
coinCache[identifier] = 0
MySQL.query('INSERT IGNORE INTO mercy_coinshop_coins (identifier, coins) VALUES (?, 0)', { identifier })
end
local coins = GetPlayerCoins(identifier)
local admin = IsAdmin(source)
-- No ownership tracking - players can buy same vehicle multiple times
-- Build categories with stock + owned info + tile data
local categories = {}
for _, cat in ipairs(Config.Categories) do
local catData = {
id = cat.id,
label = cat.label,
icon = cat.icon,
type = cat.type,
image = cat.image or '',
color = cat.color or '',
gridSize = cat.gridSize or 'small',
}
if cat.type == 'vehicle' and cat.items then
catData.items = {}
for _, item in ipairs(cat.items) do
local modelHash = tostring(GetHashKey(item.model))
local itemData = {
model = item.model,
label = item.label,
price = item.price,
image = (item.image ~= '' and item.image) or imageCache[item.model] or '',
description = item.description or '',
limited = item.limited or false,
maxStock = item.maxStock or 0,
soldCount = item.limited and GetStockCount(item.model) or 0,
owned = false,
}
catData.items[#catData.items + 1] = itemData
end
elseif cat.type == 'item' and cat.items then
catData.items = {}
for i, item in ipairs(cat.items) do
local itemData = {
index = i,
item = item.item,
label = item.label,
price = item.price,
image = item.image or '',
description = item.description or '',
amount = item.amount or 1,
limited = item.limited or false,
maxStock = item.maxStock or 0,
soldCount = item.limited and GetStockCount(item.item) or 0,
}
catData.items[#catData.items + 1] = itemData
end
elseif cat.type == 'pack' and cat.items then
catData.items = {}
for i, item in ipairs(cat.items) do
local itemData = {
index = i,
label = item.label,
price = item.price,
image = item.image or '',
description = item.description or '',
limited = item.limited or false,
maxStock = item.maxStock or 0,
soldCount = item.limited and GetStockCount('pack_' .. cat.id .. '_' .. i) or 0,
contents = item.contents or {},
}
catData.items[#catData.items + 1] = itemData
end
elseif cat.type == 'plate' then
catData.price = cat.price or 200
catData.maxLength = cat.maxLength or 8
catData.allowedPattern = cat.allowedPattern or '^[A-Z0-9 ]+$'
elseif cat.type == 'weekly' then
local weeklies = GetCurrentWeeklyVehicles()
catData.items = {}
for _, weekly in ipairs(weeklies) do
local modelHash = tostring(GetHashKey(weekly.model))
catData.items[#catData.items + 1] = {
model = weekly.model,
label = weekly.label,
price = weekly.price,
discountedPrice = GetWeeklyPrice(weekly),
discount = Config.WeeklyVehicles.discount or 0,
image = (weekly.image ~= '' and weekly.image) or imageCache[weekly.model] or '',
description = weekly.description or '',
limited = false,
maxStock = 0,
soldCount = 0,
owned = false,
isWeekly = true,
}
end
end
categories[#categories + 1] = catData
end
-- Playtime bonus info
local playtimeBonus = nil
if Config.PlaytimeBonus and Config.PlaytimeBonus.enabled then
playtimeBonus = {
coins = Config.PlaytimeBonus.coins,
intervalMinutes = Config.PlaytimeBonus.intervalMinutes,
}
end
cb({
coins = coins,
categories = categories,
isAdmin = admin,
playtimeBonus = playtimeBonus,
tebexEnabled = Config.Tebex and Config.Tebex.enabled or false,
})
end)
ESX.RegisterServerCallback('mercy-coinshop:getHistory', function(source, cb)
local identifier = GetIdentifier(source)
if not identifier then return cb({}) end
local rows = MySQL.query.await(
'SELECT type, item_name, price, created_at FROM mercy_coinshop_transactions WHERE identifier = ? ORDER BY created_at DESC LIMIT ?',
{ identifier, Config.HistoryLimit or 50 }
)
cb(rows or {})
end)
ESX.RegisterServerCallback('mercy-coinshop:getPlayerVehicles', function(source, cb)
local identifier = GetIdentifier(source)
if not identifier then return cb({}) end
local rows = MySQL.query.await('SELECT plate, vehicle FROM owned_vehicles WHERE owner = ?', { identifier })
local vehicles = {}
if rows then
for _, row in ipairs(rows) do
local label = 'Unbekannt'
local modelHash = nil
if row.vehicle then
local vehData = json.decode(row.vehicle)
if vehData and vehData.model then
modelHash = vehData.model
-- Try to find a readable name from config
local found = false
for _, cat in ipairs(Config.Categories) do
if cat.type == 'vehicle' and cat.items then
for _, item in ipairs(cat.items) do
if GetHashKey(item.model) == vehData.model then
label = item.label
found = true
break
end
end
end
if found then break end
end
-- Also check weekly pool
if not found and Config.WeeklyVehicles and Config.WeeklyVehicles.pool then
for _, item in ipairs(Config.WeeklyVehicles.pool) do
if GetHashKey(item.model) == vehData.model then
label = item.label
break
end
end
end
end
end
vehicles[#vehicles + 1] = {
plate = row.plate,
label = label,
}
end
end
cb(vehicles)
end)
-- =====================
-- TEBEX REDEEM (manual)
-- =====================
ESX.RegisterServerCallback('mercy-coinshop:redeemCode', function(source, cb, code)
local identifier = GetIdentifier(source)
local playerName = GetPlayerName(source)
if not identifier then return cb({ success = false, message = 'Spieler nicht gefunden' }) end
if not Config.Tebex or not Config.Tebex.enabled or not Config.Tebex.apiKey or Config.Tebex.apiKey == '' then
return cb({ success = false, message = 'Tebex ist nicht konfiguriert' })
end
code = tostring(code or ''):gsub("^%s+", ""):gsub("%s+$", "")
if code == '' then
return cb({ success = false, message = 'Bitte Code eingeben' })
end
-- Check if already redeemed locally
if redeemedCodes[code] then
return cb({ success = false, message = Config.Locale.redeemInvalid or 'Code bereits eingeloest!' })
end
-- Look up payment on Tebex
PerformHttpRequest('https://plugin.tebex.io/payments/' .. code, function(statusCode, responseBody, headers)
if statusCode ~= 200 then
return cb({ success = false, message = Config.Locale.redeemInvalid or 'Ungueltiger Code!' })
end
local ok, data = pcall(json.decode, responseBody)
if not ok or not data then
return cb({ success = false, message = Config.Locale.redeemError or 'Fehler beim Einloesen' })
end
-- Check payment status
if data.status ~= 'Complete' then
return cb({ success = false, message = Config.Locale.redeemInvalid or 'Zahlung nicht abgeschlossen!' })
end
-- Parse coin amount from packages
local totalCoins = 0
if data.packages then
for _, pkg in ipairs(data.packages) do
-- Try to find coin amount in package commands
if pkg.commands then
for _, cmd in ipairs(pkg.commands) do
local cmdStr = cmd.command or cmd
if type(cmdStr) == 'string' then
local amount = string.match(cmdStr, 'coinshop_add%s+%S+%s+(%d+)')
if amount then
totalCoins = totalCoins + (tonumber(amount) or 0)
end
end
end
end
end
end
-- Fallback: try amount field
if totalCoins == 0 and data.amount then
totalCoins = tonumber(data.amount) or 0
end
if totalCoins <= 0 then
return cb({ success = false, message = 'Keine Coins in diesem Paket gefunden' })
end
-- Mark as redeemed
redeemedCodes[code] = true
MySQL.query('INSERT IGNORE INTO mercy_coinshop_redeemed (code, identifier) VALUES (?, ?)', { code, identifier })
-- Credit coins
AddPlayerCoins(identifier, totalCoins)
LogTransaction(identifier, playerName, 'tebex_credit', totalCoins .. ' Coins (Tebex Redeem: ' .. code .. ')', 0)
local newCoins = GetPlayerCoins(identifier)
TriggerClientEvent('mercy-coinshop:updateCoins', source, newCoins)
SendWebhook("Tebex Redeem", string.format(
"Spieler: **%s** (%s)\nCode: **%s**\nCoins: **+%d**\nNeuer Stand: **%d**",
playerName, identifier, code, totalCoins, newCoins
), 3066993)
cb({
success = true,
message = string.format(Config.Locale.redeemSuccess or 'Code eingeloest! +%d Coins', totalCoins),
coins = newCoins,
})
end, 'GET', '', {
['X-Tebex-Secret'] = Config.Tebex.apiKey,
})
end)
-- =====================
-- BUY VEHICLE
-- =====================
RegisterNetEvent('mercy-coinshop:buyVehicle')
AddEventHandler('mercy-coinshop:buyVehicle', function(model, isWeekly)
local source = source
local identifier = GetIdentifier(source)
local playerName = GetPlayerName(source)
if not identifier then return end
local admin = IsAdmin(source)
local item, price
if isWeekly then
local weekly = FindWeeklyVehicle(model)
if not weekly then
TriggerClientEvent('mercy-coinshop:notify', source, 'Fahrzeug nicht gefunden', 'error')
return
end
item = weekly
price = GetWeeklyPrice(weekly)
else
local configItem = FindVehicleInConfig(model)
if not configItem then
TriggerClientEvent('mercy-coinshop:notify', source, 'Fahrzeug nicht gefunden', 'error')
return
end
item = configItem
price = configItem.price
end
-- No ownership check - players can buy the same vehicle multiple times
-- Check stock for limited items
if item.limited and item.maxStock and item.maxStock > 0 then
local sold = GetStockCount(model)
if sold >= item.maxStock then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.soldOut or 'Ausverkauft!', 'error')
return
end
end
-- Check coins
if not admin then
local coins = GetPlayerCoins(identifier)
if coins < price then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.notEnoughCoins or 'Nicht genug Coins!', 'error')
return
end
end
-- Generate plate
local plate = string.char(math.random(65,90)) .. string.char(math.random(65,90)) .. string.char(math.random(65,90)) .. ' ' .. tostring(math.random(100, 999))
-- Insert vehicle
local vehicleData = json.encode({ model = tonumber(GetHashKey(model)), plate = plate })
MySQL.query('INSERT INTO owned_vehicles (owner, plate, vehicle, type, stored, parking) VALUES (?, ?, ?, ?, 1, ?)', { identifier, plate, vehicleData, 'car', Config.DefaultGarage or 'Garage A' })
-- Deduct coins
local newCoins
if admin then
newCoins = GetPlayerCoins(identifier)
else
newCoins = DeductPlayerCoins(identifier, price)
end
-- Increment stock
if item.limited then
IncrementStock(model)
end
-- Log
LogTransaction(identifier, playerName, 'vehicle', item.label, admin and 0 or price)
-- Notify
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.purchaseSuccess or 'Erfolgreich gekauft!', 'success')
TriggerClientEvent('mercy-coinshop:updateCoins', source, newCoins)
TriggerClientEvent('mercy-coinshop:purchaseSuccess', source, model)
SendWebhook("Fahrzeug Gekauft", string.format(
"Spieler: **%s** (%s)\nFahrzeug: **%s**\nPreis: **%d Coins**%s\nKennzeichen: **%s**",
playerName, identifier, item.label, admin and 0 or price,
admin and ' (Admin-Bypass)' or '', plate
), admin and 10181046 or 3066993)
end)
-- =====================
-- BUY ITEM (Inventar)
-- =====================
RegisterNetEvent('mercy-coinshop:buyItem')
AddEventHandler('mercy-coinshop:buyItem', function(categoryId, itemIndex)
local source = source
local identifier = GetIdentifier(source)
local playerName = GetPlayerName(source)
if not identifier then return end
local admin = IsAdmin(source)
local xPlayer = ESX.GetPlayerFromId(source)
if not xPlayer then return end
local item, cat = FindItemInConfig(categoryId, itemIndex)
if not item then
TriggerClientEvent('mercy-coinshop:notify', source, 'Item nicht gefunden', 'error')
return
end
local price = item.price or 0
-- Check stock
if item.limited and item.maxStock and item.maxStock > 0 then
local sold = GetStockCount(item.item)
if sold >= item.maxStock then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.soldOut or 'Ausverkauft!', 'error')
return
end
end
-- Check coins
if not admin then
local coins = GetPlayerCoins(identifier)
if coins < price then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.notEnoughCoins or 'Nicht genug Coins!', 'error')
return
end
end
-- Give item
local amount = item.amount or 1
xPlayer.addInventoryItem(item.item, amount)
-- Deduct coins
local newCoins
if admin then
newCoins = GetPlayerCoins(identifier)
else
newCoins = DeductPlayerCoins(identifier, price)
end
-- Increment stock
if item.limited then
IncrementStock(item.item)
end
-- Log
LogTransaction(identifier, playerName, 'item', item.label .. ' x' .. amount, admin and 0 or price)
-- Notify
TriggerClientEvent('mercy-coinshop:notify', source, string.format(Config.Locale.itemReceived or '%dx %s erhalten!', amount, item.label), 'success')
TriggerClientEvent('mercy-coinshop:updateCoins', source, newCoins)
TriggerClientEvent('mercy-coinshop:purchaseSuccess', source, item.item)
SendWebhook("Item Gekauft", string.format(
"Spieler: **%s** (%s)\nItem: **%s** x%d\nPreis: **%d Coins**%s",
playerName, identifier, item.label, amount, admin and 0 or price,
admin and ' (Admin-Bypass)' or ''
), admin and 10181046 or 5763719)
end)
-- =====================
-- BUY PACK (Bundle)
-- =====================
RegisterNetEvent('mercy-coinshop:buyPack')
AddEventHandler('mercy-coinshop:buyPack', function(categoryId, itemIndex)
local source = source
local identifier = GetIdentifier(source)
local playerName = GetPlayerName(source)
if not identifier then return end
local admin = IsAdmin(source)
local xPlayer = ESX.GetPlayerFromId(source)
if not xPlayer then return end
local pack, cat = FindPackInConfig(categoryId, itemIndex)
if not pack then
TriggerClientEvent('mercy-coinshop:notify', source, 'Pack nicht gefunden', 'error')
return
end
local price = pack.price or 0
local stockKey = 'pack_' .. categoryId .. '_' .. itemIndex
-- Check stock
if pack.limited and pack.maxStock and pack.maxStock > 0 then
local sold = GetStockCount(stockKey)
if sold >= pack.maxStock then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.soldOut or 'Ausverkauft!', 'error')
return
end
end
-- Check coins
if not admin then
local coins = GetPlayerCoins(identifier)
if coins < price then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.notEnoughCoins or 'Nicht genug Coins!', 'error')
return
end
end
-- Give all contents
if pack.contents then
for _, content in ipairs(pack.contents) do
if content.type == 'vehicle' and content.model then
local plate = string.char(math.random(65,90)) .. string.char(math.random(65,90)) .. string.char(math.random(65,90)) .. ' ' .. tostring(math.random(100, 999))
local vehicleData = json.encode({ model = tonumber(GetHashKey(content.model)), plate = plate })
MySQL.query('INSERT INTO owned_vehicles (owner, plate, vehicle, type, stored, parking) VALUES (?, ?, ?, ?, 1, ?)', { identifier, plate, vehicleData, 'car', Config.DefaultGarage or 'Garage A' })
elseif content.type == 'item' and content.item then
local amount = content.amount or 1
xPlayer.addInventoryItem(content.item, amount)
end
end
end
-- Deduct coins
local newCoins
if admin then
newCoins = GetPlayerCoins(identifier)
else
newCoins = DeductPlayerCoins(identifier, price)
end
-- Increment stock
if pack.limited then
IncrementStock(stockKey)
end
-- Log
LogTransaction(identifier, playerName, 'pack', pack.label, admin and 0 or price)
-- Notify
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.purchaseSuccess or 'Erfolgreich gekauft!', 'success')
TriggerClientEvent('mercy-coinshop:updateCoins', source, newCoins)
SendWebhook("Pack Gekauft", string.format(
"Spieler: **%s** (%s)\nPack: **%s**\nPreis: **%d Coins**%s",
playerName, identifier, pack.label, admin and 0 or price,
admin and ' (Admin-Bypass)' or ''
), admin and 10181046 or 15844367)
end)
-- =====================
-- BUY PLATE
-- =====================
RegisterNetEvent('mercy-coinshop:buyPlate')
AddEventHandler('mercy-coinshop:buyPlate', function(vehiclePlate, newPlate)
local source = source
local identifier = GetIdentifier(source)
local playerName = GetPlayerName(source)
if not identifier then return end
local admin = IsAdmin(source)
-- Find plate config
local plateConfig = nil
for _, cat in ipairs(Config.Categories) do
if cat.type == 'plate' then
plateConfig = cat
break
end
end
if not plateConfig then
TriggerClientEvent('mercy-coinshop:notify', source, 'Kennzeichen-Kauf nicht verfuegbar', 'error')
return
end
local price = plateConfig.price or 200
local maxLength = plateConfig.maxLength or 8
local allowedPattern = plateConfig.allowedPattern or '^[A-Z0-9 ]+$'
newPlate = string.upper(tostring(newPlate or ''))
newPlate = newPlate:gsub("^%s+", ""):gsub("%s+$", "")
if newPlate == '' then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.plateEmpty or 'Bitte Kennzeichen eingeben!', 'error')
return
end
if #newPlate > maxLength then
TriggerClientEvent('mercy-coinshop:notify', source, string.format(Config.Locale.plateTooLong or 'Kennzeichen zu lang (max %d Zeichen)!', maxLength), 'error')
return
end
if not string.match(newPlate, allowedPattern) then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.plateInvalid or 'Ungueltige Zeichen im Kennzeichen!', 'error')
return
end
local vehCheck = MySQL.query.await('SELECT 1 FROM owned_vehicles WHERE owner = ? AND plate = ?', { identifier, vehiclePlate })
if not vehCheck or #vehCheck == 0 then
TriggerClientEvent('mercy-coinshop:notify', source, 'Fahrzeug nicht gefunden', 'error')
return
end
local plateCheck = MySQL.query.await('SELECT 1 FROM owned_vehicles WHERE plate = ?', { newPlate })
if plateCheck and #plateCheck > 0 then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.plateInUse or 'Kennzeichen bereits vergeben!', 'error')
return
end
if not admin then
local coins = GetPlayerCoins(identifier)
if coins < price then
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.notEnoughCoins or 'Nicht genug Coins!', 'error')
return
end
end
-- Update plate column AND plate inside vehicle JSON
MySQL.query('UPDATE owned_vehicles SET plate = ?, vehicle = JSON_SET(vehicle, "$.plate", ?) WHERE owner = ? AND plate = ?', { newPlate, newPlate, identifier, vehiclePlate })
local newCoins
if admin then
newCoins = GetPlayerCoins(identifier)
else
newCoins = DeductPlayerCoins(identifier, price)
end
LogTransaction(identifier, playerName, 'plate', vehiclePlate .. ' -> ' .. newPlate, admin and 0 or price)
TriggerClientEvent('mercy-coinshop:notify', source, Config.Locale.purchaseSuccess or 'Erfolgreich gekauft!', 'success')
TriggerClientEvent('mercy-coinshop:updateCoins', source, newCoins)
SendWebhook("Kennzeichen Gekauft", string.format(
"Spieler: **%s** (%s)\nAltes Kennzeichen: **%s**\nNeues Kennzeichen: **%s**\nPreis: **%d Coins**%s",
playerName, identifier, vehiclePlate, newPlate, admin and 0 or price,
admin and ' (Admin-Bypass)' or ''
), admin and 10181046 or 3447003)
end)
-- =====================
-- PLAYTIME BONUS
-- =====================
if Config.PlaytimeBonus and Config.PlaytimeBonus.enabled then
Citizen.CreateThread(function()
local intervalMs = (Config.PlaytimeBonus.intervalMinutes or 15) * 60 * 1000
local bonusCoins = Config.PlaytimeBonus.coins or 10
Citizen.Wait(10000) -- Initial wait for server startup
print('[mercy-coinshop] Playtime Bonus gestartet (' .. bonusCoins .. ' Coins alle ' .. Config.PlaytimeBonus.intervalMinutes .. ' Min)')
while true do
Citizen.Wait(intervalMs)
-- Give coins to all online players
for _, xPlayer in pairs(ESX.GetExtendedPlayers()) do
local identifier = xPlayer.getIdentifier()
if identifier then
AddPlayerCoins(identifier, bonusCoins)
local newCoins = GetPlayerCoins(identifier)
TriggerClientEvent('mercy-coinshop:updateCoins', xPlayer.source, newCoins)
TriggerClientEvent('mercy-coinshop:notify', xPlayer.source, '+' .. bonusCoins .. ' Coins (Spielzeit-Bonus)', 'success')
TriggerClientEvent('mercy-coinshop:playtimeReset', xPlayer.source)
end
end
end
end)
end
-- =====================
-- TEBEX POLLING (background, for auto-credit)
-- =====================
Citizen.CreateThread(function()
if not Config.Tebex or not Config.Tebex.apiKey or Config.Tebex.apiKey == '' then
print('[mercy-coinshop] Tebex API Key nicht gesetzt - Polling deaktiviert')
return
end
Citizen.Wait(10000)
print('[mercy-coinshop] Tebex Polling gestartet')
while true do
PerformHttpRequest('https://plugin.tebex.io/queue', function(statusCode, responseBody, headers)
if statusCode ~= 200 then return end
local ok, data = pcall(json.decode, responseBody)
if not ok or not data then return end
local commands = data.commands or {}
if #commands == 0 then return end
local processedIds = {}
for _, cmd in ipairs(commands) do
local playerName = cmd.player and cmd.player.name or 'Unbekannt'
local command = cmd.command or ''
local targetIdentifier, coinAmount = string.match(command, 'coinshop_add%s+(%S+)%s+(%d+)')
if targetIdentifier and coinAmount then
coinAmount = tonumber(coinAmount) or 0
if coinAmount > 0 then
AddPlayerCoins(targetIdentifier, coinAmount)
LogTransaction(targetIdentifier, playerName, 'tebex_credit', coinAmount .. ' Coins (Tebex)', 0)
for _, xPlayer in pairs(ESX.GetExtendedPlayers()) do
if xPlayer.getIdentifier() == targetIdentifier then
TriggerClientEvent('mercy-coinshop:notify', xPlayer.source, coinAmount .. ' Coins gutgeschrieben!', 'success')
TriggerClientEvent('mercy-coinshop:updateCoins', xPlayer.source, GetPlayerCoins(targetIdentifier))
break
end
end
SendWebhook("Tebex Coins", string.format(
"Spieler: **%s** (%s)\nCoins: **+%d**\nNeuer Stand: **%d**",
playerName, targetIdentifier, coinAmount, GetPlayerCoins(targetIdentifier)
), 3066993)
end
end
processedIds[#processedIds + 1] = cmd.id
end
if #processedIds > 0 then
PerformHttpRequest('https://plugin.tebex.io/queue', function(delStatus)
if delStatus == 204 then
print('[mercy-coinshop] ' .. #processedIds .. ' Tebex Commands verarbeitet')
end
end, 'DELETE', json.encode({ ids = processedIds }), {
['X-Tebex-Secret'] = Config.Tebex.apiKey,
['Content-Type'] = 'application/json',
})
end
end, 'GET', '', {
['X-Tebex-Secret'] = Config.Tebex.apiKey,
})
Citizen.Wait(5 * 60 * 1000) -- Poll every 5 minutes
end
end)
-- =====================
-- ADMIN COMMAND
-- =====================
RegisterCommand(Config.Admin.commandName, function(source, args)
if source > 0 and not IsAdmin(source) then
TriggerClientEvent('mercy-coinshop:notify', source, 'Keine Berechtigung!', 'error')
return
end
local action = args[1]
local targetId = tonumber(args[2])
local amount = tonumber(args[3])
if not action or not targetId or not amount or amount <= 0 then
if source == 0 then
print('Verwendung: ' .. Config.Admin.commandName .. ' add|remove <server_id> <amount>')
else
TriggerClientEvent('mercy-coinshop:notify', source, 'Verwendung: /' .. Config.Admin.commandName .. ' add|remove <id> <amount>', 'error')
end
return
end
local targetIdentifier = GetIdentifier(targetId)
local targetName = GetPlayerName(targetId)
if not targetIdentifier then
local msg = 'Spieler nicht gefunden (ID: ' .. targetId .. ')'
if source == 0 then print(msg) else TriggerClientEvent('mercy-coinshop:notify', source, msg, 'error') end
return
end
if action == 'add' then
AddPlayerCoins(targetIdentifier, amount)
LogTransaction(targetIdentifier, targetName, 'admin_add', amount .. ' Coins (Admin)', 0)
TriggerClientEvent('mercy-coinshop:updateCoins', targetId, GetPlayerCoins(targetIdentifier))
TriggerClientEvent('mercy-coinshop:notify', targetId, amount .. ' Coins erhalten!', 'success')
local msg = amount .. ' Coins an ' .. targetName .. ' vergeben'
if source == 0 then print(msg) else TriggerClientEvent('mercy-coinshop:notify', source, msg, 'success') end
SendWebhook("Admin: Coins Vergeben", string.format(
"Admin: **%s**\nSpieler: **%s** (%s)\nCoins: **+%d**\nNeuer Stand: **%d**",
source == 0 and 'Console' or GetPlayerName(source), targetName, targetIdentifier, amount, GetPlayerCoins(targetIdentifier)
), 10181046)
elseif action == 'remove' then
DeductPlayerCoins(targetIdentifier, amount)
LogTransaction(targetIdentifier, targetName, 'admin_remove', amount .. ' Coins (Admin)', 0)
TriggerClientEvent('mercy-coinshop:updateCoins', targetId, GetPlayerCoins(targetIdentifier))
TriggerClientEvent('mercy-coinshop:notify', targetId, amount .. ' Coins abgezogen!', 'error')
local msg = amount .. ' Coins von ' .. targetName .. ' abgezogen'
if source == 0 then print(msg) else TriggerClientEvent('mercy-coinshop:notify', source, msg, 'success') end
SendWebhook("Admin: Coins Entfernt", string.format(
"Admin: **%s**\nSpieler: **%s** (%s)\nCoins: **-%d**\nNeuer Stand: **%d**",
source == 0 and 'Console' or GetPlayerName(source), targetName, targetIdentifier, amount, GetPlayerCoins(targetIdentifier)
), 15158332)
else
local msg = 'Unbekannte Aktion: ' .. action .. ' (add|remove)'
if source == 0 then print(msg) else TriggerClientEvent('mercy-coinshop:notify', source, msg, 'error') end
end
end, true)