Defer shop persistence writes

This commit is contained in:
2026-09-06 18:23:53 +02:00
parent be9e24d3a5
commit e3bbd90be4
+137 -105
View File
@@ -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