From 2e7cd867d735193d546c4c531fb9e44d23b3634d Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 6 Sep 2026 16:38:42 +0200 Subject: [PATCH] Implement shop and score system --- README.md | 29 +++ gamemode/cl_init.lua | 3 +- gamemode/cl_progression.lua | 347 +++++++++++++++++++++++++ gamemode/init.lua | 9 +- gamemode/progression_shared.lua | 230 ++++++++++++++++ gamemode/shared.lua | 7 +- gamemode/sv_progression.lua | 447 ++++++++++++++++++++++++++++++++ 7 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 gamemode/cl_progression.lua create mode 100644 gamemode/progression_shared.lua create mode 100644 gamemode/sv_progression.lua diff --git a/README.md b/README.md index 62f55fb..d640882 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # garrysmod-coop +## Persistent progression and rewards shop + +Players earn one Lifetime Score and one spendable Credit for every eligible +monster kill. Lifetime Score determines rank and never decreases. Shop +purchases deduct Credits only. Data is committed immediately to Garry's Mod's +SQLite database at `garrysmod/sv.db`. + +Dying deducts up to five Credits, stopping at zero. It never deducts Lifetime +Score and therefore cannot lower a player's rank. + +Player controls: + +- `F3`, chat `!shop`, or client console `coop_shop`: open the rewards shop +- Chat `!points`: print personal Score, Credits, and rank +- Hold `TAB`: compare all connected players' Lifetime Scores and ranks + +The shop contains persistent Half-Life 2 player models and permanent bonus +weapon unlocks. Only one bonus weapon is equipped at a time. The server checks +all kills, balances, prices, ownership, and equipment; clients cannot award +points or choose a price. + +Configuration is in `gamemode/progression_shared.lua`: + +- `Progression.Ranks`: rank names and Lifetime Score thresholds +- `Progression.Models`: player-model catalogue and prices +- `Progression.Weapons`: bonus-weapon catalogue and prices +- `Progression.MonsterClasses`: NPC classes that award points + +Player-spawned NPCs and unknown/friendly NPC classes do not award points. diff --git a/gamemode/cl_init.lua b/gamemode/cl_init.lua index 21cdfc5..c4378d5 100644 --- a/gamemode/cl_init.lua +++ b/gamemode/cl_init.lua @@ -1 +1,2 @@ -include( "shared.lua" ) \ No newline at end of file +include( "shared.lua" ) +include( "cl_progression.lua" ) diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua new file mode 100644 index 0000000..b76a0cb --- /dev/null +++ b/gamemode/cl_progression.lua @@ -0,0 +1,347 @@ +if not CLIENT then return end + +local Progression = CustomCoopProgression +local shopFrame +local scoreboardFrame +local shopState = { + score = 0, + credits = 0, + equippedModel = Progression.DefaultModelID, + equippedWeapon = Progression.DefaultWeaponID, + ownedModels = {}, + ownedWeapons = {} +} + +surface.CreateFont("CustomCoopProgressionTitle", { + font = "Roboto", + size = 28, + weight = 700 +}) + +surface.CreateFont("CustomCoopProgressionHeading", { + font = "Roboto", + size = 20, + weight = 700 +}) + +surface.CreateFont("CustomCoopProgressionBody", { + font = "Roboto", + size = 17, + weight = 500 +}) + +surface.CreateFont("CustomCoopProgressionSmall", { + font = "Roboto", + size = 15, + weight = 400 +}) + +local colors = { + background = Color(20, 24, 29, 245), + panel = Color(31, 37, 44, 250), + panelHover = Color(39, 47, 56, 250), + accent = Color(75, 176, 255), + success = Color(92, 205, 122), + warning = Color(244, 190, 74), + text = Color(240, 244, 248), + muted = Color(170, 181, 191) +} + +local function RequestShop() + net.Start("CustomCoopProgressionRequestShop") + net.SendToServer() +end + +concommand.Add("coop_shop", RequestShop) + +hook.Add("ShowSpare1", "CustomCoopProgressionF3", function() + RequestShop() + return true +end) + +-- Some client configurations do not route F3 through ShowSpare1. This fallback +-- keeps the advertised key reliable while the server-side rate limit absorbs a +-- possible duplicate request. +local nextF3Request = 0 +hook.Add("PlayerButtonDown", "CustomCoopProgressionF3Fallback", function(ply, button) + if ply ~= LocalPlayer() or button ~= KEY_F3 or RealTime() < nextF3Request then return end + nextF3Request = RealTime() + 0.5 + RequestShop() +end) + +local function SelectItem(itemType, itemID) + net.Start("CustomCoopProgressionSelectItem") + net.WriteString(itemType) + net.WriteString(itemID) + net.SendToServer() +end + +local function AddText(parent, text, font, color) + local label = vgui.Create("DLabel", parent) + label:SetText(text) + label:SetFont(font) + label:SetTextColor(color) + label:SetWrap(true) + return label +end + +local function AddItemCard(layout, itemType, item) + local isModel = itemType == "model" + local owned = isModel and shopState.ownedModels[item.id] or shopState.ownedWeapons[item.id] + local equipped = isModel and shopState.equippedModel == item.id or shopState.equippedWeapon == item.id + + local card = layout:Add("DPanel") + card:SetSize(252, 292) + card.Paint = function(self, width, height) + local color = self:IsHovered() and colors.panelHover or colors.panel + draw.RoundedBox(8, 0, 0, width, height, color) + if equipped then + surface.SetDrawColor(colors.success) + surface.DrawOutlinedRect(0, 0, width, height, 2) + end + end + + local previewModel = item.model + if not isModel and item.weaponClass ~= "" then + local storedWeapon = weapons.GetStored(item.weaponClass) + previewModel = storedWeapon and storedWeapon.WorldModel + end + + if previewModel and previewModel ~= "" then + local preview = vgui.Create("DModelPanel", card) + preview:SetPos(8, 8) + preview:SetSize(236, 166) + preview:SetModel(previewModel) + preview:SetFOV(isModel and 30 or 38) + + if isModel then + preview:SetCamPos(Vector(68, 0, 64)) + preview:SetLookAt(Vector(0, 0, 64)) + else + preview:SetCamPos(Vector(45, 45, 28)) + preview:SetLookAt(Vector(0, 0, 0)) + end + + preview.LayoutEntity = function(_, entity) + if IsValid(entity) then entity:SetAngles(Angle(0, RealTime() * 18 % 360, 0)) end + end + else + local noPreview = AddText(card, itemType == "weapon" and "MAP LOADOUT" or "NO PREVIEW", "CustomCoopProgressionHeading", colors.muted) + noPreview:SetContentAlignment(5) + noPreview:SetPos(8, 8) + noPreview:SetSize(236, 166) + end + + local title = AddText(card, item.name, "CustomCoopProgressionHeading", colors.text) + title:SetPos(12, 177) + title:SetSize(228, 25) + + local description = AddText(card, item.description, "CustomCoopProgressionSmall", colors.muted) + description:SetPos(12, 202) + description:SetSize(228, 38) + + local priceText = item.price == 0 and "FREE" or (item.price .. " credits") + local price = AddText(card, priceText, "CustomCoopProgressionBody", item.price <= shopState.credits and colors.success or colors.warning) + price:SetPos(12, 239) + price:SetSize(100, 22) + + local button = vgui.Create("DButton", card) + button:SetPos(112, 244) + button:SetSize(128, 36) + button:SetFont("CustomCoopProgressionBody") + button:SetText(equipped and "Equipped" or (owned or item.price == 0) and "Equip" or "Buy") + button:SetEnabled(not equipped) + button.DoClick = function() + button:SetEnabled(false) + button:SetText("Please wait...") + SelectItem(itemType, item.id) + end +end + +local function CreateCataloguePage(sheet, title, itemType, items) + local page = vgui.Create("DPanel", sheet) + page:DockPadding(10, 10, 10, 10) + page.Paint = function(_, width, height) + draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35)) + end + + local scroll = vgui.Create("DScrollPanel", page) + scroll:Dock(FILL) + + local layout = vgui.Create("DIconLayout", scroll) + layout:Dock(TOP) + layout:SetSpaceX(12) + layout:SetSpaceY(12) + layout:SetTall(math.ceil(#items / 3) * 304) + + for _, item in ipairs(items) do + AddItemCard(layout, itemType, item) + end + + sheet:AddSheet(title, page) +end + +local function OpenShop() + if IsValid(shopFrame) then shopFrame:Remove() end + + shopFrame = vgui.Create("DFrame") + shopFrame:SetSize(math.min(ScrW() - 40, 850), math.min(ScrH() - 40, 650)) + shopFrame:Center() + shopFrame:SetTitle("") + shopFrame:ShowCloseButton(true) + shopFrame:MakePopup() + shopFrame.Paint = function(_, width, height) + draw.RoundedBox(10, 0, 0, width, height, colors.background) + draw.SimpleText("CO-OP REWARDS SHOP", "CustomCoopProgressionTitle", 18, 16, colors.text) + end + + local summary = AddText( + shopFrame, + string.format( + "Lifetime Score: %d Credits: %d Rank: %s", + shopState.score, + shopState.credits, + Progression.GetRank(shopState.score) + ), + "CustomCoopProgressionBody", + colors.accent + ) + summary:SetPos(19, 52) + summary:SetSize(shopFrame:GetWide() - 38, 25) + + local help = AddText( + shopFrame, + "Kills add to both totals. Purchases and deaths spend Credits only; Lifetime Score and rank never decrease.", + "CustomCoopProgressionSmall", + colors.muted + ) + help:SetPos(19, 77) + help:SetSize(shopFrame:GetWide() - 38, 20) + + local sheet = vgui.Create("DPropertySheet", shopFrame) + sheet:SetPos(14, 104) + sheet:SetSize(shopFrame:GetWide() - 28, shopFrame:GetTall() - 118) + + CreateCataloguePage(sheet, "Player Models", "model", Progression.Models) + CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons) +end + +net.Receive("CustomCoopProgressionOpenShop", function() + shopState.score = net.ReadUInt(32) + shopState.credits = net.ReadUInt(32) + shopState.equippedModel = net.ReadString() + shopState.equippedWeapon = net.ReadString() + shopState.ownedModels = { [Progression.DefaultModelID] = true } + shopState.ownedWeapons = { [Progression.DefaultWeaponID] = true } + + for _ = 1, net.ReadUInt(8) do + local itemType = net.ReadString() + local itemID = net.ReadString() + if itemType == "model" then + shopState.ownedModels[itemID] = true + elseif itemType == "weapon" then + shopState.ownedWeapons[itemID] = true + end + end + + OpenShop() +end) + +net.Receive("CustomCoopProgressionNotice", function() + local success = net.ReadBool() + local message = net.ReadString() + notification.AddLegacy(message, success and NOTIFY_GENERIC or NOTIFY_ERROR, 4) + surface.PlaySound(success and "buttons/button15.wav" or "buttons/button10.wav") +end) + +local previousScore +local gainedScore = 0 +local gainExpires = 0 + +hook.Add("HUDPaint", "CustomCoopProgressionHUD", function() + local ply = LocalPlayer() + if not IsValid(ply) then return end + + local score = ply:GetNW2Int("CustomCoopScore", -1) + if score < 0 then return end + + if previousScore and score > previousScore then + gainedScore = score - previousScore + gainExpires = CurTime() + 2 + surface.PlaySound("buttons/button14.wav") + end + previousScore = score + + local credits = ply:GetNW2Int("CustomCoopCredits", 0) + local rank = ply:GetNW2String("CustomCoopRank", Progression.GetRank(score)) + local width, height = 288, 66 + local x, y = ScrW() - width - 18, 18 + + draw.RoundedBox(8, x, y, width, height, colors.background) + draw.SimpleText(rank, "CustomCoopProgressionHeading", x + 12, y + 9, colors.accent) + draw.SimpleText("Score " .. score, "CustomCoopProgressionBody", x + 12, y + 37, colors.text) + draw.SimpleText("Credits " .. credits, "CustomCoopProgressionBody", x + 151, y + 37, colors.text) + + if CurTime() < gainExpires then + draw.SimpleText("+" .. gainedScore, "CustomCoopProgressionHeading", x - 10, y + height / 2, colors.success, TEXT_ALIGN_RIGHT, TEXT_ALIGN_CENTER) + end +end) + +local function RefreshScoreboard(list) + if not IsValid(list) then return end + list:Clear() + + local players = player.GetAll() + table.sort(players, function(left, right) + return left:GetNW2Int("CustomCoopScore", 0) > right:GetNW2Int("CustomCoopScore", 0) + end) + + for _, ply in ipairs(players) do + list:AddLine( + ply:Nick(), + ply:GetNW2String("CustomCoopRank", "Newbie"), + ply:GetNW2Int("CustomCoopScore", 0), + ply:Ping() + ) + end +end + +hook.Add("ScoreboardShow", "CustomCoopProgressionScoreboardShow", function() + if IsValid(scoreboardFrame) then return true end + + scoreboardFrame = vgui.Create("DFrame") + scoreboardFrame:SetSize(math.min(ScrW() - 60, 760), math.min(ScrH() - 80, 600)) + scoreboardFrame:Center() + scoreboardFrame:SetTitle("CO-OP LIFETIME SCORES") + scoreboardFrame:ShowCloseButton(false) + scoreboardFrame:SetDraggable(false) + scoreboardFrame:SetKeyboardInputEnabled(false) + scoreboardFrame:SetMouseInputEnabled(false) + scoreboardFrame.Paint = function(_, width, height) + draw.RoundedBox(8, 0, 0, width, height, colors.background) + end + + local list = vgui.Create("DListView", scoreboardFrame) + list:Dock(FILL) + list:DockMargin(10, 8, 10, 10) + list:AddColumn("Player") + list:AddColumn("Rank") + list:AddColumn("Lifetime Score") + list:AddColumn("Ping") + RefreshScoreboard(list) + + scoreboardFrame.NextRefresh = RealTime() + 0.5 + scoreboardFrame.Think = function(self) + if RealTime() < self.NextRefresh then return end + self.NextRefresh = RealTime() + 0.5 + RefreshScoreboard(list) + end + + return true +end) + +hook.Add("ScoreboardHide", "CustomCoopProgressionScoreboardHide", function() + if IsValid(scoreboardFrame) then scoreboardFrame:Remove() end + scoreboardFrame = nil + return true +end) diff --git a/gamemode/init.lua b/gamemode/init.lua index ad0257c..f2ad3e2 100644 --- a/gamemode/init.lua +++ b/gamemode/init.lua @@ -1,6 +1,8 @@ -AddCSLuaFile( "cl_init.lua" ) -AddCSLuaFile( "shared.lua" ) -AddCSLuaFile( "levels.lua" ) +AddCSLuaFile( "cl_init.lua" ) +AddCSLuaFile( "shared.lua" ) +AddCSLuaFile( "levels.lua" ) +AddCSLuaFile( "progression_shared.lua" ) +AddCSLuaFile( "cl_progression.lua" ) include( "spawninit.lua" ) include( "weaponsystem.lua" ) include( "chargersystem.lua" ) @@ -16,5 +18,6 @@ include( "mapperstuff.lua" ) include( "noheadcrabs.lua" ) include( "shared.lua" ) include( "maprotation.lua" ) +include( "sv_progression.lua" ) resource.AddFile( "sound/weapons/universal/pap.wav" ) diff --git a/gamemode/progression_shared.lua b/gamemode/progression_shared.lua new file mode 100644 index 0000000..f9cf6d1 --- /dev/null +++ b/gamemode/progression_shared.lua @@ -0,0 +1,230 @@ +-- Shared, server-authoritative progression configuration. +-- Prices and rewards are only acted upon by sv_progression.lua. Clients receive +-- this file so the shop can render the same catalogue without duplicating it. + +CustomCoopProgression = CustomCoopProgression or {} +local Progression = CustomCoopProgression + +Progression.KillReward = 1 +Progression.DeathCreditCost = 5 +Progression.DefaultModelID = "kleiner" +Progression.DefaultWeaponID = "none" + +Progression.Ranks = { + { score = 0, name = "Newbie" }, + { score = 10, name = "Survivor" }, + { score = 50, name = "Hunter" }, + { score = 150, name = "Slayer" }, + { score = 500, name = "Veteran" }, + { score = 1500, name = "Exterminator" }, + { score = 5000, name = "Legend" } +} + +Progression.Models = { + { + id = "kleiner", + name = "Dr. Kleiner", + model = "models/player/kleiner.mdl", + price = 0, + description = "The free default model." + }, + { + id = "male_citizen", + name = "Male Citizen", + model = "models/player/group01/male_07.mdl", + price = 5, + description = "A survivor from City 17." + }, + { + id = "female_citizen", + name = "Female Citizen", + model = "models/player/group01/female_04.mdl", + price = 5, + description = "A survivor from City 17." + }, + { + id = "barney", + name = "Barney Calhoun", + model = "models/player/barney.mdl", + price = 10, + description = "Civil Protection's least loyal officer." + }, + { + id = "alyx", + name = "Alyx Vance", + model = "models/player/alyx.mdl", + price = 15, + description = "Resistance fighter and engineer." + }, + { + id = "monk", + name = "Father Grigori", + model = "models/player/monk.mdl", + price = 25, + description = "A familiar face from Ravenholm." + }, + { + id = "combine", + name = "Combine Soldier", + model = "models/player/combine_soldier.mdl", + price = 50, + description = "Standard Combine infantry." + }, + { + id = "combine_elite", + name = "Combine Elite", + model = "models/player/combine_super_soldier.mdl", + price = 100, + description = "An elite Overwatch soldier." + } +} + +Progression.Weapons = { + { + id = "none", + name = "No Bonus Weapon", + weaponClass = "", + price = 0, + description = "Use only weapons supplied by the map." + }, + { + id = "glock", + name = "Glock", + weaponClass = "coop_glock", + price = 15, + description = "A reliable permanent sidearm unlock." + }, + { + id = "mp5", + name = "MP5", + weaponClass = "coop_mp5", + price = 40, + description = "A permanent compact SMG unlock." + }, + { + id = "pumpshotgun", + name = "Pump Shotgun", + weaponClass = "coop_pumpshotgun", + price = 75, + description = "A permanent close-range weapon unlock." + }, + { + id = "m4", + name = "M4", + weaponClass = "coop_m4", + price = 125, + description = "A permanent rifle unlock." + }, + { + id = "nyan_gun", + name = "Nyan Gun", + weaponClass = "weapon_nyangun", + price = 1000, + description = "Rainbows, music and extremely colourful destruction." + }, + { + id = "lightsaber", + name = "Lightsaber", + weaponClass = "weapon_lightsaber", + price = 1000, + description = "A permanent weapon from a galaxy far, far away." + }, + { + id = "orbital_friendship_cannon", + name = "Orbital Friendship Cannon", + weaponClass = "orbital_friendship_cannon", + price = 5000, + description = "Deliver friendship from orbit." + }, + { + id = "sexyness", + name = "Sexyness (2-bit)", + weaponClass = "pist_weagon", + price = 100000, + description = "The absurdly powerful Ultimate Admin Gun." + } +} + +-- Only these combat NPC classes award points. Keeping an allow-list avoids +-- rewards for friendly citizens, scripted targets and manually invented entity +-- classes. Add a class here if a future coop map uses another real monster. +Progression.MonsterClasses = { + npc_advisor = true, + npc_antlion = true, + npc_antlionguard = true, + npc_antlion_worker = true, + npc_barnacle = true, + npc_clawscanner = true, + npc_combine_s = true, + npc_combinedropship = true, + npc_combinegunship = true, + npc_cscanner = true, + npc_fastzombie = true, + npc_fastzombie_torso = true, + npc_headcrab = true, + npc_headcrab_black = true, + npc_headcrab_fast = true, + npc_headcrab_poison = true, + npc_helicopter = true, + npc_hunter = true, + npc_manhack = true, + npc_metropolice = true, + npc_poisonzombie = true, + npc_rollermine = true, + npc_sniper = true, + npc_stalker = true, + npc_strider = true, + npc_turret_ceiling = true, + npc_turret_floor = true, + npc_turret_ground = true, + npc_vortigaunt = true, + npc_zombie = true, + npc_zombie_torso = true, + npc_zombine = true, + + monster_alien_controller = true, + monster_alien_grunt = true, + monster_alien_slave = true, + monster_apache = true, + monster_barnacle = true, + monster_bigmomma = true, + monster_bullchicken = true, + monster_gargantua = true, + monster_headcrab = true, + monster_houndeye = true, + monster_human_assassin = true, + monster_human_grunt = true, + monster_ichthyosaur = true, + monster_leech = true, + monster_nihilanth = true, + monster_osprey = true, + monster_snark = true, + monster_tentacle = true, + monster_zombie = true +} + +local function FindByID(items, id) + for _, item in ipairs(items) do + if item.id == id then return item end + end +end + +function Progression.GetModel(id) + return FindByID(Progression.Models, id) +end + +function Progression.GetWeapon(id) + return FindByID(Progression.Weapons, id) +end + +function Progression.GetRank(score) + local result = Progression.Ranks[1].name + score = math.max(tonumber(score) or 0, 0) + + for _, rank in ipairs(Progression.Ranks) do + if score < rank.score then break end + result = rank.name + end + + return result +end diff --git a/gamemode/shared.lua b/gamemode/shared.lua index 43c632b..0c76634 100644 --- a/gamemode/shared.lua +++ b/gamemode/shared.lua @@ -3,9 +3,10 @@ GM.Author = "0xFEEDC0DE64" GM.Email = "daniel@brunner.ninja" GM.Website = "brunner.ninja" -DeriveGamemode("sandbox") - -include( "levels.lua" ) +DeriveGamemode("sandbox") + +include( "levels.lua" ) +include( "progression_shared.lua" ) if (SERVER) then function ChangeRandomLevel() diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua new file mode 100644 index 0000000..5cb8933 --- /dev/null +++ b/gamemode/sv_progression.lua @@ -0,0 +1,447 @@ +if not SERVER then return end + +local Progression = CustomCoopProgression +local playerStates = setmetatable({}, { __mode = "k" }) +local requestCooldowns = setmetatable({}, { __mode = "k" }) + +util.AddNetworkString("CustomCoopProgressionOpenShop") +util.AddNetworkString("CustomCoopProgressionRequestShop") +util.AddNetworkString("CustomCoopProgressionSelectItem") +util.AddNetworkString("CustomCoopProgressionNotice") + +local function Query(query, context) + local result = sql.Query(query) + 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") +end + +CreateTables() + +local function EmptyState() + return { + score = 0, + credits = 0, + equippedModel = Progression.DefaultModelID, + equippedWeapon = Progression.DefaultWeaponID, + ownedModels = { [Progression.DefaultModelID] = true }, + ownedWeapons = { [Progression.DefaultWeaponID] = true } + } +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 EnsurePlayerRow(ply) + local steamID64 = ply:SteamID64() + if not steamID64 or steamID64 == "" then return false end + + return Query(string.format( + "INSERT OR IGNORE INTO custom_coop_players " .. + "(steamid64, player_name, equipped_model, equipped_weapon) VALUES (%s, %s, %s, %s)", + SQLString(steamID64), + SQLString(ply:Nick()), + SQLString(Progression.DefaultModelID), + SQLString(Progression.DefaultWeaponID) + ), "could not create player row") ~= false +end + +local function LoadPlayer(ply) + if not IsValid(ply) or not EnsurePlayerRow(ply) then return end + + local steamID64 = ply:SteamID64() + 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() + 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 + end + end + end + + if not Progression.GetModel(state.equippedModel) or not state.ownedModels[state.equippedModel] then + state.equippedModel = Progression.DefaultModelID + end + if not Progression.GetWeapon(state.equippedWeapon) or not state.ownedWeapons[state.equippedWeapon] then + state.equippedWeapon = Progression.DefaultWeaponID + end + + playerStates[ply] = state + SyncPlayer(ply, state) + + Query(string.format( + "UPDATE custom_coop_players SET player_name = %s, equipped_model = %s, equipped_weapon = %s " .. + "WHERE steamid64 = %s", + SQLString(ply:Nick()), + SQLString(state.equippedModel), + SQLString(state.equippedWeapon), + SQLString(steamID64) + ), "could not refresh player row") + + return state +end + +local function GetState(ply) + return playerStates[ply] or LoadPlayer(ply) +end + +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 + + util.PrecacheModel(item.model) + 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 + + 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.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 PurchaseOrEquip(ply, itemType, itemID) + if itemType ~= "model" and itemType ~= "weapon" then return end + + local item = itemType == "model" and Progression.GetModel(itemID) or Progression.GetWeapon(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 + + local state = GetState(ply) + if not state then return end + + local ownedItems = itemType == "model" and state.ownedModels or state.ownedWeapons + local alreadyOwned = ownedItems[itemID] == true or item.price == 0 + local previousWeaponID = state.equippedWeapon + + if not alreadyOwned then + if state.credits < item.price then + SendNotice(ply, false, "You need " .. (item.price - state.credits) .. " more credits.") + return + end + + sql.Begin() + local insertResult = Query(string.format( + "INSERT INTO custom_coop_owned_items (steamid64, item_type, item_id, purchased_at) " .. + "VALUES (%s, %s, %s, %d)", + SQLString(ply:SteamID64()), SQLString(itemType), SQLString(itemID), os.time() + ), "could not store purchase") + + local updateResult = insertResult ~= false and Query(string.format( + "UPDATE custom_coop_players SET credits = credits - %d WHERE steamid64 = %s AND credits >= %d", + item.price, SQLString(ply:SteamID64()), item.price + ), "could not debit purchase") + + if insertResult == false or updateResult == false then + sql.Query("ROLLBACK") + SendNotice(ply, false, "The database rejected this purchase. No credits were spent.") + return + end + sql.Commit() + + state.credits = state.credits - item.price + ownedItems[itemID] = true + end + + local column = itemType == "model" and "equipped_model" or "equipped_weapon" + local equipResult = Query(string.format( + "UPDATE custom_coop_players SET %s = %s WHERE steamid64 = %s", + column, SQLString(itemID), SQLString(ply:SteamID64()) + ), "could not equip item") + + if equipResult == false then + SendNotice(ply, false, "The item was purchased, but could not be equipped yet.") + SyncPlayer(ply, state) + SendShop(ply) + return + end + + if itemType == "model" then + state.equippedModel = itemID + ApplyModel(ply) + else + state.equippedWeapon = itemID + ApplyWeapon(ply, previousWeaponID) + end + + SyncPlayer(ply, state) + SendNotice(ply, true, (alreadyOwned and "Equipped " or "Purchased and equipped ") .. 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) + local result = Query(string.format( + "UPDATE custom_coop_players SET lifetime_score = lifetime_score + %d, " .. + "credits = credits + %d, player_name = %s WHERE steamid64 = %s", + reward, reward, SQLString(ply:Nick()), SQLString(ply:SteamID64()) + ), "could not save monster reward") + + if result == false then return end + + state.score = state.score + reward + state.credits = state.credits + reward + 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 + + local result = Query(string.format( + "UPDATE custom_coop_players SET credits = MAX(credits - %d, 0) WHERE steamid64 = %s", + configuredCost, SQLString(ply:SteamID64()) + ), "could not save death penalty") + + if result == false then return end + + state.credits = math.max(state.credits - configuredCost, 0) + 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 spend Credits on permanent models and bonus weapons.") + 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) + playerStates[ply] = nil + requestCooldowns[ply] = nil +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 +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)