Files
garrysmod-coop/gamemode/sv_progression.lua
T

787 lines
26 KiB
Lua

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 <model|weapon|music> <item_id> <credits>"
if IsValid(ply) then ply:ChatPrint("[CO-OP] " .. usage) else print("[CO-OP] " .. usage) end
return
end
SetShopPrice(ply, itemType, itemID, price)
end)