448 lines
15 KiB
Lua
448 lines
15 KiB
Lua
if not SERVER then return end
|
|
|
|
local Progression = CustomCoopProgression
|
|
local playerStates = setmetatable({}, { __mode = "k" })
|
|
local requestCooldowns = setmetatable({}, { __mode = "k" })
|
|
|
|
util.AddNetworkString("CustomCoopProgressionOpenShop")
|
|
util.AddNetworkString("CustomCoopProgressionRequestShop")
|
|
util.AddNetworkString("CustomCoopProgressionSelectItem")
|
|
util.AddNetworkString("CustomCoopProgressionNotice")
|
|
|
|
local function Query(query, context)
|
|
local result = sql.Query(query)
|
|
if result == false then
|
|
ErrorNoHalt("[custom_coop progression] " .. context .. ": " .. sql.LastError() .. "\n")
|
|
return false
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function SQLString(value)
|
|
return sql.SQLStr(tostring(value or ""))
|
|
end
|
|
|
|
local function CreateTables()
|
|
Query([[
|
|
CREATE TABLE IF NOT EXISTS custom_coop_players (
|
|
steamid64 TEXT PRIMARY KEY NOT NULL,
|
|
player_name TEXT NOT NULL DEFAULT '',
|
|
lifetime_score INTEGER NOT NULL DEFAULT 0,
|
|
credits INTEGER NOT NULL DEFAULT 0,
|
|
equipped_model TEXT NOT NULL DEFAULT 'kleiner',
|
|
equipped_weapon TEXT NOT NULL DEFAULT 'none'
|
|
)
|
|
]], "could not create player table")
|
|
|
|
Query([[
|
|
CREATE TABLE IF NOT EXISTS custom_coop_owned_items (
|
|
steamid64 TEXT NOT NULL,
|
|
item_type TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
purchased_at INTEGER NOT NULL,
|
|
PRIMARY KEY (steamid64, item_type, item_id)
|
|
)
|
|
]], "could not create ownership table")
|
|
end
|
|
|
|
CreateTables()
|
|
|
|
local function EmptyState()
|
|
return {
|
|
score = 0,
|
|
credits = 0,
|
|
equippedModel = Progression.DefaultModelID,
|
|
equippedWeapon = Progression.DefaultWeaponID,
|
|
ownedModels = { [Progression.DefaultModelID] = true },
|
|
ownedWeapons = { [Progression.DefaultWeaponID] = true }
|
|
}
|
|
end
|
|
|
|
local function SyncPlayer(ply, state)
|
|
if not IsValid(ply) then return end
|
|
|
|
ply:SetNW2Int("CustomCoopScore", state.score)
|
|
ply:SetNW2Int("CustomCoopCredits", state.credits)
|
|
ply:SetNW2String("CustomCoopRank", Progression.GetRank(state.score))
|
|
end
|
|
|
|
local function EnsurePlayerRow(ply)
|
|
local steamID64 = ply:SteamID64()
|
|
if not steamID64 or steamID64 == "" then return false end
|
|
|
|
return Query(string.format(
|
|
"INSERT OR IGNORE INTO custom_coop_players " ..
|
|
"(steamid64, player_name, equipped_model, equipped_weapon) VALUES (%s, %s, %s, %s)",
|
|
SQLString(steamID64),
|
|
SQLString(ply:Nick()),
|
|
SQLString(Progression.DefaultModelID),
|
|
SQLString(Progression.DefaultWeaponID)
|
|
), "could not create player row") ~= false
|
|
end
|
|
|
|
local function LoadPlayer(ply)
|
|
if not IsValid(ply) or not EnsurePlayerRow(ply) then return end
|
|
|
|
local steamID64 = ply:SteamID64()
|
|
local row = Query(
|
|
"SELECT lifetime_score, credits, equipped_model, equipped_weapon " ..
|
|
"FROM custom_coop_players WHERE steamid64 = " .. SQLString(steamID64) .. " LIMIT 1",
|
|
"could not load player"
|
|
)
|
|
|
|
local state = EmptyState()
|
|
if istable(row) and row[1] then
|
|
state.score = math.max(tonumber(row[1].lifetime_score) or 0, 0)
|
|
state.credits = math.max(tonumber(row[1].credits) or 0, 0)
|
|
state.equippedModel = row[1].equipped_model or Progression.DefaultModelID
|
|
state.equippedWeapon = row[1].equipped_weapon or Progression.DefaultWeaponID
|
|
end
|
|
|
|
local ownedRows = Query(
|
|
"SELECT item_type, item_id FROM custom_coop_owned_items WHERE steamid64 = " .. SQLString(steamID64),
|
|
"could not load owned items"
|
|
)
|
|
|
|
if istable(ownedRows) then
|
|
for _, owned in ipairs(ownedRows) do
|
|
if owned.item_type == "model" and Progression.GetModel(owned.item_id) then
|
|
state.ownedModels[owned.item_id] = true
|
|
elseif owned.item_type == "weapon" and Progression.GetWeapon(owned.item_id) then
|
|
state.ownedWeapons[owned.item_id] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
if not Progression.GetModel(state.equippedModel) or not state.ownedModels[state.equippedModel] then
|
|
state.equippedModel = Progression.DefaultModelID
|
|
end
|
|
if not Progression.GetWeapon(state.equippedWeapon) or not state.ownedWeapons[state.equippedWeapon] then
|
|
state.equippedWeapon = Progression.DefaultWeaponID
|
|
end
|
|
|
|
playerStates[ply] = state
|
|
SyncPlayer(ply, state)
|
|
|
|
Query(string.format(
|
|
"UPDATE custom_coop_players SET player_name = %s, equipped_model = %s, equipped_weapon = %s " ..
|
|
"WHERE steamid64 = %s",
|
|
SQLString(ply:Nick()),
|
|
SQLString(state.equippedModel),
|
|
SQLString(state.equippedWeapon),
|
|
SQLString(steamID64)
|
|
), "could not refresh player row")
|
|
|
|
return state
|
|
end
|
|
|
|
local function GetState(ply)
|
|
return playerStates[ply] or LoadPlayer(ply)
|
|
end
|
|
|
|
local function ApplyModel(ply)
|
|
local state = GetState(ply)
|
|
local item = state and Progression.GetModel(state.equippedModel)
|
|
if not item then item = Progression.GetModel(Progression.DefaultModelID) end
|
|
if not item then return end
|
|
|
|
util.PrecacheModel(item.model)
|
|
ply:SetModel(item.model)
|
|
end
|
|
|
|
local function ApplyWeapon(ply, previousWeaponID)
|
|
local state = GetState(ply)
|
|
if not state or not IsValid(ply) or not ply:Alive() then return end
|
|
|
|
if previousWeaponID then
|
|
local previous = Progression.GetWeapon(previousWeaponID)
|
|
if previous and previous.weaponClass ~= "" and ply.CustomCoopGrantedWeapon == previous.weaponClass then
|
|
ply:StripWeapon(previous.weaponClass)
|
|
end
|
|
end
|
|
|
|
local item = Progression.GetWeapon(state.equippedWeapon)
|
|
if not item or item.weaponClass == "" then
|
|
ply.CustomCoopGrantedWeapon = nil
|
|
return
|
|
end
|
|
|
|
if not weapons.GetStored(item.weaponClass) then
|
|
ErrorNoHalt("[custom_coop progression] Unknown shop weapon " .. item.weaponClass .. "\n")
|
|
return
|
|
end
|
|
|
|
ply:Give(item.weaponClass)
|
|
ply.CustomCoopGrantedWeapon = item.weaponClass
|
|
end
|
|
|
|
local function SendShop(ply)
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
|
|
local owned = {}
|
|
for id in pairs(state.ownedModels) do
|
|
owned[#owned + 1] = { itemType = "model", id = id }
|
|
end
|
|
for id in pairs(state.ownedWeapons) do
|
|
owned[#owned + 1] = { itemType = "weapon", id = id }
|
|
end
|
|
|
|
net.Start("CustomCoopProgressionOpenShop")
|
|
net.WriteUInt(math.min(state.score, 4294967295), 32)
|
|
net.WriteUInt(math.min(state.credits, 4294967295), 32)
|
|
net.WriteString(state.equippedModel)
|
|
net.WriteString(state.equippedWeapon)
|
|
net.WriteUInt(math.min(#owned, 255), 8)
|
|
for index = 1, math.min(#owned, 255) do
|
|
net.WriteString(owned[index].itemType)
|
|
net.WriteString(owned[index].id)
|
|
end
|
|
net.Send(ply)
|
|
end
|
|
|
|
local function SendNotice(ply, success, message)
|
|
net.Start("CustomCoopProgressionNotice")
|
|
net.WriteBool(success)
|
|
net.WriteString(message)
|
|
net.Send(ply)
|
|
end
|
|
|
|
local function RequestAllowed(ply)
|
|
local now = CurTime()
|
|
if (requestCooldowns[ply] or 0) > now then return false end
|
|
requestCooldowns[ply] = now + 0.2
|
|
return true
|
|
end
|
|
|
|
local function PurchaseOrEquip(ply, itemType, itemID)
|
|
if itemType ~= "model" and itemType ~= "weapon" then return end
|
|
|
|
local item = itemType == "model" and Progression.GetModel(itemID) or Progression.GetWeapon(itemID)
|
|
if not item then return end
|
|
|
|
if itemType == "weapon" and item.weaponClass ~= "" and not weapons.GetStored(item.weaponClass) then
|
|
SendNotice(ply, false, "That Workshop weapon is not currently available. No credits were spent.")
|
|
return
|
|
end
|
|
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
|
|
local ownedItems = itemType == "model" and state.ownedModels or state.ownedWeapons
|
|
local alreadyOwned = ownedItems[itemID] == true or item.price == 0
|
|
local previousWeaponID = state.equippedWeapon
|
|
|
|
if not alreadyOwned then
|
|
if state.credits < item.price then
|
|
SendNotice(ply, false, "You need " .. (item.price - state.credits) .. " more credits.")
|
|
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 = insertResult ~= false and Query(string.format(
|
|
"UPDATE custom_coop_players SET credits = credits - %d WHERE steamid64 = %s AND credits >= %d",
|
|
item.price, SQLString(ply:SteamID64()), item.price
|
|
), "could not debit purchase")
|
|
|
|
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()
|
|
|
|
state.credits = state.credits - item.price
|
|
ownedItems[itemID] = true
|
|
end
|
|
|
|
local column = itemType == "model" and "equipped_model" or "equipped_weapon"
|
|
local equipResult = Query(string.format(
|
|
"UPDATE custom_coop_players SET %s = %s WHERE steamid64 = %s",
|
|
column, SQLString(itemID), SQLString(ply:SteamID64())
|
|
), "could not equip item")
|
|
|
|
if equipResult == false then
|
|
SendNotice(ply, false, "The item was purchased, but could not be equipped yet.")
|
|
SyncPlayer(ply, state)
|
|
SendShop(ply)
|
|
return
|
|
end
|
|
|
|
if itemType == "model" then
|
|
state.equippedModel = itemID
|
|
ApplyModel(ply)
|
|
else
|
|
state.equippedWeapon = itemID
|
|
ApplyWeapon(ply, previousWeaponID)
|
|
end
|
|
|
|
SyncPlayer(ply, state)
|
|
SendNotice(ply, true, (alreadyOwned and "Equipped " or "Purchased and equipped ") .. item.name .. ".")
|
|
SendShop(ply)
|
|
end
|
|
|
|
local function ResolvePlayerAttacker(attacker, inflictor)
|
|
if IsValid(attacker) and attacker:IsPlayer() then return attacker end
|
|
|
|
if IsValid(attacker) then
|
|
local owner = attacker:GetOwner()
|
|
if IsValid(owner) and owner:IsPlayer() then return owner end
|
|
|
|
if attacker:IsVehicle() then
|
|
local driver = attacker:GetDriver()
|
|
if IsValid(driver) and driver:IsPlayer() then return driver end
|
|
end
|
|
end
|
|
|
|
if IsValid(inflictor) then
|
|
local owner = inflictor:GetOwner()
|
|
if IsValid(owner) and owner:IsPlayer() then return owner end
|
|
end
|
|
end
|
|
|
|
local function AwardMonsterKill(ply)
|
|
local state = GetState(ply)
|
|
if not state then return end
|
|
|
|
local reward = math.max(math.floor(Progression.KillReward), 1)
|
|
local result = Query(string.format(
|
|
"UPDATE custom_coop_players SET lifetime_score = lifetime_score + %d, " ..
|
|
"credits = credits + %d, player_name = %s WHERE steamid64 = %s",
|
|
reward, reward, SQLString(ply:Nick()), SQLString(ply:SteamID64())
|
|
), "could not save monster reward")
|
|
|
|
if result == false then return end
|
|
|
|
state.score = state.score + reward
|
|
state.credits = state.credits + reward
|
|
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
|
|
|
|
local result = Query(string.format(
|
|
"UPDATE custom_coop_players SET credits = MAX(credits - %d, 0) WHERE steamid64 = %s",
|
|
configuredCost, SQLString(ply:SteamID64())
|
|
), "could not save death penalty")
|
|
|
|
if result == false then return end
|
|
|
|
state.credits = math.max(state.credits - configuredCost, 0)
|
|
SyncPlayer(ply, state)
|
|
SendNotice(ply, false, string.format(
|
|
"Death cost %d credit%s. Lifetime Score and rank were not affected.",
|
|
charged,
|
|
charged == 1 and "" or "s"
|
|
))
|
|
end
|
|
|
|
hook.Add("PlayerInitialSpawn", "CustomCoopProgressionLoad", function(ply)
|
|
LoadPlayer(ply)
|
|
|
|
timer.Simple(4, function()
|
|
if not IsValid(ply) then return end
|
|
ply:ChatPrint("[CO-OP] Kill monsters to earn Score and Credits. Your lifetime Score determines your rank.")
|
|
ply:ChatPrint("[CO-OP] Press F3 or type !shop to spend Credits on permanent models and bonus weapons.")
|
|
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)
|
|
playerStates[ply] = nil
|
|
requestCooldowns[ply] = nil
|
|
end)
|
|
|
|
-- Returning true prevents Sandbox's unrestricted cl_playermodel selection from
|
|
-- bypassing the shop. The equipped, server-owned model is the only source.
|
|
hook.Add("PlayerSetModel", "CustomCoopProgressionApplyModel", function(ply)
|
|
ApplyModel(ply)
|
|
return true
|
|
end)
|
|
|
|
hook.Add("PlayerSpawn", "CustomCoopProgressionApplyLoadout", function(ply)
|
|
ply.CustomCoopGrantedWeapon = nil
|
|
|
|
timer.Simple(0.5, function()
|
|
if IsValid(ply) then ApplyWeapon(ply) end
|
|
end)
|
|
|
|
-- mapperstuff.lua restores its carried model two seconds after some map
|
|
-- changes; reassert the selected shop model immediately afterward.
|
|
timer.Simple(2.2, function()
|
|
if IsValid(ply) then ApplyModel(ply) end
|
|
end)
|
|
end)
|
|
|
|
hook.Add("PlayerSpawnedNPC", "CustomCoopProgressionExcludeSpawnedNPC", function(_, npc)
|
|
if IsValid(npc) then npc.CustomCoopNoScore = true end
|
|
end)
|
|
|
|
hook.Add("OnNPCKilled", "CustomCoopProgressionMonsterKilled", function(npc, attacker, inflictor)
|
|
if not IsValid(npc) or npc.CustomCoopNoScore or npc.CustomCoopScoreAwarded then return end
|
|
if not Progression.MonsterClasses[npc:GetClass()] then return end
|
|
|
|
local creator = npc:GetCreator()
|
|
if IsValid(creator) and creator:IsPlayer() then return end
|
|
|
|
local ply = ResolvePlayerAttacker(attacker, inflictor)
|
|
if not IsValid(ply) or ply:IsBot() then return end
|
|
|
|
npc.CustomCoopScoreAwarded = true
|
|
AwardMonsterKill(ply)
|
|
end)
|
|
|
|
hook.Add("PlayerDeath", "CustomCoopProgressionDeathPenalty", function(victim)
|
|
if not IsValid(victim) or victim:IsBot() then return end
|
|
ChargeForDeath(victim)
|
|
end)
|
|
|
|
hook.Add("PlayerSay", "CustomCoopProgressionChatCommands", function(ply, text)
|
|
local command = string.Trim(string.lower(text or ""))
|
|
if command == "!shop" or command == "/shop" then
|
|
SendShop(ply)
|
|
return ""
|
|
end
|
|
|
|
if command == "!points" or command == "/points" then
|
|
local state = GetState(ply)
|
|
if state then
|
|
ply:ChatPrint(string.format(
|
|
"[CO-OP] Score: %d | Credits: %d | Rank: %s",
|
|
state.score, state.credits, Progression.GetRank(state.score)
|
|
))
|
|
end
|
|
return ""
|
|
end
|
|
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)
|