diff --git a/server/appearance.lua b/server/appearance.lua new file mode 100644 index 0000000..ff18085 --- /dev/null +++ b/server/appearance.lua @@ -0,0 +1,88 @@ +-- server/appearance.lua +print("^2[turfwar]^7 appearance.lua loaded (server)") + +local function getLicense(src) + for _, id in ipairs(GetPlayerIdentifiers(src)) do + if id:sub(1, 8) == "license:" then + return id + end + end + return nil +end + +local function defaultRow(license) + return { + license = license, + gender = "m", + hair_drawable = 0, + hair_texture = 0, + hair_color = 0, + hair_highlight = 0 + } +end + +RegisterNetEvent("turfwar:appearance:request", function() + local src = source + local license = getLicense(src) + if not license then + print(("^1[turfwar]^7 appearance: missing license for src %d"):format(src)) + return + end + + local row = exports.oxmysql:singleSync( + "SELECT gender, hair_drawable, hair_texture, hair_color, hair_highlight FROM turfwar_appearance WHERE license = ?", + { license } + ) + + if not row then + local def = defaultRow(license) + exports.oxmysql:insertSync( + "INSERT INTO turfwar_appearance (license, gender, hair_drawable, hair_texture, hair_color, hair_highlight) VALUES (?, ?, ?, ?, ?, ?)", + { def.license, def.gender, def.hair_drawable, def.hair_texture, def.hair_color, def.hair_highlight } + ) + row = def + end + + TriggerClientEvent("turfwar:appearance:update", src, row) +end) + +RegisterNetEvent("turfwar:appearance:setGender", function(gender) + local src = source + local license = getLicense(src) + if not license then return end + + gender = (gender == "f") and "f" or "m" + + exports.oxmysql:updateSync( + "UPDATE turfwar_appearance SET gender = ? WHERE license = ?", + { gender, license } + ) + + local row = exports.oxmysql:singleSync( + "SELECT gender, hair_drawable, hair_texture, hair_color, hair_highlight FROM turfwar_appearance WHERE license = ?", + { license } + ) + TriggerClientEvent("turfwar:appearance:update", src, row) +end) + +RegisterNetEvent("turfwar:appearance:setHair", function(drawable, texture, color, highlight) + local src = source + local license = getLicense(src) + if not license then return end + + drawable = tonumber(drawable) or 0 + texture = tonumber(texture) or 0 + color = tonumber(color) or 0 + highlight = tonumber(highlight) or color + + exports.oxmysql:updateSync( + "UPDATE turfwar_appearance SET hair_drawable=?, hair_texture=?, hair_color=?, hair_highlight=? WHERE license=?", + { drawable, texture, color, highlight, license } + ) + + local row = exports.oxmysql:singleSync( + "SELECT gender, hair_drawable, hair_texture, hair_color, hair_highlight FROM turfwar_appearance WHERE license = ?", + { license } + ) + TriggerClientEvent("turfwar:appearance:update", src, row) +end) diff --git a/server/atm_access.lua b/server/atm_access.lua new file mode 100644 index 0000000..2b50f3c --- /dev/null +++ b/server/atm_access.lua @@ -0,0 +1,104 @@ +-- server/atm_access.lua +print("^2[turfwar]^7 atm_access.lua loaded (server-side gatekeeper)") + +Config = Config or {} +Config.Finance = Config.Finance or {} + +local USE_RADIUS = tonumber(Config.Finance.ATM_USE_RADIUS) or 1.8 +local TTL_MS = tonumber(Config.Finance.ATM_SESSION_TTL_MS) or 15000 + +local sessions = {} + +local function dist3(a, b) + local dx = (a.x - b.x) + local dy = (a.y - b.y) + local dz = (a.z - b.z) + return math.sqrt(dx*dx + dy*dy + dz*dz) +end + +local function newToken() + return ("%08x%08x"):format(math.random(0, 0xffffffff), math.random(0, 0xffffffff)) +end + +local function getPlayerCoords(src) + local ped = GetPlayerPed(src) + if not ped or ped == 0 then return nil end + local c = GetEntityCoords(ped) + return { x = c.x, y = c.y, z = c.z } +end + +RegisterNetEvent("turfwar:atm:open", function(payload) + local src = source + if type(payload) ~= "table" then + TriggerClientEvent("turfwar:atm:openDenied", src, "bad_payload") + return + end + + local x, y, z = tonumber(payload.x), tonumber(payload.y), tonumber(payload.z) + local model = payload.model + + if not x or not y or not z then + TriggerClientEvent("turfwar:atm:openDenied", src, "bad_coords") + return + end + + local okModel = false + for _, m in ipairs(Config.Finance.ATM_MODELS or {}) do + if m == model then okModel = true break end + end + if not okModel then + TriggerClientEvent("turfwar:atm:openDenied", src, "bad_model") + return + end + + local pc = getPlayerCoords(src) + if not pc then + TriggerClientEvent("turfwar:atm:openDenied", src, "no_ped") + return + end + + local atmC = { x = x, y = y, z = z } + if dist3(pc, atmC) > (USE_RADIUS + 0.75) then + TriggerClientEvent("turfwar:atm:openDenied", src, "too_far") + return + end + + local token = newToken() + sessions[src] = { + token = token, + expires = GetGameTimer() + TTL_MS, + atm = { x = x, y = y, z = z, model = model } + } + + TriggerClientEvent("turfwar:atm:openGranted", src, token) +end) + +local function RequireATM(src, token, verbose) + local s = sessions[src] + if not s then return false, "no_session" end + if token ~= s.token then return false, "bad_token" end + if GetGameTimer() > (s.expires or 0) then + sessions[src] = nil + return false, "expired" + end + + local pc = getPlayerCoords(src) + if not pc then return false, "no_ped" end + + local atmC = { x = s.atm.x, y = s.atm.y, z = s.atm.z } + if dist3(pc, atmC) > (USE_RADIUS + 0.75) then + if verbose then + print(("^1[ATM BLOCK]^7 src=%d moved away from ATM"):format(src)) + end + sessions[src] = nil + return false, "moved_away" + end + + return true +end + +exports("RequireATM", RequireATM) + +AddEventHandler("playerDropped", function() + sessions[source] = nil +end) diff --git a/server/cash.lua b/server/cash.lua new file mode 100644 index 0000000..555d842 --- /dev/null +++ b/server/cash.lua @@ -0,0 +1,304 @@ +-- server/cash.lua +print("^2[turfwar]^7 cash.lua loaded") + +Cash = {} + +local cache = {} + +local function PushMoney(src) + TriggerClientEvent('turfwar:money:update', src, Cash.Get(src), Cash.GetBank(src)) +end + +-- Safe notify helper (won't crash if you don't have turfwar:notify hooked) +local function Notify(src, msg) + TriggerClientEvent('chat:addMessage', src, { args = { "ATM", tostring(msg) } }) +end + +local function getIdentifier(src) + local ids = GetPlayerIdentifiers(src) + if not ids or #ids == 0 then return nil end + + -- Prefer license: + for _, id in ipairs(ids) do + if id:sub(1, 8) == "license:" then + return id + end + end + + -- Fallback (steam/discord/etc.) + return ids[1] +end + +local function ensureLoaded(src) + local identifier = getIdentifier(src) + if not identifier then return nil end + if not cache[identifier] then + Cash.Load(src) + end + return identifier +end + +function Cash.Load(src) + local identifier = getIdentifier(src) + if not identifier then return end + + local rows = MySQL.query.await( + 'SELECT cash, bank FROM player_money WHERE identifier = ?', + { identifier } + ) + local row = (rows and rows[1]) or nil + + if not row then + MySQL.insert.await( + 'INSERT INTO player_money (identifier, cash, bank) VALUES (?, 0, 0)', + { identifier } + ) + row = { cash = 0, bank = 0 } + end + + cache[identifier] = { + cash = tonumber(row.cash) or 0, + bank = tonumber(row.bank) or 0 + } + + PushMoney(src) +end + +function Cash.Get(src) + local identifier = ensureLoaded(src) + if not identifier then return 0 end + return cache[identifier].cash or 0 +end + +function Cash.GetBank(src) + local identifier = ensureLoaded(src) + if not identifier then return 0 end + return cache[identifier].bank or 0 +end + +function Cash.Add(src, amount) + amount = tonumber(amount) or 0 + if amount == 0 then return end + + local identifier = ensureLoaded(src) + if not identifier then return end + + local data = cache[identifier] + data.cash = math.max(0, (data.cash or 0) + amount) + + MySQL.update( + 'UPDATE player_money SET cash = ? WHERE identifier = ?', + { data.cash, identifier } + ) + + PushMoney(src) +end + +function Cash.Remove(src, amount) + Cash.Add(src, -(tonumber(amount) or 0)) +end + +function Cash.Set(src, amount) + amount = math.max(0, tonumber(amount) or 0) + + local identifier = ensureLoaded(src) + if not identifier then return end + + cache[identifier].cash = amount + + MySQL.update( + 'UPDATE player_money SET cash = ? WHERE identifier = ?', + { amount, identifier } + ) + + PushMoney(src) +end + +function Cash.SetBank(src, amount) + amount = math.max(0, tonumber(amount) or 0) + + local identifier = ensureLoaded(src) + if not identifier then return end + + cache[identifier].bank = amount + + MySQL.update( + 'UPDATE player_money SET bank = ? WHERE identifier = ?', + { amount, identifier } + ) + + PushMoney(src) +end + +AddEventHandler('playerJoining', function() + Cash.Load(source) +end) + +AddEventHandler('playerDropped', function() + local src = source + local identifier = getIdentifier(src) + if identifier and cache[identifier] then + local data = cache[identifier] + MySQL.update( + 'UPDATE player_money SET cash = ?, bank = ? WHERE identifier = ?', + { data.cash or 0, data.bank or 0, identifier } + ) + cache[identifier] = nil + end +end) + +RegisterNetEvent('turfwar:money:request', function() + local src = source + Cash.Load(src) + PushMoney(src) +end) + +-- ========================================================= +-- Ped cash drops -> claim once -> add to player's cash +-- ========================================================= +local CashDrops = {} +local NextDropId = 1 + +RegisterNetEvent("environment:pedCashDrop", function(amount, coords) + amount = tonumber(amount) or 0 + if amount < 1 or amount > 1000 then return end + if type(coords) ~= "table" or coords.x == nil or coords.y == nil or coords.z == nil then return end + + local dropId = NextDropId + NextDropId = NextDropId + 1 + + CashDrops[dropId] = { + amount = amount, + taken = false, + createdAt = os.time() + } + + TriggerClientEvent("environment:spawnCashPickup", -1, dropId, amount, coords) + + SetTimeout(5 * 60 * 1000, function() + if CashDrops[dropId] and not CashDrops[dropId].taken then + CashDrops[dropId] = nil + TriggerClientEvent("environment:removeCashPickup", -1, dropId) + end + end) +end) + +RegisterNetEvent("environment:pickupCash", function(dropId) + local src = source + dropId = tonumber(dropId) + if not dropId then return end + + local drop = CashDrops and CashDrops[dropId] + if not drop or drop.taken then return end + + drop.taken = true + local amount = tonumber(drop.amount) or 0 + + CashDrops[dropId] = nil + + if amount > 0 then + Cash.Add(src, amount) + end + + TriggerClientEvent("environment:removeCashPickup", -1, dropId) + TriggerClientEvent("environment:pickupFeedback", src, amount) +end) + +-- ========================================================= +-- ATM-Gated Bank Transactions (server authoritative) +-- Requires: server/atm_access.lua exporting RequireATM +-- ========================================================= + +local function RequireATM(src, token) + return exports[GetCurrentResourceName()]:RequireATM(src, token, true) +end + +local function clampAmount(x) + x = tonumber(x) + if not x then return nil end + x = math.floor(x) + if x <= 0 then return nil end + return x +end + +RegisterNetEvent("turfwar:bank:deposit", function(token, amount) + local src = source + + local ok, err = RequireATM(src, token) + if not ok then + print(("^1[ATM BLOCK]^7 deposit src=%s reason=%s"):format(src, err)) + return + end + + amount = clampAmount(amount) + if not amount then return end + + local cash = Cash.Get(src) + if cash < amount then + Notify(src, "Not enough cash.") + return + end + + Cash.Set(src, cash - amount) + Cash.SetBank(src, Cash.GetBank(src) + amount) +end) + +RegisterNetEvent("turfwar:bank:withdraw", function(token, amount) + local src = source + + local ok, err = RequireATM(src, token) + if not ok then + print(("^1[ATM BLOCK]^7 withdraw src=%s reason=%s"):format(src, err)) + return + end + + amount = clampAmount(amount) + if not amount then return end + + local bank = Cash.GetBank(src) + if bank < amount then + Notify(src, "Not enough money in bank.") + return + end + + Cash.SetBank(src, bank - amount) + Cash.Set(src, Cash.Get(src) + amount) +end) + +RegisterNetEvent("turfwar:atm:balance", function(token) + local src = source + local ok = RequireATM(src, token) + if not ok then return end + PushMoney(src) +end) + +-- ========================================================= +-- Admin +-- ========================================================= + +RegisterCommand("cash", function(src) + if src == 0 then + print("[turfwar] /cash can only be used in-game.") + return + end + + TriggerClientEvent('chat:addMessage', src, { + args = { "Money", ("Cash: $%d | Bank: $%d"):format(Cash.Get(src), Cash.GetBank(src)) } + }) +end, false) + +RegisterCommand("addcash", function(src, args) + if src == 0 then + print("[turfwar] Use in-game: /addcash ") + return + end + Cash.Add(src, tonumber(args[1]) or 0) +end, false) + +RegisterCommand("setcash", function(src, args) + if src == 0 then + print("[turfwar] Use in-game: /setcash ") + return + end + Cash.Set(src, tonumber(args[1]) or 0) +end, false) diff --git a/server/finance.lua b/server/finance.lua new file mode 100644 index 0000000..49b0eab --- /dev/null +++ b/server/finance.lua @@ -0,0 +1,160 @@ +print("^2[turfwar]^7 finance server loaded") + +-- Uses your existing Cash API: +-- Cash.Get(src), Cash.Add(src, amt), Cash.Remove(src, amt) +-- Cash.GetBank(src), Cash.SetBank(src, amt) + +local function Notify(src, msg) + -- This is your existing chat/notify path + TriggerClientEvent('turfwar:finance:notify', src, msg) +end + +local function Feed(src, kind, amount) + -- NEW: GTA feed popup (client will render it) + -- kind: "deposit" | "withdraw" | "balance" + TriggerClientEvent('turfwar:finance:feed', src, kind, amount) +end + +local function PushMoney(src) + -- Keep using your existing HUD/stat updater (in main.lua) + TriggerClientEvent('turfwar:money:update', src, Cash.Get(src), Cash.GetBank(src)) +end + +local function parseAmount(arg) + local n = tonumber(arg) + if not n then return nil end + n = math.floor(n) + if n <= 0 then return nil end + return n +end + +-- Simple anti-spam +local lastCmd = {} +local CMD_COOLDOWN_MS = 700 + +local function canRun(src) + local now = GetGameTimer() + local last = lastCmd[src] or 0 + if (now - last) < CMD_COOLDOWN_MS then + return false + end + lastCmd[src] = now + return true +end + +AddEventHandler('playerDropped', function() + lastCmd[source] = nil +end) + +-- Make sure Cash is loaded before commands are used +CreateThread(function() + Wait(0) + if type(Cash) ~= "table" then + print("^1[turfwar]^7 finance.lua: Cash table missing. Ensure cash.lua loads before finance.lua in fxmanifest.") + return + end + if type(Cash.Get) ~= "function" or type(Cash.Add) ~= "function" or type(Cash.Remove) ~= "function" then + print("^1[turfwar]^7 finance.lua: Missing Cash.Get/Add/Remove. Check cash.lua.") + end + if type(Cash.GetBank) ~= "function" or type(Cash.SetBank) ~= "function" then + print("^1[turfwar]^7 finance.lua: Missing Cash.GetBank/SetBank. Check cash.lua.") + end +end) + +-- ------------------------------------------------ +-- /deposit +-- ------------------------------------------------ +RegisterCommand("deposit", function(src, args) + if not src or src <= 0 then return end + if not canRun(src) then return end + + local amount = parseAmount(args[1]) + if not amount then + Notify(src, "~r~Usage: /deposit ") + return + end + + local cash = Cash.Get(src) + if cash < amount then + Notify(src, "~r~Not enough cash.") + return + end + + -- Move cash -> bank + Cash.Remove(src, amount) + Cash.SetBank(src, Cash.GetBank(src) + amount) + + -- Update your HUD (no duplication) + PushMoney(src) + + -- NEW: GTA feed popup (single line) + Feed(src, "deposit", amount) + + -- Keep your existing notify too (optional; remove if you only want GTA feed) + Notify(src, ("~g~Deposited $%d"):format(amount)) +end, false) + +-- ------------------------------------------------ +-- /withdraw +-- ------------------------------------------------ +RegisterCommand("withdraw", function(src, args) + if not src or src <= 0 then return end + if not canRun(src) then return end + + local amount = parseAmount(args[1]) + if not amount then + Notify(src, "~r~Usage: /withdraw ") + return + end + + local bank = Cash.GetBank(src) + if bank < amount then + Notify(src, "~r~Not enough in bank.") + return + end + + -- Move bank -> cash + Cash.SetBank(src, bank - amount) + Cash.Add(src, amount) + + -- Update your HUD (no duplication) + PushMoney(src) + + -- NEW: GTA feed popup (single line) + Feed(src, "withdraw", amount) + + -- Keep your existing notify too (optional) + Notify(src, ("~g~Withdrew $%d"):format(amount)) +end, false) + +-- ------------------------------------------------ +-- /balance +-- ------------------------------------------------ +RegisterCommand("balance", function(src) + if not src or src <= 0 then return end + + local cash = Cash.Get(src) + local bank = Cash.GetBank(src) + + PushMoney(src) + + -- Optional: feed (shows bank balance) + Feed(src, "balance", bank) + + Notify(src, ("~g~Cash:~w~ $%d ~b~Bank:~w~ $%d"):format(cash, bank)) +end, false) + + +local atmOpenUntil = 0 + +local function OpenATMInterface() + atmOpenUntil = GetGameTimer() + 15000 -- 15s window + PlaySoundFrontend(-1, "ATM_WINDOW", "HUD_FRONTEND_DEFAULT_SOUNDSET", true) + + BeginTextCommandThefeedPost("STRING") + AddTextComponentSubstringPlayerName("~g~ATM~s~: /deposit /withdraw /balance") + EndTextCommandThefeedPostTicker(false, false) +end + +-- When player presses E at ATM, call this instead of the old help: +-- OpenATMInterface() diff --git a/server/gang_shops.lua b/server/gang_shops.lua new file mode 100644 index 0000000..85f0993 --- /dev/null +++ b/server/gang_shops.lua @@ -0,0 +1,245 @@ +-- server/gang_shops.lua +print("^2[turfwar]^7 gang_shops.lua loaded (server)") + +Config = Config or {} +Config.Shops = Config.Shops or {} + +-- IMPORTANT: +-- This assumes you already have: +-- - player gang stored server-side somewhere, OR a callback/event to fetch it. +-- +-- Below uses a simple server cache: +local playerGang = {} -- [src] = gangId + +-- You likely already broadcast this elsewhere. +-- Hook into your existing gang set event if you have one. +RegisterNetEvent("turfwar:myGang:set", function(gangId) + local src = source + playerGang[src] = tonumber(gangId) or 0 +end) + +AddEventHandler("playerDropped", function() + local src = source + playerGang[src] = nil +end) + +local function getGangId(src) + return tonumber(playerGang[src]) or 0 +end + +-- ========================= +-- DB helpers (gang bank) +-- ========================= +local function dbFetchBalance(gangId, cb) + exports.oxmysql:query("SELECT balance FROM turfwar_gang_accounts WHERE gang_id = ? LIMIT 1", { gangId }, function(rows) + local bal = 0 + if rows and rows[1] and rows[1].balance then bal = tonumber(rows[1].balance) or 0 end + cb(bal) + end) +end + +local function dbEnsureRow(gangId, cb) + exports.oxmysql:query("INSERT IGNORE INTO turfwar_gang_accounts (gang_id, balance) VALUES (?, 0)", { gangId }, function() + cb() + end) +end + +local function dbDeduct(gangId, amount, cb) + amount = tonumber(amount) or 0 + if amount <= 0 then cb(false, "bad_amount") return end + + dbEnsureRow(gangId, function() + exports.oxmysql:query("UPDATE turfwar_gang_accounts SET balance = balance - ? WHERE gang_id = ? AND balance >= ?", { + amount, gangId, amount + }, function(result) + -- oxmysql UPDATE result varies by version; handle common cases: + local changed = 0 + if type(result) == "table" and result.affectedRows then changed = result.affectedRows end + if type(result) == "number" then changed = result end + + cb(changed and changed > 0, changed and changed > 0 and nil or "insufficient") + end) + end) +end + +-- ========================= +-- Lookup helpers +-- ========================= +local function findWeaponItem(itemId) + if not Config.Shops or not Config.Shops.WeaponList then return nil end + for _, it in ipairs(Config.Shops.WeaponList) do + if it.id == itemId then return it end + end + return nil +end + +local function findAmmoItem(itemId) + if not Config.Shops or not Config.Shops.AmmoList then return nil end + for _, it in ipairs(Config.Shops.AmmoList) do + if it.id == itemId then return it end + end + return nil +end + +local function findVehicleItem(itemId) + if not Config.Shops or not Config.Shops.VehicleList then return nil end + for _, it in ipairs(Config.Shops.VehicleList) do + if it.id == itemId then return it end + end + return nil +end + +-- ========================= +-- Purchase: weapons +-- ========================= +RegisterNetEvent("turfwar:shop:buyWeapon", function(itemId) + local src = source + local gangId = getGangId(src) + + if not (Config.Shops.AllowedGang and Config.Shops.AllowedGang(gangId)) then + TriggerClientEvent("turfwar:shop:notify", src, "~r~You can't use the gang shop.") + return + end + + local item = findWeaponItem(itemId) + if not item then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Invalid item.") + return + end + + local price = tonumber(item.price) or 0 + if price < 0 then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Bad price config.") + return + end + + dbDeduct(gangId, price, function(ok, reason) + if not ok then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Not enough gang cash.") + return + end + + -- Grant on client (server-authoritative purchase) + TriggerClientEvent("turfwar:shop:grantWeapon", src, item, gangId) + TriggerClientEvent("turfwar:shop:notify", src, ("~g~Purchased: ~s~%s"):format(item.label or item.id)) + TriggerClientEvent("turfwar:gangbank:requestRefresh", src) -- optional hook + end) +end) + +RegisterNetEvent("turfwar:shop:buyAmmo", function(itemId) + local src = source + local gangId = getGangId(src) + + if not (Config.Shops.AllowedGang and Config.Shops.AllowedGang(gangId)) then + TriggerClientEvent("turfwar:shop:notify", src, "~r~You can't use the gang shop.") + return + end + + local item = findAmmoItem(itemId) + if not item then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Invalid ammo item.") + return + end + + local price = tonumber(item.price) or 0 + if price <= 0 then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Bad price config.") + return + end + + dbDeduct(gangId, price, function(ok) + if not ok then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Not enough gang cash.") + return + end + + TriggerClientEvent("turfwar:shop:grantAmmo", src, item) + TriggerClientEvent("turfwar:shop:notify", src, ("~g~Purchased: ~s~%s"):format(item.label or item.id)) + TriggerClientEvent("turfwar:gangbank:requestRefresh", src) + end) +end) + +-- ========================= +-- Purchase: vehicles +-- ========================= +RegisterNetEvent("turfwar:shop:buyVehicle", function(itemId) + local src = source + local gangId = getGangId(src) + + if not (Config.Shops.AllowedGang and Config.Shops.AllowedGang(gangId)) then + TriggerClientEvent("turfwar:shop:notify", src, "~r~You can't use the gang shop.") + return + end + + local item = findVehicleItem(itemId) + if not item then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Invalid vehicle.") + return + end + + local price = tonumber(item.price) + if price == nil then price = 0 end + + -- Allow FREE vehicles (price = 0). Only reject negative. + if price < 0 then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Bad price config.") + return + end + + local function afterPaid() + -- Use your existing spawn-pad system + if not VehicleSpawner or not VehicleSpawner.SpawnModelFor then + TriggerClientEvent("turfwar:shop:notify", src, "~r~VehicleSpawner not available.") + return + end + + -- Support special model alias for "gang default" + local model = item.model + if model == "__GANG_DEFAULT__" then + model = (Config.Vehicles and Config.Vehicles.gangModels and Config.Vehicles.gangModels[gangId]) + end + + if type(model) ~= "string" or model == "" then + TriggerClientEvent("turfwar:shop:notify", src, "~r~No vehicle configured for your gang.") + return + end + + local ok2, reason = VehicleSpawner.SpawnModelFor(src, model, { platePrefix = "G" }) + if not ok2 then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Vehicle spawn failed.") + return + end + + TriggerClientEvent("turfwar:shop:notify", src, ("~g~Purchased: ~s~%s"):format(item.label or item.id)) + TriggerClientEvent("turfwar:gangbank:requestRefresh", src) + end + + -- If free, don't touch the gang bank + if price == 0 then + afterPaid() + return + end + + -- Deduct first (server authority) + dbDeduct(gangId, price, function(ok) + if not ok then + TriggerClientEvent("turfwar:shop:notify", src, "~r~Not enough gang cash.") + return + end + afterPaid() + end) +end) + + +-- Optional: allow client to query gang balance (for UI) +RegisterNetEvent("turfwar:shop:getBalance", function() + local src = source + local gangId = getGangId(src) + if gangId <= 0 then + TriggerClientEvent("turfwar:shop:balance", src, 0) + return + end + dbFetchBalance(gangId, function(bal) + TriggerClientEvent("turfwar:shop:balance", src, bal) + end) +end)