-- 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)