From e3bbd90be45f7c2789cc677dd67c620859f30831 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 6 Sep 2026 18:23:53 +0200 Subject: [PATCH] Defer shop persistence writes --- gamemode/sv_progression.lua | 242 ++++++++++++++++++++---------------- 1 file changed, 137 insertions(+), 105 deletions(-) diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 463ab51..110f851 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -4,12 +4,14 @@ 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 Score and Credits.", + "Seconds between write-behind saves of changed co-op progression data.", 10, 3600 ) @@ -88,12 +90,19 @@ local catalogues = { 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 @@ -162,7 +171,8 @@ local function EmptyState() ownedModels = { [Progression.DefaultModelID] = true }, ownedWeapons = { [Progression.DefaultWeaponID] = true }, ownedMusic = {}, - totalsDirty = false + pendingOwnedItems = {}, + profileDirty = false } end @@ -174,24 +184,12 @@ local function SyncPlayer(ply, state) 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 + 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", @@ -201,6 +199,7 @@ local function LoadPlayer(ply) 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) @@ -227,23 +226,16 @@ local function LoadPlayer(ply) 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) - 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 @@ -251,11 +243,14 @@ 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 +-- 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 @@ -264,38 +259,114 @@ local function SavePlayerTotals(ply, state) state.playerName = ply:Nick() end + local saveStartedAt = SysTime() + sql.Begin() + local result = Query(string.format( - "UPDATE custom_coop_players SET lifetime_score = %d, credits = %d, player_name = %s " .. - "WHERE steamid64 = %s", + "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.playerName), - SQLString(steamID64) - ), "could not save player totals") + SQLString(state.equippedModel), + SQLString(state.equippedWeapon) + ), "could not save player profile") - if result == false then return false end - state.totalsDirty = false + 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 FlushDirtyPlayerTotals() - for ply, state in pairs(playerStates) do - SavePlayerTotals(ply, state) +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 StartTotalsSaveTimer() +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, - FlushDirtyPlayerTotals + FlushDirtyProgression ) end -StartTotalsSaveTimer() +StartProgressionSaveTimer() cvars.AddChangeCallback("gc_progression_save_interval", function() - StartTotalsSaveTimer() + StartProgressionSaveTimer() end, "CustomCoopProgressionSaveInterval") local function ApplyModel(ply) @@ -304,7 +375,6 @@ local function ApplyModel(ply) 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 @@ -392,16 +462,13 @@ local function SetShopPrice(actor, itemType, itemID, requestedPrice) 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 + 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) @@ -423,7 +490,7 @@ local function PlayMusic(ply, item, successMessage) return end - if not file.Exists("sound/" .. item.sound, "GAME") then + if not availableMusic[item.id] then SendNotice(ply, false, "That music file is unavailable on the server.") return end @@ -451,7 +518,7 @@ local function PurchaseOrEquip(ply, itemType, itemID) return end - if itemType == "music" and not file.Exists("sound/" .. item.sound, "GAME") then + if itemType == "music" and not availableMusic[item.id] then SendNotice(ply, false, "That music file is unavailable. No credits were spent.") return end @@ -463,7 +530,7 @@ local function PurchaseOrEquip(ply, itemType, itemID) or itemType == "weapon" and state.ownedWeapons or state.ownedMusic local price = GetShopPrice(itemType, item) - local alreadyOwned = ownedItems[itemID] == true or price == 0 + local alreadyOwned = ownedItems[itemID] == true local previousWeaponID = state.equippedWeapon local adminCreditBypass = false @@ -487,37 +554,14 @@ local function PurchaseOrEquip(ply, itemType, itemID) )) 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 + state.pendingOwnedItems[PriceKey(itemType, itemID)] = { + itemType = itemType, + itemID = itemID, + purchasedAt = os.time() + } + state.profileDirty = true end if itemType == "music" then @@ -531,19 +575,6 @@ local function PurchaseOrEquip(ply, itemType, itemID) 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) @@ -551,6 +582,7 @@ local function PurchaseOrEquip(ply, itemType, itemID) state.equippedWeapon = itemID ApplyWeapon(ply, previousWeaponID) end + state.profileDirty = true SyncPlayer(ply, state) local action = alreadyOwned and "Equipped " @@ -587,7 +619,7 @@ local function AwardMonsterKill(ply) state.score = state.score + reward state.credits = state.credits + reward state.playerName = ply:Nick() - state.totalsDirty = true + state.profileDirty = true SyncPlayer(ply, state) end @@ -604,7 +636,7 @@ local function ChargeForDeath(ply) state.credits = math.max(state.credits - configuredCost, 0) state.playerName = ply:Nick() - state.totalsDirty = true + state.profileDirty = true SyncPlayer(ply, state) SendNotice(ply, false, string.format( "Death cost %d credit%s. Lifetime Score and rank were not affected.", @@ -627,13 +659,13 @@ hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply) end) hook.Add("PlayerDisconnected", "CustomCoopProgressionForget", function(ply) - SavePlayerTotals(ply, playerStates[ply]) + SavePlayerState(ply, playerStates[ply]) playerStates[ply] = nil requestCooldowns[ply] = nil end) hook.Add("ShutDown", "CustomCoopProgressionSaveOnShutdown", function() - FlushDirtyPlayerTotals() + FlushDirtyProgression() end) -- Returning true prevents Sandbox's unrestricted cl_playermodel selection from