Turfwar/server/shop.lua

449 lines
14 KiB
Lua

-- server/shop.lua
print("^2[turfwar]^7 server/shop.lua loaded (shop purchases + police support + DB gang bank + anti-doublefire + oxmysql-safe)")
Config = Config or {}
Config.Shops = Config.Shops or {}
local POLICE_GANG_ID = 3
-- DB table
local GANG_TABLE = "turfwar_gang_accounts"
local COL_GANG_ID = "gang_id"
local COL_BALANCE = "balance"
-- Debug toggle: set Config.Shops.Debug = true
local function D(msg)
if Config and Config.Shops and Config.Shops.Debug then
print("^3[turfwar:shop]^7 " .. msg)
end
end
-- =========================================================
-- Gang tracking
-- =========================================================
local playerGang = {} -- [src] = gangId
-- =========================================================
-- Time helper (must be defined before used)
-- =========================================================
local function nowMs()
return GetGameTimer()
end
-- =========================================================
-- Gang sync handshake state (must be defined before used)
-- =========================================================
local freshSync = {} -- [src] = lastGameTimerMs
local pendingBuy = {} -- [src] = { nonce, kind, itemId }
local GANG_SYNC_TTL_MS = 2000
local function SetGangFor(src, gangId)
playerGang[src] = tonumber(gangId) or 0
end
local function GetPlayerGangId(src)
return tonumber(playerGang[src]) or 0
end
RegisterNetEvent("turfwar:setFaction", function(gangId)
local src = source
SetGangFor(src, gangId)
freshSync[src] = nowMs()
end)
RegisterNetEvent("turfwar:gangUpdate", function(gangId)
local src = source
SetGangFor(src, gangId)
freshSync[src] = nowMs()
end)
RegisterNetEvent("turfwar:setMyGang", function(gangId)
local src = source
SetGangFor(src, gangId)
freshSync[src] = nowMs()
end)
-- =========================================================
-- Gang sync handshake
-- =========================================================
local freshSync = {} -- [src] = lastGameTimerMs
local pendingBuy = {} -- [src] = { nonce, kind, itemId }
local GANG_SYNC_TTL_MS = 2000
local function nowMs()
return GetGameTimer()
end
local function isGangSyncFresh(src)
local t = freshSync[src]
return t and (nowMs() - t) <= GANG_SYNC_TTL_MS
end
local function requestGangSync(src, nonce)
TriggerClientEvent("turfwar:shop:syncGangRequest", src, nonce)
end
-- =========================================================
-- Anti-doublefire / anti-overlap
-- =========================================================
local inFlight = {} -- [src] = true
local seen = {} -- seen[src][key] = expireMs
local SEEN_TTL_MS = 800
local function seenKey(src, kind, itemId)
local bucket = math.floor(nowMs() / 250)
return ("%d|%s|%s|%d"):format(src, kind, tostring(itemId or ""), bucket)
end
local function isSeen(src, key)
local t = seen[src]
if not t then return false end
local exp = t[key]
if not exp then return false end
if exp < nowMs() then
t[key] = nil
return false
end
return true
end
local function markSeen(src, key)
seen[src] = seen[src] or {}
seen[src][key] = nowMs() + SEEN_TTL_MS
end
local function clearSrcState(src)
playerGang[src] = nil
freshSync[src] = nil
pendingBuy[src] = nil
inFlight[src] = nil
seen[src] = nil
end
AddEventHandler("playerDropped", function()
clearSrcState(source)
end)
-- =========================================================
-- Shop tables (police vs gangs)
-- =========================================================
local function GetShopTablesForGang(gangId)
local s = Config.Shops or {}
if gangId == POLICE_GANG_ID then
return s.PoliceWeapons or {}, s.PoliceAmmo or {}, s.PoliceVehicles or {}, true
end
return s.Weapons or {}, s.Ammo or {}, s.Vehicles or {}, false
end
-- =========================================================
-- DB Adapter (oxmysql or mysql-async) - FIXED normalize affectedRows
-- =========================================================
local function have_oxmysql()
return GetResourceState("oxmysql") == "started" and exports.oxmysql ~= nil
end
local function have_mysql_async()
return MySQL and MySQL.Async and MySQL.Async.fetchScalar ~= nil
end
local function normalizeAffected(result)
-- oxmysql sometimes returns a table, mysql-async usually returns number
if type(result) == "number" then
return result
end
if type(result) == "table" then
-- common oxmysql shapes:
-- { affectedRows = 1, changedRows = 1, insertId = 0 }
if result.affectedRows ~= nil then return tonumber(result.affectedRows) or 0 end
if result.changedRows ~= nil then return tonumber(result.changedRows) or 0 end
-- sometimes wrapped in array:
if result[1] and type(result[1]) == "table" then
if result[1].affectedRows ~= nil then return tonumber(result[1].affectedRows) or 0 end
if result[1].changedRows ~= nil then return tonumber(result[1].changedRows) or 0 end
end
end
return 0
end
local function db_fetchScalar(query, params, cb)
if have_oxmysql() then
exports.oxmysql:scalar(query, params, cb)
return
end
if have_mysql_async() then
MySQL.Async.fetchScalar(query, params, cb)
return
end
print("^1[turfwar]^7 ERROR: No SQL adapter found. Start oxmysql or mysql-async.")
cb(nil)
end
local function db_execute(query, params, cb)
if have_oxmysql() then
exports.oxmysql:execute(query, params, function(result)
cb(normalizeAffected(result))
end)
return
end
if have_mysql_async() then
MySQL.Async.execute(query, params, function(affected)
cb(normalizeAffected(affected))
end)
return
end
print("^1[turfwar]^7 ERROR: No SQL adapter found. Start oxmysql or mysql-async.")
cb(0)
end
-- =========================================================
-- Gang bank: balance + atomic charge
-- =========================================================
local function ensureGangRow(gangId)
-- NOTE: ON DUPLICATE KEY requires UNIQUE KEY on gang_id.
-- You *should* add: ALTER TABLE turfwar_gang_accounts ADD UNIQUE KEY uq_gang_id (gang_id);
local q = ("INSERT INTO %s (%s,%s) VALUES (?,0) ON DUPLICATE KEY UPDATE %s=%s")
:format(GANG_TABLE, COL_GANG_ID, COL_BALANCE, COL_GANG_ID, COL_GANG_ID)
db_execute(q, { gangId }, function(_) end)
end
local function GetGangBalanceAsync(gangId, cb)
ensureGangRow(gangId)
local q = ("SELECT %s FROM %s WHERE %s = ? LIMIT 1")
:format(COL_BALANCE, GANG_TABLE, COL_GANG_ID)
db_fetchScalar(q, { gangId }, function(val)
cb(tonumber(val) or 0)
end)
end
local function TryChargeGangAsync(gangId, amount, cb)
ensureGangRow(gangId)
amount = tonumber(amount) or 0
if amount <= 0 then
GetGangBalanceAsync(gangId, function(bal) cb(true, bal) end)
return
end
local q = ("UPDATE %s SET %s = %s - ? WHERE %s = ? AND %s >= ?")
:format(GANG_TABLE, COL_BALANCE, COL_BALANCE, COL_GANG_ID, COL_BALANCE)
db_execute(q, { amount, gangId, amount }, function(affected)
affected = tonumber(affected) or 0
D(("UPDATE affectedRows=%d gang=%d amount=%d"):format(affected, gangId, amount))
if affected > 0 then
GetGangBalanceAsync(gangId, function(newBal) cb(true, newBal) end)
else
GetGangBalanceAsync(gangId, function(bal) cb(false, bal) end)
end
end)
end
-- =========================================================
-- Balance request
-- =========================================================
RegisterNetEvent("turfwar:shop:requestBalance", function()
local src = source
local gangId = GetPlayerGangId(src)
if gangId == POLICE_GANG_ID then
TriggerClientEvent("turfwar:shop:balance", src, gangId, 0)
return
end
GetGangBalanceAsync(gangId, function(bal)
TriggerClientEvent("turfwar:shop:balance", src, gangId, bal)
end)
end)
-- =========================================================
-- Finish helper
-- =========================================================
local function Finish(src, ok, message, newBal)
TriggerClientEvent("turfwar:shop:result", src, ok, message or "", newBal)
inFlight[src] = nil
end
-- =========================================================
-- Purchase cores
-- =========================================================
local function HandleBuyWeapon(src, gangId, itemId)
itemId = tostring(itemId or "")
if gangId == 0 then
Finish(src, false, "You are not in a gang.", nil)
return
end
local weapons, _, _, isPolice = GetShopTablesForGang(gangId)
local item = weapons[itemId]
if type(item) ~= "table" then
Finish(src, false, "Invalid weapon.", nil)
return
end
local price = tonumber(item.price) or 0
if isPolice or price <= 0 then
TriggerClientEvent("turfwar:shop:grantWeapon", src, item.weapon, item.ammo or 0)
Finish(src, true, ("Issued %s"):format(item.label or "item"), 0)
return
end
TryChargeGangAsync(gangId, price, function(ok, newBal)
D(("CHARGE weapon src=%d gang=%d price=%d ok=%s newBal=%d item=%s"):format(
src, gangId, price, tostring(ok), tonumber(newBal) or -1, itemId
))
if not ok then
Finish(src, false, "Not enough gang funds.", newBal)
return
end
TriggerClientEvent("turfwar:shop:grantWeapon", src, item.weapon, item.ammo or 0)
Finish(src, true, ("Purchased %s"):format(item.label or "item"), newBal)
end)
end
local function HandleBuyAmmo(src, gangId, itemId)
itemId = tostring(itemId or "")
if gangId == 0 then
Finish(src, false, "You are not in a gang.", nil)
return
end
local _, ammo, _, isPolice = GetShopTablesForGang(gangId)
local item = ammo[itemId]
if type(item) ~= "table" then
Finish(src, false, "Invalid ammo.", nil)
return
end
local price = tonumber(item.price) or 0
if isPolice or price <= 0 then
TriggerClientEvent("turfwar:shop:grantAmmo", src, item.forWeapon, item.amount or 0)
Finish(src, true, ("Issued %s"):format(item.label or "ammo"), 0)
return
end
TryChargeGangAsync(gangId, price, function(ok, newBal)
D(("CHARGE ammo src=%d gang=%d price=%d ok=%s newBal=%d item=%s"):format(
src, gangId, price, tostring(ok), tonumber(newBal) or -1, itemId
))
if not ok then
Finish(src, false, "Not enough gang funds.", newBal)
return
end
TriggerClientEvent("turfwar:shop:grantAmmo", src, item.forWeapon, item.amount or 0)
Finish(src, true, ("Purchased %s"):format(item.label or "ammo"), newBal)
end)
end
local function HandleBuyVehicle(src, gangId, itemId)
itemId = tostring(itemId or "")
if gangId == 0 then
Finish(src, false, "You are not in a gang.", nil)
return
end
local _, _, vehicles, isPolice = GetShopTablesForGang(gangId)
local item = vehicles[itemId]
if type(item) ~= "table" then
Finish(src, false, "Invalid vehicle.", nil)
return
end
local price = tonumber(item.price) or 0
if isPolice or price <= 0 then
TriggerClientEvent("turfwar:shop:grantVehicle", src, item.model)
Finish(src, true, ("Issued %s"):format(item.label or "vehicle"), 0)
return
end
TryChargeGangAsync(gangId, price, function(ok, newBal)
D(("CHARGE vehicle src=%d gang=%d price=%d ok=%s newBal=%d item=%s"):format(
src, gangId, price, tostring(ok), tonumber(newBal) or -1, itemId
))
if not ok then
Finish(src, false, "Not enough gang funds.", newBal)
return
end
TriggerClientEvent("turfwar:shop:grantVehicle", src, item.model)
Finish(src, true, ("Purchased %s"):format(item.label or "vehicle"), newBal)
end)
end
local function Execute(src, gangId, kind, itemId)
D(("EXEC src=%d gang=%d kind=%s item=%s"):format(src, gangId, tostring(kind), tostring(itemId)))
if kind == "weapon" then
HandleBuyWeapon(src, gangId, itemId)
elseif kind == "ammo" then
HandleBuyAmmo(src, gangId, itemId)
elseif kind == "vehicle" then
HandleBuyVehicle(src, gangId, itemId)
else
Finish(src, false, "Invalid purchase.", nil)
end
end
local function BeginPurchase(src, kind, itemId)
local key = seenKey(src, kind, itemId)
if isSeen(src, key) then
D(("IGNORED DUPLICATE src=%d kind=%s item=%s"):format(src, kind, tostring(itemId)))
return
end
markSeen(src, key)
if inFlight[src] then
D(("BLOCKED IN-FLIGHT src=%d kind=%s item=%s"):format(src, kind, tostring(itemId)))
TriggerClientEvent("turfwar:shop:result", src, false, "Please wait…", nil)
return
end
inFlight[src] = true
if not isGangSyncFresh(src) then
local nonce = ("%d:%d:%d"):format(src, nowMs(), math.random(100000, 999999))
pendingBuy[src] = { nonce = nonce, kind = kind, itemId = tostring(itemId or "") }
D(("SYNC-REQ src=%d nonce=%s kind=%s item=%s cachedGang=%d"):format(
src, nonce, kind, tostring(itemId), GetPlayerGangId(src)
))
requestGangSync(src, nonce)
return
end
Execute(src, GetPlayerGangId(src), kind, itemId)
end
RegisterNetEvent("turfwar:shop:syncGangResponse", function(nonce, gangId)
local src = source
local p = pendingBuy[src]
if not p or p.nonce ~= nonce then return end
gangId = tonumber(gangId) or 0
SetGangFor(src, gangId)
freshSync[src] = nowMs()
pendingBuy[src] = nil
D(("SYNC-OK src=%d gang=%d kind=%s item=%s"):format(src, gangId, p.kind, tostring(p.itemId)))
Execute(src, gangId, p.kind, p.itemId)
end)
RegisterNetEvent("turfwar:shop:buyWeapon", function(itemId) BeginPurchase(source, "weapon", itemId) end)
RegisterNetEvent("turfwar:shop:buyAmmo", function(itemId) BeginPurchase(source, "ammo", itemId) end)
RegisterNetEvent("turfwar:shop:buyVehicle", function(itemId) BeginPurchase(source, "vehicle", itemId) end)