Files
garrysmod-coop/gamemode/sv_progression.lua
T

755 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 musicBusyUntil = 0
local totalsSaveInterval = CreateConVar(
"gc_progression_save_interval",
"60",
FCVAR_ARCHIVE,
"Seconds between write-behind saves of changed co-op Score and Credits.",
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
resource.AddFile(soundPath)
else
ErrorNoHalt("[custom_coop progression] Missing configured music file " .. soundPath .. "\n")
end
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 = {},
totalsDirty = 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 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()
state.steamID64 = steamID64
state.playerName = ply:Nick()
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
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
-- Garry's Mod's built-in SQLite API runs on the game thread. Keep the
-- frequently changing totals in memory and write their latest absolute values
-- periodically instead of issuing a disk-backed query for every NPC death.
local function SavePlayerTotals(ply, state)
if not state or not state.totalsDirty 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 result = Query(string.format(
"UPDATE custom_coop_players SET lifetime_score = %d, credits = %d, player_name = %s " ..
"WHERE steamid64 = %s",
math.max(math.floor(state.score or 0), 0),
math.max(math.floor(state.credits or 0), 0),
SQLString(state.playerName),
SQLString(steamID64)
), "could not save player totals")
if result == false then return false end
state.totalsDirty = false
return true
end
local function FlushDirtyPlayerTotals()
for ply, state in pairs(playerStates) do
SavePlayerTotals(ply, state)
end
end
local function StartTotalsSaveTimer()
timer.Create(
"CustomCoopProgressionSaveTotals",
math.max(totalsSaveInterval:GetFloat(), 10),
0,
FlushDirtyPlayerTotals
)
end
StartTotalsSaveTimer()
cvars.AddChangeCallback("gc_progression_save_interval", function()
StartTotalsSaveTimer()
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
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
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 result = Query(string.format(
"INSERT OR REPLACE INTO custom_coop_shop_prices (item_type, item_id, price) VALUES (%s, %s, %d)",
SQLString(itemType), SQLString(itemID), price
), "could not save shop price")
if result == false then
if IsValid(actor) then SendNotice(actor, false, "The new price could not be saved.") end
return false
end
shopPrices[PriceKey(itemType, itemID)] = 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 file.Exists("sound/" .. item.sound, "GAME") 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 file.Exists("sound/" .. item.sound, "GAME") 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 or price == 0
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
-- The database may be up to one save interval behind the in-memory
-- balance. Bring it current before performing the durable purchase.
if not adminCreditBypass and not SavePlayerTotals(ply, state) then
SendNotice(ply, false, "Your credits could not be saved, so nothing was purchased.")
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 = true
if insertResult ~= false and not adminCreditBypass then
updateResult = Query(string.format(
"UPDATE custom_coop_players SET credits = credits - %d WHERE steamid64 = %s AND credits >= %d",
price, SQLString(ply:SteamID64()), price
), "could not debit purchase")
end
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()
if not adminCreditBypass then state.credits = state.credits - price end
ownedItems[itemID] = 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
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)
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.totalsDirty = 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.totalsDirty = 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)
SavePlayerTotals(ply, playerStates[ply])
playerStates[ply] = nil
requestCooldowns[ply] = nil
end)
hook.Add("ShutDown", "CustomCoopProgressionSaveOnShutdown", function()
FlushDirtyPlayerTotals()
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)