Defer shop persistence writes
This commit is contained in:
+137
-105
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user