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

661 lines
25 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)