Implement shop and score system

This commit is contained in:
2026-09-06 16:38:42 +02:00
parent 062c358c23
commit 2e7cd867d7
7 changed files with 1065 additions and 7 deletions
+29
View File
@@ -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.
+2 -1
View File
@@ -1 +1,2 @@
include( "shared.lua" )
include( "shared.lua" )
include( "cl_progression.lua" )
+347
View File
@@ -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)
+6 -3
View File
@@ -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" )
+230
View File
@@ -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
+4 -3
View File
@@ -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()
+447
View File
@@ -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)