1467 lines
51 KiB
Lua
1467 lines
51 KiB
Lua
if not SERVER then return end
|
|
|
|
local Progression = CustomCoopProgression
|
|
-- This file is auto-refreshed while developing on a live server. Keep all
|
|
-- authoritative state outside this file's local scope so a refresh cannot
|
|
-- replace newer in-memory totals with an older SQLite snapshot.
|
|
local runtime = CustomCoopProgressionRuntime
|
|
if not istable(runtime) then
|
|
runtime = {}
|
|
CustomCoopProgressionRuntime = runtime
|
|
end
|
|
|
|
runtime.playerStates = runtime.playerStates or setmetatable({}, { __mode = "k" })
|
|
runtime.disconnectedStates = runtime.disconnectedStates or {}
|
|
runtime.requestCooldowns = runtime.requestCooldowns or setmetatable({}, { __mode = "k" })
|
|
runtime.purchasedSpawns = runtime.purchasedSpawns or setmetatable({}, { __mode = "k" })
|
|
runtime.shopPrices = runtime.shopPrices or {}
|
|
runtime.dirtyShopPrices = runtime.dirtyShopPrices or {}
|
|
runtime.availableMusic = runtime.availableMusic or {}
|
|
|
|
local playerStates = runtime.playerStates
|
|
local disconnectedStates = runtime.disconnectedStates
|
|
local requestCooldowns = runtime.requestCooldowns
|
|
local purchasedSpawns = runtime.purchasedSpawns
|
|
local shopPrices = runtime.shopPrices
|
|
local dirtyShopPrices = runtime.dirtyShopPrices
|
|
local availableMusic = runtime.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,
|
|
playtime_seconds INTEGER NOT NULL DEFAULT 0,
|
|
equipped_model TEXT NOT NULL DEFAULT 'kleiner',
|
|
equipped_weapon TEXT NOT NULL DEFAULT 'none',
|
|
equipped_trail 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")
|
|
|
|
Query([[
|
|
CREATE TABLE IF NOT EXISTS custom_coop_enabled_behaviours (
|
|
steamid64 TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
PRIMARY KEY (steamid64, item_id)
|
|
)
|
|
]], "could not create enabled behaviour table")
|
|
|
|
local columns = Query("PRAGMA table_info(custom_coop_players)", "could not inspect player table")
|
|
local hasEquippedTrail = false
|
|
local hasPlaytime = false
|
|
for _, column in ipairs(columns or {}) do
|
|
if column.name == "equipped_trail" then hasEquippedTrail = true end
|
|
if column.name == "playtime_seconds" then hasPlaytime = true end
|
|
end
|
|
if not hasEquippedTrail then
|
|
Query(
|
|
"ALTER TABLE custom_coop_players ADD COLUMN equipped_trail TEXT NOT NULL DEFAULT 'none'",
|
|
"could not add equipped trail column"
|
|
)
|
|
end
|
|
if not hasPlaytime then
|
|
Query(
|
|
"ALTER TABLE custom_coop_players ADD COLUMN playtime_seconds INTEGER NOT NULL DEFAULT 0",
|
|
"could not add playtime column"
|
|
)
|
|
end
|
|
end
|
|
|
|
if not runtime.databaseInitialized then
|
|
CreateTables()
|
|
runtime.databaseInitialized = true
|
|
end
|
|
|
|
local catalogues = {
|
|
{ itemType = "model", items = Progression.Models },
|
|
{ itemType = "weapon", items = Progression.Weapons },
|
|
{ itemType = "music", items = Progression.Music },
|
|
{ itemType = "behaviour", items = Progression.Behaviours },
|
|
{ itemType = "trail", items = Progression.Trails },
|
|
{ itemType = "entity", items = Progression.Entities },
|
|
{ itemType = "helper", items = Progression.Helpers }
|
|
}
|
|
|
|
if not runtime.assetsPrepared then
|
|
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 once while
|
|
-- the gamemode is loading, not again after every live Lua refresh.
|
|
for _, items in ipairs({ Progression.Models, Progression.Entities, Progression.Helpers }) do
|
|
for _, item in ipairs(items) do
|
|
if item.model and item.model ~= "" then util.PrecacheModel(item.model) end
|
|
end
|
|
end
|
|
|
|
runtime.assetsPrepared = true
|
|
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
|
|
if itemType == "behaviour" then return Progression.GetBehaviour(itemID) end
|
|
if itemType == "trail" then return Progression.GetTrail(itemID) end
|
|
if itemType == "entity" then return Progression.GetEntity(itemID) end
|
|
if itemType == "helper" then return Progression.GetHelper(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()
|
|
local rows = Query("SELECT item_type, item_id, price FROM custom_coop_shop_prices", "could not load shop prices")
|
|
local existing = {}
|
|
if istable(rows) then
|
|
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
|
|
local key = PriceKey(row.item_type, row.item_id)
|
|
shopPrices[key] = price
|
|
existing[key] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- SQLite is synchronous in Garry's Mod. Seed every newly configured item
|
|
-- in one statement so slow storage cannot stall once per catalogue entry.
|
|
local values = {}
|
|
for _, catalogue in ipairs(catalogues) do
|
|
for _, item in ipairs(catalogue.items) do
|
|
if not existing[PriceKey(catalogue.itemType, item.id)] then
|
|
values[#values + 1] = string.format(
|
|
"(%s, %s, %d)",
|
|
SQLString(catalogue.itemType),
|
|
SQLString(item.id),
|
|
math.max(math.floor(item.price or 0), 0)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
if #values > 0 then
|
|
Query(
|
|
"INSERT OR IGNORE INTO custom_coop_shop_prices (item_type, item_id, price) VALUES " ..
|
|
table.concat(values, ","),
|
|
"could not seed shop prices"
|
|
)
|
|
end
|
|
end
|
|
|
|
if not runtime.shopPricesLoaded then
|
|
LoadShopPrices()
|
|
runtime.shopPricesLoaded = true
|
|
end
|
|
|
|
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,
|
|
playtimeSeconds = 0,
|
|
playtimeRemainder = 0,
|
|
playtimeUpdatedAt = CurTime(),
|
|
equippedModel = Progression.DefaultModelID,
|
|
equippedWeapon = Progression.DefaultWeaponID,
|
|
equippedTrail = Progression.DefaultTrailID,
|
|
ownedModels = { [Progression.DefaultModelID] = true },
|
|
ownedWeapons = { [Progression.DefaultWeaponID] = true },
|
|
ownedTrails = { [Progression.DefaultTrailID] = true },
|
|
ownedMusic = {},
|
|
ownedBehaviours = {},
|
|
enabledBehaviours = {},
|
|
behavioursDirty = false,
|
|
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:SetNW2Int("CustomCoopPlaytime", math.min(math.floor(state.playtimeSeconds or 0), 2147483647))
|
|
ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score))
|
|
ply:SetNW2String("CustomCoopTrail", state.equippedTrail)
|
|
ply:SetNW2Bool("CustomCoopHoverpackEnabled", state.enabledBehaviours.hoverpack == true)
|
|
if state.enabledBehaviours.hoverpack and ply.EquipSuit
|
|
and (not ply.IsSuitEquipped or not ply:IsSuitEquipped()) then
|
|
ply:EquipSuit()
|
|
end
|
|
end
|
|
|
|
local function AccruePlaytime(ply, state)
|
|
if not state then return 0 end
|
|
|
|
local now = CurTime()
|
|
local previousUpdate = state.playtimeUpdatedAt or now
|
|
state.playtimeUpdatedAt = now
|
|
state.playtimeRemainder = (state.playtimeRemainder or 0) + math.max(now - previousUpdate, 0)
|
|
|
|
local wholeSeconds = math.floor(state.playtimeRemainder)
|
|
if wholeSeconds <= 0 then return 0 end
|
|
|
|
state.playtimeRemainder = state.playtimeRemainder - wholeSeconds
|
|
local oldPlaytime = math.max(math.floor(state.playtimeSeconds or 0), 0)
|
|
state.playtimeSeconds = oldPlaytime + wholeSeconds
|
|
state.profileDirty = true
|
|
|
|
local rewardInterval = math.max(math.floor(Progression.PlaytimeRewardInterval or 0), 0)
|
|
local creditsPerInterval = math.max(math.floor(Progression.PlaytimeCreditsPerInterval or 0), 0)
|
|
local reward = 0
|
|
if rewardInterval > 0 and creditsPerInterval > 0 then
|
|
local oldMilestones = math.floor(oldPlaytime / rewardInterval)
|
|
local newMilestones = math.floor(state.playtimeSeconds / rewardInterval)
|
|
reward = math.max(newMilestones - oldMilestones, 0) * creditsPerInterval
|
|
state.credits = state.credits + reward
|
|
end
|
|
|
|
if IsValid(ply) then
|
|
state.playerName = ply:Nick()
|
|
SyncPlayer(ply, state)
|
|
if reward > 0 then
|
|
ply:ChatPrint(string.format(
|
|
"[CO-OP] Playtime reward: +%d Credits for %s played.",
|
|
reward,
|
|
Progression.FormatPlaytime(state.playtimeSeconds)
|
|
))
|
|
end
|
|
end
|
|
|
|
return reward
|
|
end
|
|
|
|
local function LoadPlayer(ply)
|
|
if not IsValid(ply) then return end
|
|
|
|
local steamID64 = ply:SteamID64()
|
|
if not steamID64 or steamID64 == "" then return end
|
|
|
|
-- A reconnect can happen before an idle write. Reuse the newer in-memory
|
|
-- state instead of replacing it with the older database snapshot.
|
|
local disconnectedState = disconnectedStates[steamID64]
|
|
if disconnectedState then
|
|
disconnectedStates[steamID64] = nil
|
|
disconnectedState.playerName = ply:Nick()
|
|
disconnectedState.playtimeUpdatedAt = CurTime()
|
|
playerStates[ply] = disconnectedState
|
|
SyncPlayer(ply, disconnectedState)
|
|
return disconnectedState
|
|
end
|
|
|
|
local row = Query(
|
|
"SELECT lifetime_score, credits, playtime_seconds, equipped_model, equipped_weapon, equipped_trail " ..
|
|
"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.playtimeSeconds = math.max(tonumber(row[1].playtime_seconds) or 0, 0)
|
|
state.equippedModel = row[1].equipped_model or Progression.DefaultModelID
|
|
state.equippedWeapon = row[1].equipped_weapon or Progression.DefaultWeaponID
|
|
state.equippedTrail = row[1].equipped_trail or Progression.DefaultTrailID
|
|
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
|
|
elseif owned.item_type == "behaviour" and Progression.GetBehaviour(owned.item_id) then
|
|
state.ownedBehaviours[owned.item_id] = true
|
|
elseif owned.item_type == "trail" and Progression.GetTrail(owned.item_id) then
|
|
state.ownedTrails[owned.item_id] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
local enabledRows = Query(
|
|
"SELECT item_id FROM custom_coop_enabled_behaviours WHERE steamid64 = " .. SQLString(steamID64),
|
|
"could not load enabled behaviours"
|
|
)
|
|
if istable(enabledRows) then
|
|
for _, enabled in ipairs(enabledRows) do
|
|
if state.ownedBehaviours[enabled.item_id] and Progression.GetBehaviour(enabled.item_id) then
|
|
state.enabledBehaviours[enabled.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
|
|
if not Progression.GetTrail(state.equippedTrail) or not state.ownedTrails[state.equippedTrail] then
|
|
state.equippedTrail = Progression.DefaultTrailID
|
|
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 and not state.behavioursDirty 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, playtime_seconds, equipped_model, equipped_weapon, equipped_trail) " ..
|
|
"VALUES (%s, %s, %d, %d, %d, %s, %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),
|
|
math.max(math.floor(state.playtimeSeconds or 0), 0),
|
|
SQLString(state.equippedModel),
|
|
SQLString(state.equippedWeapon),
|
|
SQLString(state.equippedTrail)
|
|
), "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 and state.behavioursDirty then
|
|
result = Query(
|
|
"DELETE FROM custom_coop_enabled_behaviours WHERE steamid64 = " .. SQLString(steamID64),
|
|
"could not clear enabled behaviours"
|
|
)
|
|
|
|
if result ~= false then
|
|
for itemID in pairs(state.enabledBehaviours) do
|
|
result = Query(string.format(
|
|
"INSERT INTO custom_coop_enabled_behaviours (steamid64, item_id) VALUES (%s, %s)",
|
|
SQLString(steamID64), SQLString(itemID)
|
|
), "could not save enabled behaviour")
|
|
if result == false then break end
|
|
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
|
|
state.behavioursDirty = 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
|
|
|
|
for steamID64, state in pairs(disconnectedStates) do
|
|
if SavePlayerState(nil, state) then
|
|
disconnectedStates[steamID64] = nil
|
|
end
|
|
end
|
|
|
|
SaveDirtyShopPrices()
|
|
end
|
|
|
|
local function FlushProgressionWhenServerIsEmpty()
|
|
if #player.GetHumans() > 0 then return end
|
|
FlushDirtyProgression()
|
|
end
|
|
|
|
local function StartProgressionSaveTimer()
|
|
timer.Create(
|
|
"CustomCoopProgressionSaveTotals",
|
|
math.max(totalsSaveInterval:GetFloat(), 10),
|
|
0,
|
|
FlushProgressionWhenServerIsEmpty
|
|
)
|
|
end
|
|
|
|
StartProgressionSaveTimer()
|
|
cvars.AddChangeCallback("gc_progression_save_interval", function()
|
|
StartProgressionSaveTimer()
|
|
end, "CustomCoopProgressionSaveInterval")
|
|
|
|
timer.Create(
|
|
"CustomCoopProgressionAccruePlaytime",
|
|
math.max(tonumber(Progression.PlaytimeUpdateInterval) or 10, 1),
|
|
0,
|
|
function()
|
|
for _, ply in ipairs(player.GetHumans()) do
|
|
if not ply:IsBot() then AccruePlaytime(ply, GetState(ply)) end
|
|
end
|
|
end
|
|
)
|
|
|
|
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 RemovePlayerTrail(ply)
|
|
if IsValid(ply.CustomCoopTrailEntity) then ply.CustomCoopTrailEntity:Remove() end
|
|
ply.CustomCoopTrailEntity = nil
|
|
end
|
|
|
|
local function ApplyTrail(ply)
|
|
if not IsValid(ply) then return end
|
|
RemovePlayerTrail(ply)
|
|
|
|
local state = GetState(ply)
|
|
local item = state and Progression.GetTrail(state.equippedTrail)
|
|
if not item or item.material == "" or not ply:Alive() then return end
|
|
|
|
local startWidth = math.Clamp(tonumber(item.startWidth) or 24, 1, 128)
|
|
local endWidth = math.Clamp(tonumber(item.endWidth) or 2, 0, 128)
|
|
local lifetime = math.Clamp(tonumber(item.lifetime) or 2.5, 0.1, 10)
|
|
local textureResolution = 1 / math.max((startWidth + endWidth) * 0.5, 0.0001)
|
|
local color = item.color or color_white
|
|
|
|
ply.CustomCoopTrailEntity = util.SpriteTrail(
|
|
ply,
|
|
0,
|
|
color,
|
|
item.additive == true,
|
|
startWidth,
|
|
endWidth,
|
|
lifetime,
|
|
textureResolution,
|
|
item.material .. ".vmt"
|
|
)
|
|
end
|
|
|
|
hook.Add("Think", "CustomCoopProgressionRainbowTrails", function()
|
|
for _, ply in ipairs(player.GetAll()) do
|
|
if IsValid(ply.CustomCoopTrailEntity) then
|
|
local state = playerStates[ply]
|
|
local item = state and Progression.GetTrail(state.equippedTrail)
|
|
if item and item.rainbow then
|
|
local color = HSVToColor((CurTime() * 90 + ply:EntIndex() * 23) % 360, 1, 1)
|
|
ply.CustomCoopTrailEntity:SetColor(color)
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
function Progression.SetHoverpackThrusting(ply, thrusting)
|
|
if not IsValid(ply) then return end
|
|
|
|
thrusting = thrusting == true
|
|
and ply:GetNW2Bool("CustomCoopHoverpackEnabled", false)
|
|
and ply:Alive()
|
|
and (not ply.GetSuitPower or ply:GetSuitPower() > 0.01)
|
|
|
|
if ply:GetNW2Bool("CustomCoopHoverpackThrusting", false) == thrusting then return end
|
|
ply:SetNW2Bool("CustomCoopHoverpackThrusting", thrusting)
|
|
|
|
if ply.CustomCoopHoverpackSound then
|
|
ply.CustomCoopHoverpackSound:Stop()
|
|
ply.CustomCoopHoverpackSound = nil
|
|
end
|
|
|
|
if thrusting then
|
|
ply.CustomCoopHoverpackLastThrust = CurTime()
|
|
local soundPatch = CreateSound(ply, Progression.HoverpackSound)
|
|
if soundPatch then
|
|
ply.CustomCoopHoverpackSound = soundPatch
|
|
soundPatch:PlayEx(Progression.HoverpackSoundVolume, Progression.HoverpackSoundPitch)
|
|
end
|
|
end
|
|
end
|
|
|
|
hook.Add("Think", "CustomCoopProgressionHoverpackPower", function()
|
|
local now = CurTime()
|
|
|
|
for _, ply in ipairs(player.GetAll()) do
|
|
local previousUpdate = ply.CustomCoopHoverpackPowerUpdated or now
|
|
local elapsed = math.Clamp(now - previousUpdate, 0, 0.25)
|
|
ply.CustomCoopHoverpackPowerUpdated = now
|
|
|
|
if elapsed > 0 and ply:GetNW2Bool("CustomCoopHoverpackEnabled", false)
|
|
and ply.GetSuitPower and ply.SetSuitPower then
|
|
local power = math.Clamp(ply:GetSuitPower(), 0, 100)
|
|
local thrusting = ply:GetNW2Bool("CustomCoopHoverpackThrusting", false)
|
|
local newPower = power
|
|
|
|
if thrusting then
|
|
ply.CustomCoopHoverpackLastThrust = now
|
|
newPower = math.max(power - Progression.HoverpackDrainPerSecond * elapsed, 0)
|
|
if newPower <= 0 then Progression.SetHoverpackThrusting(ply, false) end
|
|
else
|
|
local lastThrust = ply.CustomCoopHoverpackLastThrust or 0
|
|
local sprinting = ply:KeyDown(IN_SPEED) and ply:GetVelocity():Length2D() > ply:GetWalkSpeed()
|
|
local releasedJump = not ply:KeyDown(IN_JUMP)
|
|
|
|
if releasedJump and not sprinting
|
|
and now - lastThrust >= Progression.HoverpackRechargeDelay then
|
|
local walking = ply:OnGround() and ply:GetVelocity():Length2D() > 20
|
|
local rechargeRate = walking
|
|
and Progression.HoverpackWalkingRechargePerSecond
|
|
or Progression.HoverpackRechargePerSecond
|
|
newPower = math.min(power + rechargeRate * elapsed, 100)
|
|
end
|
|
end
|
|
|
|
if math.abs(newPower - power) >= 0.001 then ply:SetSuitPower(newPower) end
|
|
end
|
|
end
|
|
end)
|
|
|
|
local function SendShop(ply)
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
AccruePlaytime(ply, state)
|
|
|
|
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
|
|
for id in pairs(state.ownedBehaviours) do
|
|
owned[#owned + 1] = { itemType = "behaviour", id = id }
|
|
end
|
|
for id in pairs(state.ownedTrails) do
|
|
owned[#owned + 1] = { itemType = "trail", id = id }
|
|
end
|
|
|
|
net.Start("CustomCoopProgressionOpenShop")
|
|
net.WriteUInt(math.min(state.score, 4294967295), 32)
|
|
net.WriteUInt(math.min(state.credits, 4294967295), 32)
|
|
net.WriteUInt(math.min(math.floor(state.playtimeSeconds or 0), 4294967295), 32)
|
|
net.WriteString(state.equippedModel)
|
|
net.WriteString(state.equippedWeapon)
|
|
net.WriteString(state.equippedTrail)
|
|
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
|
|
local enabledBehaviours = {}
|
|
for id in pairs(state.enabledBehaviours) do
|
|
enabledBehaviours[#enabledBehaviours + 1] = id
|
|
end
|
|
net.WriteUInt(math.min(#enabledBehaviours, 255), 8)
|
|
for index = 1, math.min(#enabledBehaviours, 255) do
|
|
net.WriteString(enabledBehaviours[index])
|
|
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 FindShopSpawnTransform(ply, item, isHelper)
|
|
if not IsValid(ply) or not ply:Alive() then return nil, "You must be alive to spawn this purchase." end
|
|
|
|
local startPosition = ply:EyePos()
|
|
local trace = util.TraceLine({
|
|
start = startPosition,
|
|
endpos = startPosition + ply:GetAimVector() * math.max(Progression.ShopSpawnDistance or 500, 100),
|
|
filter = ply,
|
|
mask = MASK_SOLID
|
|
})
|
|
if not trace.Hit or trace.HitSky or trace.HitNormal.z < 0.45 then
|
|
return nil, "Aim at a clear floor within range, then try again."
|
|
end
|
|
|
|
local position = trace.HitPos + trace.HitNormal * math.max(tonumber(item.spawnOffset) or 18, 2)
|
|
if not util.IsInWorld(position) then return nil, "That spawn position is outside the map." end
|
|
|
|
local radius = isHelper and 18 or 12
|
|
local height = isHelper and 72 or 28
|
|
local blocked = util.TraceHull({
|
|
start = position,
|
|
endpos = position + Vector(0, 0, 1),
|
|
mins = Vector(-radius, -radius, 0),
|
|
maxs = Vector(radius, radius, height),
|
|
filter = ply,
|
|
mask = MASK_PLAYERSOLID
|
|
})
|
|
if blocked.Hit then return nil, "There is not enough clear space at that position." end
|
|
|
|
return position, Angle(0, ply:EyeAngles().y + 180, 0)
|
|
end
|
|
|
|
local function TrackPurchasedSpawn(ply, itemType, ent)
|
|
local records = purchasedSpawns[ply]
|
|
if not records then
|
|
records = { entity = {}, helper = {} }
|
|
purchasedSpawns[ply] = records
|
|
end
|
|
|
|
local list = records[itemType]
|
|
for index = #list, 1, -1 do
|
|
if not IsValid(list[index]) then table.remove(list, index) end
|
|
end
|
|
|
|
local limit = itemType == "helper"
|
|
and math.max(math.floor(Progression.PurchasedHelperLimit or 6), 1)
|
|
or math.max(math.floor(Progression.PurchasedEntityLimit or 12), 1)
|
|
while #list >= limit do
|
|
local oldest = table.remove(list, 1)
|
|
if IsValid(oldest) then oldest:Remove() end
|
|
end
|
|
|
|
list[#list + 1] = ent
|
|
end
|
|
|
|
local function SpawnShopEntity(ply, item, position, angles)
|
|
local ent = ents.Create(item.class)
|
|
if not IsValid(ent) then return end
|
|
|
|
ent:SetPos(position)
|
|
ent:SetAngles(angles)
|
|
if item.model and item.model ~= "" then ent:SetModel(item.model) end
|
|
if item.ballSize and ent.SetBallSize then ent:SetBallSize(item.ballSize) end
|
|
ent:Spawn()
|
|
ent:Activate()
|
|
|
|
if item.id == "glowstick" then
|
|
if ent.SetColor then ent:SetColor(item.color or Color(60, 220, 255)) end
|
|
if ent.SetBrightness then ent:SetBrightness(tonumber(item.brightness) or 2) end
|
|
if ent.SetLightSize then ent:SetLightSize(tonumber(item.lightSize) or 320) end
|
|
if ent.SetToggle then ent:SetToggle(true) end
|
|
if ent.SetOn then ent:SetOn(true) end
|
|
elseif item.id == "tnt" then
|
|
if ent.SetDamage then ent:SetDamage(math.Clamp(tonumber(item.damage) or 250, 0, 1500)) end
|
|
if ent.SetShouldRemove then ent:SetShouldRemove(true) end
|
|
local fuse = math.Clamp(tonumber(item.fuse) or 5, 0.1, 60)
|
|
timer.Simple(fuse, function()
|
|
if IsValid(ent) and ent.Explode then ent:Explode(0, IsValid(ply) and ply or ent) end
|
|
end)
|
|
elseif item.id == "red_tnt" then
|
|
-- Nuke Pack 4 reads this legacy Lua variable when a charge is
|
|
-- wire-triggered; damage-triggered explosions replace it with the
|
|
-- actual attacker themselves.
|
|
ent:SetVar("Owner", ply)
|
|
end
|
|
|
|
local physics = ent:GetPhysicsObject()
|
|
if IsValid(physics) then physics:Wake() end
|
|
ent:SetCreator(ply)
|
|
ent.CustomCoopPurchasedSpawn = true
|
|
if ent.SetPlayer then ent:SetPlayer(ply) end
|
|
return ent
|
|
end
|
|
|
|
local function SpawnShopHelper(ply, item, position, angles)
|
|
local npc = ents.Create(item.class)
|
|
if not IsValid(npc) then return end
|
|
|
|
npc:SetPos(position)
|
|
npc:SetAngles(angles)
|
|
if item.model and item.model ~= "" then npc:SetModel(item.model) end
|
|
npc:SetKeyValue("SquadName", "custom_coop_resistance")
|
|
if item.citizenType then npc:SetKeyValue("citizentype", tostring(item.citizenType)) end
|
|
if item.weapon and item.weapon ~= "" then npc:SetKeyValue("additionalequipment", item.weapon) end
|
|
|
|
local spawnFlags = bit.bor(SF_NPC_FADE_CORPSE or 512, SF_NPC_ALWAYSTHINK or 1024)
|
|
if item.medic then
|
|
spawnFlags = bit.bor(spawnFlags, SF_NPC_DROP_HEALTHKIT or 8, SF_CITIZEN_MEDIC or 0)
|
|
end
|
|
if item.friendlyTurret then spawnFlags = SF_FLOOR_TURRET_CITIZEN or spawnFlags end
|
|
npc:SetKeyValue("spawnflags", tostring(spawnFlags))
|
|
|
|
npc:Spawn()
|
|
npc:Activate()
|
|
npc:DropToFloor()
|
|
npc:SetCreator(ply)
|
|
npc.CustomCoopNoScore = true
|
|
npc.CustomCoopPurchasedSpawn = true
|
|
if npc.SetCurrentWeaponProficiency then
|
|
npc:SetCurrentWeaponProficiency(WEAPON_PROFICIENCY_GOOD or 4)
|
|
end
|
|
for _, playerTarget in ipairs(player.GetAll()) do
|
|
npc:AddEntityRelationship(playerTarget, D_LI, 99)
|
|
end
|
|
return npc
|
|
end
|
|
|
|
local function ActivateSpawnPurchase(ply, itemType, item)
|
|
local isHelper = itemType == "helper"
|
|
local position, anglesOrMessage = FindShopSpawnTransform(ply, item, isHelper)
|
|
if not position then
|
|
SendNotice(ply, false, anglesOrMessage)
|
|
return
|
|
end
|
|
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
|
|
local price = GetShopPrice(itemType, item)
|
|
local adminCreditBypass = state.credits < price
|
|
and Progression.AdminShopTestingBypass
|
|
and ply:IsAdmin()
|
|
if state.credits < price and not adminCreditBypass then
|
|
SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.")
|
|
return
|
|
end
|
|
|
|
local ent
|
|
if isHelper then
|
|
ent = SpawnShopHelper(ply, item, position, anglesOrMessage)
|
|
else
|
|
ent = SpawnShopEntity(ply, item, position, anglesOrMessage)
|
|
end
|
|
if not IsValid(ent) then
|
|
SendNotice(ply, false, "That purchase could not be spawned. No credits were spent.")
|
|
return
|
|
end
|
|
|
|
TrackPurchasedSpawn(ply, itemType, ent)
|
|
if adminCreditBypass then
|
|
SendNotice(ply, false, string.format(
|
|
"You need %d more credits. Admin testing override: no credits were charged.",
|
|
price - state.credits
|
|
))
|
|
else
|
|
state.credits = math.max(state.credits - price, 0)
|
|
state.profileDirty = true
|
|
end
|
|
|
|
SyncPlayer(ply, state)
|
|
SendNotice(ply, true, (adminCreditBypass and "Admin test-spawned " or "Purchased and spawned ") .. item.name .. ".")
|
|
SendShop(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 ActivateConsumableBehaviour(ply, item)
|
|
if not item.godmode then return false end
|
|
|
|
local now = CurTime()
|
|
local activeUntil = ply.CustomCoopGodmodeUntil or 0
|
|
if activeUntil > now then
|
|
SendNotice(ply, false, string.format(
|
|
"God Mode is already active for another %d seconds. No credits were spent.",
|
|
math.ceil(activeUntil - now)
|
|
))
|
|
SendShop(ply)
|
|
return true
|
|
end
|
|
|
|
local state = GetState(ply)
|
|
if not state then return true end
|
|
|
|
local price = GetShopPrice("behaviour", item)
|
|
local adminCreditBypass = state.credits < price
|
|
and Progression.AdminShopTestingBypass
|
|
and ply:IsAdmin()
|
|
|
|
if state.credits < price and not adminCreditBypass then
|
|
SendNotice(ply, false, "You need " .. (price - state.credits) .. " more credits.")
|
|
return true
|
|
end
|
|
|
|
if adminCreditBypass then
|
|
SendNotice(ply, false, string.format(
|
|
"You need %d more credits. Admin testing override: no credits will be charged.",
|
|
price - state.credits
|
|
))
|
|
else
|
|
state.credits = state.credits - price
|
|
state.profileDirty = true
|
|
end
|
|
|
|
local duration = math.max(math.floor(tonumber(item.duration) or 90), 1)
|
|
local expiresAt = now + duration
|
|
ply.CustomCoopGodmodeUntil = expiresAt
|
|
ply:SetNW2Float("CustomCoopGodmodeUntil", expiresAt)
|
|
SyncPlayer(ply, state)
|
|
|
|
local message = adminCreditBypass
|
|
and string.format("Admin test-activated God Mode for %d seconds.", duration)
|
|
or string.format("Activated God Mode for %d seconds.", duration)
|
|
SendNotice(ply, true, message)
|
|
SendShop(ply)
|
|
|
|
timer.Simple(duration, function()
|
|
if not IsValid(ply) or ply.CustomCoopGodmodeUntil ~= expiresAt then return end
|
|
ply.CustomCoopGodmodeUntil = nil
|
|
ply:SetNW2Float("CustomCoopGodmodeUntil", 0)
|
|
SendNotice(ply, false, "God Mode expired.")
|
|
end)
|
|
|
|
return true
|
|
end
|
|
|
|
local function PurchaseOrEquip(ply, itemType, itemID)
|
|
local item = GetCatalogueItem(itemType, itemID)
|
|
if not item then return end
|
|
|
|
if itemType == "entity" or itemType == "helper" then
|
|
ActivateSpawnPurchase(ply, itemType, item)
|
|
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
|
|
|
|
if itemType == "behaviour" and item.consumable then
|
|
ActivateConsumableBehaviour(ply, item)
|
|
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 itemType == "music" and state.ownedMusic
|
|
or itemType == "trail" and state.ownedTrails
|
|
or state.ownedBehaviours
|
|
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 itemType == "behaviour" and alreadyOwned then
|
|
local enabled = state.enabledBehaviours[itemID] ~= true
|
|
state.enabledBehaviours[itemID] = enabled or nil
|
|
state.behavioursDirty = true
|
|
SyncPlayer(ply, state)
|
|
if not enabled then Progression.SetHoverpackThrusting(ply, false) end
|
|
SendNotice(ply, true, item.name .. (enabled and " enabled." or " disabled."))
|
|
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 == "behaviour" then
|
|
state.enabledBehaviours[itemID] = true
|
|
state.behavioursDirty = true
|
|
SyncPlayer(ply, state)
|
|
local action = adminCreditBypass and "Admin test-unlocked and enabled " or "Purchased and enabled "
|
|
SendNotice(ply, true, action .. item.name .. ".")
|
|
SendShop(ply)
|
|
return
|
|
end
|
|
|
|
if itemType == "trail" then
|
|
state.equippedTrail = itemID
|
|
state.profileDirty = true
|
|
SyncPlayer(ply, state)
|
|
ApplyTrail(ply)
|
|
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)
|
|
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, reward)
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
|
|
reward = math.max(math.floor(tonumber(reward) or 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] Harder monsters award more Score and Credits. Lifetime Score determines your rank.")
|
|
ply:ChatPrint(string.format(
|
|
"[CO-OP] Every %s played awards %d bonus Credits.",
|
|
Progression.FormatPlaytime(Progression.PlaytimeRewardInterval),
|
|
Progression.PlaytimeCreditsPerInterval
|
|
))
|
|
ply:ChatPrint("[CO-OP] Press F3 or type !shop for models, weapons, music, abilities, trails, deployables, and friendly NPCs.")
|
|
ply:ChatPrint("[CO-OP] Deployables and friendly NPCs are consumables: aim at a clear floor before buying one.")
|
|
ply:ChatPrint("[CO-OP] Enable the Hoverpack in Behaviours, then hold Space after jumping to hover.")
|
|
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)
|
|
Progression.SetHoverpackThrusting(ply, false)
|
|
RemovePlayerTrail(ply)
|
|
local spawned = purchasedSpawns[ply]
|
|
if spawned then
|
|
for _, list in pairs(spawned) do
|
|
for _, ent in ipairs(list) do
|
|
if IsValid(ent) then ent:Remove() end
|
|
end
|
|
end
|
|
purchasedSpawns[ply] = nil
|
|
end
|
|
local state = playerStates[ply]
|
|
if state then AccruePlaytime(nil, state) end
|
|
if state and (state.profileDirty or state.behavioursDirty or next(state.pendingOwnedItems) ~= nil) then
|
|
disconnectedStates[state.steamID64] = state
|
|
end
|
|
|
|
playerStates[ply] = nil
|
|
requestCooldowns[ply] = nil
|
|
|
|
timer.Simple(0, FlushProgressionWhenServerIsEmpty)
|
|
end)
|
|
|
|
hook.Add("ShutDown", "CustomCoopProgressionSaveOnShutdown", function()
|
|
for ply, state in pairs(playerStates) do
|
|
AccruePlaytime(ply, state)
|
|
end
|
|
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)
|
|
Progression.SetHoverpackThrusting(ply, false)
|
|
ply.CustomCoopGrantedWeapon = nil
|
|
|
|
timer.Simple(0.5, function()
|
|
if IsValid(ply) then
|
|
ApplyWeapon(ply)
|
|
ApplyTrail(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
|
|
local reward = Progression.GetMonsterReward(npc:GetClass())
|
|
if not reward 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, reward)
|
|
end)
|
|
|
|
hook.Add("EntityTakeDamage", "CustomCoopProgressionTimedGodmode", function(target, damageInfo)
|
|
if not IsValid(target) or not target:IsPlayer() then return end
|
|
if (target.CustomCoopGodmodeUntil or 0) <= CurTime() then return end
|
|
|
|
damageInfo:SetDamage(0)
|
|
return true
|
|
end)
|
|
|
|
hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim)
|
|
if not IsValid(victim) or victim:IsBot() then return end
|
|
Progression.SetHoverpackThrusting(victim, false)
|
|
RemovePlayerTrail(victim)
|
|
ChargeForDeath(victim)
|
|
end)
|
|
|
|
hook.Add("PostCleanupMap", "CustomCoopProgressionRestoreTrails", function()
|
|
timer.Simple(0.5, function()
|
|
for _, ply in ipairs(player.GetAll()) do
|
|
if IsValid(ply) then ApplyTrail(ply) end
|
|
end
|
|
end)
|
|
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
|
|
AccruePlaytime(ply, state)
|
|
ply:ChatPrint(string.format(
|
|
"[CO-OP] Score: %d | Credits: %d | Rank: %s | Played: %s",
|
|
state.score,
|
|
state.credits,
|
|
Progression.GetRank(state.score),
|
|
Progression.FormatPlaytime(state.playtimeSeconds)
|
|
))
|
|
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|behaviour|trail|entity|helper> <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)
|