if not SERVER then return end local Progression = CustomCoopProgression local playerStates = setmetatable({}, { __mode = "k" }) local requestCooldowns = setmetatable({}, { __mode = "k" }) local shopPrices = {} local dirtyShopPrices = {} local availableMusic = {} local musicBusyUntil = 0 local totalsSaveInterval = CreateConVar( "gc_progression_save_interval", "60", FCVAR_ARCHIVE, "Seconds between write-behind saves of changed co-op progression data.", 10, 3600 ) util.AddNetworkString("CustomCoopProgressionOpenShop") util.AddNetworkString("CustomCoopProgressionRequestShop") util.AddNetworkString("CustomCoopProgressionSelectItem") util.AddNetworkString("CustomCoopProgressionNotice") util.AddNetworkString("CustomCoopProgressionSetPrice") util.AddNetworkString("CustomCoopProgressionPricesChanged") util.AddNetworkString("CustomCoopProgressionPlayMusic") util.AddNetworkString("CustomCoopProgressionToggleMusicMute") local function Query(query, context) local startedAt = SysTime() local result = sql.Query(query) local elapsed = SysTime() - startedAt if elapsed >= 0.05 then ErrorNoHalt(string.format( "[custom_coop progression] Slow SQLite query (%s): %.1f ms\n", context, elapsed * 1000 )) end if result == false then ErrorNoHalt("[custom_coop progression] " .. context .. ": " .. sql.LastError() .. "\n") return false end return result end local function SQLString(value) return sql.SQLStr(tostring(value or "")) end local function CreateTables() Query([[ CREATE TABLE IF NOT EXISTS custom_coop_players ( steamid64 TEXT PRIMARY KEY NOT NULL, player_name TEXT NOT NULL DEFAULT '', lifetime_score INTEGER NOT NULL DEFAULT 0, credits INTEGER NOT NULL DEFAULT 0, equipped_model TEXT NOT NULL DEFAULT 'kleiner', equipped_weapon TEXT NOT NULL DEFAULT 'none' ) ]], "could not create player table") Query([[ CREATE TABLE IF NOT EXISTS custom_coop_owned_items ( steamid64 TEXT NOT NULL, item_type TEXT NOT NULL, item_id TEXT NOT NULL, purchased_at INTEGER NOT NULL, PRIMARY KEY (steamid64, item_type, item_id) ) ]], "could not create ownership table") Query([[ CREATE TABLE IF NOT EXISTS custom_coop_shop_prices ( item_type TEXT NOT NULL, item_id TEXT NOT NULL, price INTEGER NOT NULL, PRIMARY KEY (item_type, item_id) ) ]], "could not create shop price table") end CreateTables() local catalogues = { { itemType = "model", items = Progression.Models }, { itemType = "weapon", items = Progression.Weapons }, { itemType = "music", items = Progression.Music } } for _, item in ipairs(Progression.Music) do local soundPath = "sound/" .. item.sound if file.Exists(soundPath, "GAME") then availableMusic[item.id] = true resource.AddFile(soundPath) else ErrorNoHalt("[custom_coop progression] Missing configured music file " .. soundPath .. "\n") end end -- Model files can live on slow Workshop storage. Precache them while the -- gamemode is loading rather than when a player clicks an item in the shop. for _, item in ipairs(Progression.Models) do util.PrecacheModel(item.model) end local function PriceKey(itemType, itemID) return itemType .. ":" .. itemID end local function GetCatalogueItem(itemType, itemID) if itemType == "model" then return Progression.GetModel(itemID) end if itemType == "weapon" then return Progression.GetWeapon(itemID) end if itemType == "music" then return Progression.GetMusic(itemID) end end local function GetShopPrice(itemType, item) return shopPrices[PriceKey(itemType, item.id)] or math.max(math.floor(item.price or 0), 0) end local function LoadShopPrices() for _, catalogue in ipairs(catalogues) do for _, item in ipairs(catalogue.items) do Query(string.format( "INSERT OR IGNORE INTO custom_coop_shop_prices (item_type, item_id, price) VALUES (%s, %s, %d)", SQLString(catalogue.itemType), SQLString(item.id), math.max(math.floor(item.price or 0), 0) ), "could not seed shop price") end end local rows = Query("SELECT item_type, item_id, price FROM custom_coop_shop_prices", "could not load shop prices") if not istable(rows) then return end for _, row in ipairs(rows) do local item = GetCatalogueItem(row.item_type, row.item_id) local price = math.floor(tonumber(row.price) or -1) if item and price >= 0 and price <= Progression.MaximumShopPrice then shopPrices[PriceKey(row.item_type, row.item_id)] = price end end end LoadShopPrices() local function WriteShopPrices() local entries = {} for _, catalogue in ipairs(catalogues) do for _, item in ipairs(catalogue.items) do entries[#entries + 1] = { itemType = catalogue.itemType, id = item.id, price = GetShopPrice(catalogue.itemType, item) } end end net.WriteUInt(math.min(#entries, 255), 8) for index = 1, math.min(#entries, 255) do local entry = entries[index] net.WriteString(entry.itemType) net.WriteString(entry.id) net.WriteUInt(math.min(entry.price, 4294967295), 32) end end local function EmptyState() return { score = 0, credits = 0, equippedModel = Progression.DefaultModelID, equippedWeapon = Progression.DefaultWeaponID, ownedModels = { [Progression.DefaultModelID] = true }, ownedWeapons = { [Progression.DefaultWeaponID] = true }, ownedMusic = {}, pendingOwnedItems = {}, profileDirty = false } end local function SyncPlayer(ply, state) if not IsValid(ply) then return end ply:SetNW2Int("CustomCoopScore", state.score) ply:SetNW2Int("CustomCoopCredits", state.credits) ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score)) end local function LoadPlayer(ply) if not IsValid(ply) then return end local steamID64 = ply:SteamID64() if not steamID64 or steamID64 == "" then return end local row = Query( "SELECT lifetime_score, credits, equipped_model, equipped_weapon " .. "FROM custom_coop_players WHERE steamid64 = " .. SQLString(steamID64) .. " LIMIT 1", "could not load player" ) local state = EmptyState() state.steamID64 = steamID64 state.playerName = ply:Nick() state.profileDirty = not (istable(row) and row[1]) if istable(row) and row[1] then state.score = math.max(tonumber(row[1].lifetime_score) or 0, 0) state.credits = math.max(tonumber(row[1].credits) or 0, 0) state.equippedModel = row[1].equipped_model or Progression.DefaultModelID state.equippedWeapon = row[1].equipped_weapon or Progression.DefaultWeaponID end local ownedRows = Query( "SELECT item_type, item_id FROM custom_coop_owned_items WHERE steamid64 = " .. SQLString(steamID64), "could not load owned items" ) if istable(ownedRows) then for _, owned in ipairs(ownedRows) do if owned.item_type == "model" and Progression.GetModel(owned.item_id) then state.ownedModels[owned.item_id] = true elseif owned.item_type == "weapon" and Progression.GetWeapon(owned.item_id) then state.ownedWeapons[owned.item_id] = true elseif owned.item_type == "music" and Progression.GetMusic(owned.item_id) then state.ownedMusic[owned.item_id] = true end end end if not Progression.GetModel(state.equippedModel) or not state.ownedModels[state.equippedModel] then state.equippedModel = Progression.DefaultModelID state.profileDirty = true end if not Progression.GetWeapon(state.equippedWeapon) or not state.ownedWeapons[state.equippedWeapon] then state.equippedWeapon = Progression.DefaultWeaponID state.profileDirty = true end playerStates[ply] = state SyncPlayer(ply, state) return state end local function GetState(ply) return playerStates[ply] or LoadPlayer(ply) end -- Garry's Mod's built-in SQLite API runs on the game thread. Keep progression -- changes in memory and persist their latest values periodically, never from a -- kill, death, or shop-selection callback. local function SavePlayerState(ply, state) if not state then return true end local hasPendingOwnership = next(state.pendingOwnedItems) ~= nil if not state.profileDirty and not hasPendingOwnership then return true end local steamID64 = state.steamID64 if not steamID64 or steamID64 == "" then return false end if IsValid(ply) then state.playerName = ply:Nick() end local saveStartedAt = SysTime() sql.Begin() local result = Query(string.format( "INSERT OR REPLACE INTO custom_coop_players " .. "(steamid64, player_name, lifetime_score, credits, equipped_model, equipped_weapon) " .. "VALUES (%s, %s, %d, %d, %s, %s)", SQLString(steamID64), SQLString(state.playerName), math.max(math.floor(state.score or 0), 0), math.max(math.floor(state.credits or 0), 0), SQLString(state.equippedModel), SQLString(state.equippedWeapon) ), "could not save player profile") if result ~= false then for _, owned in pairs(state.pendingOwnedItems) do result = Query(string.format( "INSERT OR IGNORE INTO custom_coop_owned_items " .. "(steamid64, item_type, item_id, purchased_at) VALUES (%s, %s, %s, %d)", SQLString(steamID64), SQLString(owned.itemType), SQLString(owned.itemID), owned.purchasedAt ), "could not save owned shop item") if result == false then break end end end if result == false then sql.Query("ROLLBACK") return false end sql.Commit() local elapsed = SysTime() - saveStartedAt if elapsed >= 0.05 then ErrorNoHalt(string.format( "[custom_coop progression] Slow SQLite player flush: %.1f ms\n", elapsed * 1000 )) end state.profileDirty = false table.Empty(state.pendingOwnedItems) return true end local function SaveDirtyShopPrices() if next(dirtyShopPrices) == nil then return true end local saveStartedAt = SysTime() local result = true sql.Begin() for _, entry in pairs(dirtyShopPrices) do result = Query(string.format( "INSERT OR REPLACE INTO custom_coop_shop_prices (item_type, item_id, price) " .. "VALUES (%s, %s, %d)", SQLString(entry.itemType), SQLString(entry.itemID), entry.price ), "could not save shop price") if result == false then break end end if result == false then sql.Query("ROLLBACK") return false end sql.Commit() local elapsed = SysTime() - saveStartedAt if elapsed >= 0.05 then ErrorNoHalt(string.format( "[custom_coop progression] Slow SQLite price flush: %.1f ms\n", elapsed * 1000 )) end table.Empty(dirtyShopPrices) return true end local function FlushDirtyProgression() for ply, state in pairs(playerStates) do SavePlayerState(ply, state) end SaveDirtyShopPrices() end local function StartProgressionSaveTimer() timer.Create( "CustomCoopProgressionSaveTotals", math.max(totalsSaveInterval:GetFloat(), 10), 0, FlushDirtyProgression ) end StartProgressionSaveTimer() cvars.AddChangeCallback("gc_progression_save_interval", function() StartProgressionSaveTimer() end, "CustomCoopProgressionSaveInterval") local function ApplyModel(ply) local state = GetState(ply) local item = state and Progression.GetModel(state.equippedModel) if not item then item = Progression.GetModel(Progression.DefaultModelID) end if not item then return end ply:SetModel(item.model) end local function ApplyWeapon(ply, previousWeaponID) local state = GetState(ply) if not state or not IsValid(ply) or not ply:Alive() then return end if previousWeaponID then local previous = Progression.GetWeapon(previousWeaponID) if previous and previous.weaponClass ~= "" and ply.CustomCoopGrantedWeapon == previous.weaponClass then ply:StripWeapon(previous.weaponClass) end end local item = Progression.GetWeapon(state.equippedWeapon) if not item or item.weaponClass == "" then ply.CustomCoopGrantedWeapon = nil return end if not weapons.GetStored(item.weaponClass) then ErrorNoHalt("[custom_coop progression] Unknown shop weapon " .. item.weaponClass .. "\n") return end ply:Give(item.weaponClass) ply.CustomCoopGrantedWeapon = item.weaponClass end local function SendShop(ply) local state = GetState(ply) if not state then return end local owned = {} for id in pairs(state.ownedModels) do owned[#owned + 1] = { itemType = "model", id = id } end for id in pairs(state.ownedWeapons) do owned[#owned + 1] = { itemType = "weapon", id = id } end for id in pairs(state.ownedMusic) do owned[#owned + 1] = { itemType = "music", id = id } end net.Start("CustomCoopProgressionOpenShop") net.WriteUInt(math.min(state.score, 4294967295), 32) net.WriteUInt(math.min(state.credits, 4294967295), 32) net.WriteString(state.equippedModel) net.WriteString(state.equippedWeapon) net.WriteUInt(math.min(#owned, 255), 8) for index = 1, math.min(#owned, 255) do net.WriteString(owned[index].itemType) net.WriteString(owned[index].id) end net.WriteBool(ply:IsAdmin()) WriteShopPrices() net.Send(ply) end local function SendNotice(ply, success, message) net.Start("CustomCoopProgressionNotice") net.WriteBool(success) net.WriteString(message) net.Send(ply) end local function RequestAllowed(ply) local now = CurTime() if (requestCooldowns[ply] or 0) > now then return false end requestCooldowns[ply] = now + 0.2 return true end local function BroadcastShopPrices() net.Start("CustomCoopProgressionPricesChanged") WriteShopPrices() net.Broadcast() end local function SetShopPrice(actor, itemType, itemID, requestedPrice) local item = GetCatalogueItem(itemType, itemID) local price = math.floor(tonumber(requestedPrice) or -1) if not item or price < 0 or price > Progression.MaximumShopPrice then if IsValid(actor) then SendNotice(actor, false, "Invalid shop item or price.") end return false end local key = PriceKey(itemType, itemID) shopPrices[key] = price dirtyShopPrices[key] = { itemType = itemType, itemID = itemID, price = price } BroadcastShopPrices() local message = string.format("Set %s to %d credits.", item.name, price) if IsValid(actor) then SendNotice(actor, true, message) else print("[custom_coop progression] " .. message) end return true end local function PlayMusic(ply, item, successMessage) local now = CurTime() if musicBusyUntil > now then SendNotice(ply, false, string.format( "Music is busy. Try again in %d seconds.", math.ceil(musicBusyUntil - now) )) return end if not availableMusic[item.id] then SendNotice(ply, false, "That music file is unavailable on the server.") return end local duration = math.max(tonumber(item.duration) or 0, 1) musicBusyUntil = now + duration + math.max(Progression.MusicCooldownPadding or 0, 0) net.Start("CustomCoopProgressionPlayMusic") net.WriteString(item.id) net.WriteEntity(ply) net.WriteString(ply:Nick()) net.Broadcast() PrintMessage(HUD_PRINTTALK, string.format("[CO-OP] %s is playing %s.", ply:Nick(), item.name)) local playbackTarget = item.spatial and " from your character." or " for everyone." SendNotice(ply, true, successMessage or ("Playing " .. item.name .. playbackTarget)) end local function PurchaseOrEquip(ply, itemType, itemID) local item = GetCatalogueItem(itemType, itemID) if not item then return end if itemType == "weapon" and item.weaponClass ~= "" and not weapons.GetStored(item.weaponClass) then SendNotice(ply, false, "That Workshop weapon is not currently available. No credits were spent.") return end if itemType == "music" and not availableMusic[item.id] then SendNotice(ply, false, "That music file is unavailable. No credits were spent.") return end local state = GetState(ply) if not state then return end local ownedItems = itemType == "model" and state.ownedModels or itemType == "weapon" and state.ownedWeapons or state.ownedMusic local price = GetShopPrice(itemType, item) local alreadyOwned = ownedItems[itemID] == true local previousWeaponID = state.equippedWeapon local adminCreditBypass = false if itemType == "music" and alreadyOwned then PlayMusic(ply, item) SendShop(ply) return end if not alreadyOwned then if state.credits < price then adminCreditBypass = Progression.AdminShopTestingBypass and ply:IsAdmin() if not adminCreditBypass then SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.") return end SendNotice(ply, false, string.format( "You need %d more credits. Admin testing override: no credits will be charged.", price - state.credits )) end if not adminCreditBypass then state.credits = state.credits - price end ownedItems[itemID] = true state.pendingOwnedItems[PriceKey(itemType, itemID)] = { itemType = itemType, itemID = itemID, purchasedAt = os.time() } state.profileDirty = true end if itemType == "music" then SyncPlayer(ply, state) local playbackTarget = item.spatial and " from your character." or " for everyone." local message = adminCreditBypass and ("Admin test-unlocked and playing " .. item.name .. playbackTarget) or ("Purchased and playing " .. item.name .. playbackTarget) PlayMusic(ply, item, message) SendShop(ply) return end if itemType == "model" then state.equippedModel = itemID ApplyModel(ply) else state.equippedWeapon = itemID ApplyWeapon(ply, previousWeaponID) end state.profileDirty = true SyncPlayer(ply, state) local action = alreadyOwned and "Equipped " or adminCreditBypass and "Admin test-unlocked and equipped " or "Purchased and equipped " SendNotice(ply, true, action .. item.name .. ".") SendShop(ply) end local function ResolvePlayerAttacker(attacker, inflictor) if IsValid(attacker) and attacker:IsPlayer() then return attacker end if IsValid(attacker) then local owner = attacker:GetOwner() if IsValid(owner) and owner:IsPlayer() then return owner end if attacker:IsVehicle() then local driver = attacker:GetDriver() if IsValid(driver) and driver:IsPlayer() then return driver end end end if IsValid(inflictor) then local owner = inflictor:GetOwner() if IsValid(owner) and owner:IsPlayer() then return owner end end end local function AwardMonsterKill(ply) local state = GetState(ply) if not state then return end local reward = math.max(math.floor(Progression.KillReward), 1) state.score = state.score + reward state.credits = state.credits + reward state.playerName = ply:Nick() state.profileDirty = true SyncPlayer(ply, state) end local function ChargeForDeath(ply) local state = GetState(ply) if not state then return end local configuredCost = math.max(math.floor(Progression.DeathCreditCost or 0), 0) local charged = math.min(state.credits, configuredCost) if charged <= 0 then SendNotice(ply, false, "You died, but had no credits to lose.") return end state.credits = math.max(state.credits - configuredCost, 0) state.playerName = ply:Nick() state.profileDirty = true SyncPlayer(ply, state) SendNotice(ply, false, string.format( "Death cost %d credit%s. Lifetime Score and rank were not affected.", charged, charged == 1 and "" or "s" )) end hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply) LoadPlayer(ply) timer.Simple(4, function() if not IsValid(ply) then return end ply:ChatPrint("[CO-OP] Kill monsters to earn Score and Credits. Your lifetime Score determines your rank.") ply:ChatPrint("[CO-OP] Press F3 or type !shop to buy permanent models, bonus weapons, and server music.") ply:ChatPrint("[CO-OP] Bought music plays for everyone. Type !musicmute if you do not want to hear it.") ply:ChatPrint("[CO-OP] Dying costs up to " .. Progression.DeathCreditCost .. " Credits, but never reduces Score or rank.") ply:ChatPrint("[CO-OP] Type !points for your totals. Hold TAB to compare lifetime scores.") end) end) hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply) SavePlayerState(ply, playerStates[ply]) playerStates[ply] = nil requestCooldowns[ply] = nil end) hook.Add("ShutDown", "CustomCoopProgressionSaveOnShutdown", function() FlushDirtyProgression() end) -- Returning true prevents Sandbox's unrestricted cl_playermodel selection from -- bypassing the shop. The equipped, server-owned model is the only source. hook.Add("PlayerSetModel", "CustomCoopProgressionApplyModel", function(ply) ApplyModel(ply) return true end) hook.Add("PlayerSpawn", "CustomCoopProgressionApplyLoadout", function(ply) ply.CustomCoopGrantedWeapon = nil timer.Simple(0.5, function() if IsValid(ply) then ApplyWeapon(ply) end end) -- mapperstuff.lua restores its carried model two seconds after some map -- changes; reassert the selected shop model immediately afterward. timer.Simple(2.2, function() if IsValid(ply) then ApplyModel(ply) end end) end) hook.Add("PlayerSpawnedNPC", "CustomCoopProgressionExcludeSpawnedNPC", function(_, npc) if IsValid(npc) then npc.CustomCoopNoScore = true end end) hook.Add("OnNPCKilled", "CustomCoopProgressionMonsterKilled", function(npc, attacker, inflictor) if not IsValid(npc) or npc.CustomCoopNoScore or npc.CustomCoopScoreAwarded then return end if not Progression.MonsterClasses[npc:GetClass()] then return end local creator = npc:GetCreator() if IsValid(creator) and creator:IsPlayer() then return end local ply = ResolvePlayerAttacker(attacker, inflictor) if not IsValid(ply) or ply:IsBot() then return end npc.CustomCoopScoreAwarded = true AwardMonsterKill(ply) end) hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim) if not IsValid(victim) or victim:IsBot() then return end ChargeForDeath(victim) end) hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text) local command = string.Trim(string.lower(text or "")) if command == "!shop" or command == "/shop" then SendShop(ply) return "" end if command == "!points" or command == "/points" then local state = GetState(ply) if state then ply:ChatPrint(string.format( "[CO-OP] Score: %d | Credits: %d | Rank: %s", state.score, state.credits, Progression.GetRank(state.score) )) end return "" end if command == "!musicmute" or command == "/musicmute" then net.Start("CustomCoopProgressionToggleMusicMute") net.Send(ply) return "" end end) net.Receive("CustomCoopProgressionRequestShop", function(_, ply) if not RequestAllowed(ply) then return end SendShop(ply) end) net.Receive("CustomCoopProgressionSelectItem", function(_, ply) if not RequestAllowed(ply) then return end local itemType = net.ReadString() local itemID = net.ReadString() if #itemType > 16 or #itemID > 64 then return end PurchaseOrEquip(ply, itemType, itemID) end) net.Receive("CustomCoopProgressionSetPrice", function(_, ply) if not RequestAllowed(ply) then return end if not ply:IsAdmin() then SendNotice(ply, false, "Only server administrators can change shop prices.") return end local itemType = net.ReadString() local itemID = net.ReadString() local price = net.ReadUInt(32) if #itemType > 16 or #itemID > 64 then return end SetShopPrice(ply, itemType, itemID, price) end) concommand.Add("coop_setprice", function(ply, _, args) if IsValid(ply) and not ply:IsAdmin() then SendNotice(ply, false, "Only server administrators can change shop prices.") return end local itemType = string.lower(args[1] or "") local itemID = string.lower(args[2] or "") local price = tonumber(args[3]) if not GetCatalogueItem(itemType, itemID) or not price then local usage = "Usage: coop_setprice " if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end return end SetShopPrice(ply, itemType, itemID, price) end)