diff --git a/README.md b/README.md index c51ffc5..450308a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Score and therefore cannot lower a player's rank. Player controls: - `F3`, chat `!shop`, or client console `coop_shop`: open the rewards shop +- F3 `Players`: view the all-time rankings; right-click or double-click a row + to open that player's Steam profile - Chat `!points`: print personal Score, Credits, rank, and total playtime - Chat `!musicmute` or client console `coop_music_mute`: toggle purchased server music locally - Client console `coop_music_volume 0.0` through `coop_music_volume 1.0`: set music volume @@ -30,7 +32,9 @@ afterward. Each player can mute or adjust the music locally. The permanent weapon catalogue contains all thirteen spawnable weapons from the gamemode's `CO-OP` Q-menu category: Glock, P228, USP, Five-Seven, MAC-10, MP5, Pump Shotgun, FAMAS, AK-47, M4A1, Desert Eagle, Automatic Shotgun, and M249. The -separate Workshop novelty weapons remain available at their higher prices. +Workshop novelty weapons are Nyan Gun, Lightsaber, HAAAX! SWEP, Popcorn +Spammable, Orbital Friendship Cannon, and Sexyness (2-bit). They are permanent +unlocks at their higher prices. Music entries may set `spatial = true` plus `minimumDistance` and `maximumDistance`. Spatial music follows the buyer's character while they move @@ -83,14 +87,26 @@ cannot award points, submit a purchase price, or debit their own Credits. Administrators get an `Admin Prices` tab in the F3 shop. Select an item, enter a non-negative Credit price, and save it. The server permission-checks and persists the change in SQLite, then broadcasts the new catalogue to all connected clients. -The adjacent `Admin Players` tab lists every SQLite player record, including -offline players, with Lifetime Score, Credits, playtime, equipped model, weapon, -trail, enabled behaviours, and permanent purchases. Connected players' newer -in-memory totals are merged into the view without forcing a synchronous save. For testing, an administrator who cannot afford an item is allowed to unlock it without a Credit charge; the server reports the shortfall and Credits remain at their current non-negative value. Set `Progression.AdminShopTestingBypass` to `false` to disable this behavior. + +Every player gets one `Players` tab containing all SQLite player records, +including offline players, sorted by all-time Lifetime Score. The public payload +contains only SteamID64, name, online status, rank inputs, Score, Credits, and +playtime. It never sends equipped models, weapons, trails, behaviours, or +purchases. SteamID64 is used only for the explicit profile action; the list has +no automatic player-detail tooltip. Offline history is loaded once when F3 opens; +online rows and ranking then update automatically from the same networked Score, +Credits, and playtime fields used by the regular TAB scoreboard. There is no +manual refresh button, and the regular TAB scoreboard is unchanged. For admins, +the same table adds model, weapon, trail, and purchase-count columns plus hover +and selected-row details for enabled behaviours and permanent purchases. The +server appends these private fields only to responses for an authenticated admin; +it does not send them to guests and hide them in the UI. Connected players' +newer in-memory totals are merged without forcing a synchronous SQLite save. + The equivalent server/admin console command is: `coop_setprice ` @@ -128,5 +144,6 @@ back it up separately. Player-spawned NPCs and unknown/friendly NPC classes do not award points. -Shop refreshes preserve the selected F3 tab and catalogue scroll position, so -buying or equipping an item does not return the window to Player Models. +Purchase, equip, behaviour, and price responses update the existing F3 controls +in place. They do not recreate, close, move, or scroll the window, so there is no +visible frame flash and the selected tab and scroll position stay untouched. diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua index 34c8bfb..d923dbd 100644 --- a/gamemode/cl_progression.lua +++ b/gamemode/cl_progression.lua @@ -17,18 +17,19 @@ local shopState = { enabledBehaviours = {}, prices = {}, isAdmin = false, - adminPlayers = {}, - adminPlayersLoading = false + players = {}, + playersLoading = false, + playersHaveDetails = false } local shopViewState = { - activeTab = "Player Models", - scroll = {} + activeTab = "Player Models" } -local adminPlayersList -local adminPlayersStatus -local adminPlayerDetails -local adminPlayerRequestID = 0 -local RefreshAdminPlayersView +local playersList +local playersStatus +local playerDetails +local playerRequestID = 0 +local RefreshPlayersView +local RefreshShopFrame local activeMusicChannel local activeMusicSource local activeMusicIsSpatial = false @@ -92,17 +93,15 @@ local function RequestShop() net.SendToServer() end -local function RequestAdminPlayers() - if not shopState.isAdmin then return end +local function RequestPlayers() + playerRequestID = (playerRequestID + 1) % 65536 + shopState.players = {} + shopState.playersLoading = true + if IsValid(playersStatus) then playersStatus:SetText("Loading all-time player records...") end + if IsValid(playersList) then playersList:Clear() end - adminPlayerRequestID = (adminPlayerRequestID + 1) % 65536 - shopState.adminPlayers = {} - shopState.adminPlayersLoading = true - if IsValid(adminPlayersStatus) then adminPlayersStatus:SetText("Loading all player records...") end - if IsValid(adminPlayersList) then adminPlayersList:Clear() end - - net.Start("CustomCoopProgressionRequestAdminPlayers") - net.WriteUInt(adminPlayerRequestID, 16) + net.Start("CustomCoopProgressionRequestPlayers") + net.WriteUInt(playerRequestID, 16) net.SendToServer() end @@ -187,23 +186,12 @@ local function AddItemCard(layout, itemType, item) local isHelper = itemType == "helper" local isSpawnPurchase = isEntity or isHelper local isConsumable = isBehaviour and item.consumable == true - local owned = isModel and shopState.ownedModels[item.id] - or isWeapon and shopState.ownedWeapons[item.id] - or isMusic and shopState.ownedMusic[item.id] - or isBehaviour and shopState.ownedBehaviours[item.id] - or isTrail and shopState.ownedTrails[item.id] - local selected = isModel and shopState.equippedModel == item.id - or isWeapon and shopState.equippedWeapon == item.id - or isBehaviour and shopState.enabledBehaviours[item.id] - or isTrail and shopState.equippedTrail == item.id - local currentPrice = CurrentPrice(itemType, item) - local card = layout:Add("DPanel") card:SetSize(252, 292) card.Paint = function(self, width, height) local color = self:IsHovered() and colors.panelHover or colors.panel draw.RoundedBox(8, 0, 0, width, height, color) - if selected then + if self.CustomCoopSelected then surface.SetDrawColor(colors.success) surface.DrawOutlinedRect(0, 0, width, height, 2) end @@ -256,8 +244,7 @@ local function AddItemCard(layout, itemType, item) description:SetPos(12, 202) description:SetSize(228, 38) - local priceText = currentPrice == 0 and "FREE" or (currentPrice .. " credits") - local price = AddText(card, priceText, "CustomCoopProgressionBody", currentPrice <= shopState.credits and colors.success or colors.warning) + local price = AddText(card, "", "CustomCoopProgressionBody", colors.warning) price:SetPos(12, 239) price:SetSize(100, 22) @@ -265,31 +252,59 @@ local function AddItemCard(layout, itemType, item) button:SetPos(112, 244) button:SetSize(128, 36) button:SetFont("CustomCoopProgressionBody") - local actionText - if isMusic then - actionText = (owned or currentPrice == 0) and "Play" or "Buy" - elseif isSpawnPurchase then - actionText = "Buy & Spawn" - elseif isBehaviour then - actionText = isConsumable and "Activate" - or owned and (selected and "Disable" or "Enable") - or "Buy & Enable" - else - actionText = selected and "Equipped" or (owned or currentPrice == 0) and "Equip" or "Buy" + local function RefreshCard(clearPending) + if clearPending then button.CustomCoopPending = false end + local owned = isModel and shopState.ownedModels[item.id] + or isWeapon and shopState.ownedWeapons[item.id] + or isMusic and shopState.ownedMusic[item.id] + or isBehaviour and shopState.ownedBehaviours[item.id] + or isTrail and shopState.ownedTrails[item.id] + local selected = isModel and shopState.equippedModel == item.id + or isWeapon and shopState.equippedWeapon == item.id + or isBehaviour and shopState.enabledBehaviours[item.id] + or isTrail and shopState.equippedTrail == item.id + local currentPrice = CurrentPrice(itemType, item) + local actionText + + if isMusic then + actionText = (owned or currentPrice == 0) and "Play" or "Buy" + elseif isSpawnPurchase then + actionText = "Buy & Spawn" + elseif isBehaviour then + actionText = isConsumable and "Activate" + or owned and (selected and "Disable" or "Enable") + or "Buy & Enable" + else + actionText = selected and "Equipped" or (owned or currentPrice == 0) and "Equip" or "Buy" + end + + card.CustomCoopSelected = selected == true + price:SetText(currentPrice == 0 and "FREE" or (currentPrice .. " credits")) + price:SetTextColor(currentPrice <= shopState.credits and colors.success or colors.warning) + button.CustomCoopActionText = actionText + if not button.CustomCoopPending then + button:SetText(actionText) + button:SetEnabled(isBehaviour or not selected) + end + end + + card.CustomCoopRefresh = RefreshCard + if IsValid(shopFrame) then + shopFrame.CustomCoopCards[#shopFrame.CustomCoopCards + 1] = card end - button:SetText(actionText) - button:SetEnabled(isBehaviour or not selected) button.DoClick = function() + button.CustomCoopPending = true button:SetEnabled(false) button:SetText("Please wait...") SelectItem(itemType, item.id) timer.Simple(1, function() if IsValid(button) then - button:SetEnabled(isBehaviour or not selected) - button:SetText(actionText) + button.CustomCoopPending = false + RefreshCard() end end) end + RefreshCard() end local function CreateCataloguePage(sheet, title, itemType, items) @@ -314,13 +329,6 @@ local function CreateCataloguePage(sheet, title, itemType, items) page.CustomCoopScroll = scroll sheet:AddSheet(title, page) - - local savedScroll = shopViewState.scroll[title] - if savedScroll and savedScroll > 0 then - timer.Simple(0, function() - if IsValid(scroll) then scroll:GetVBar():SetScroll(savedScroll) end - end) - end end local function SendPriceChange(itemType, itemID, price) @@ -389,6 +397,8 @@ local function CreateAdminPage(sheet) local row = list:AddLine(catalogue.itemType, item.id, item.name, CurrentPrice(catalogue.itemType, item)) row.CustomCoopItemType = catalogue.itemType row.CustomCoopItemID = item.id + row.CustomCoopItem = item + shopFrame.CustomCoopPriceRows[#shopFrame.CustomCoopPriceRows + 1] = row end end @@ -406,6 +416,11 @@ local function CreateAdminPage(sheet) save:SetText("Saving...") end + shopFrame.CustomCoopPriceSave = save + shopFrame.CustomCoopSelectedPriceRow = function() + return selectedRow + end + sheet:AddSheet("Admin Prices", page) end @@ -451,34 +466,64 @@ local function DescribeAdminPlayer(record) ) end -RefreshAdminPlayersView = function() - if not IsValid(adminPlayersList) then return end +local function OpenSteamProfile(record) + local steamID64 = tostring(record and record.steamID64 or "") + if #steamID64 ~= 17 or not string.match(steamID64, "^%d+$") then + notification.AddLegacy("That player has no valid Steam profile ID.", NOTIFY_ERROR, 4) + return + end + gui.OpenURL("https://steamcommunity.com/profiles/" .. steamID64) +end - adminPlayersList:Clear() - for _, record in ipairs(shopState.adminPlayers) do - local row = adminPlayersList:AddLine( +RefreshPlayersView = function() + if not IsValid(playersList) then return end + + playersList:Clear() + for position, record in ipairs(shopState.players) do + local baseColumns = { + position, record.playerName, record.online and "Online" or "Offline", - record.steamID64, + Progression.GetRank(record.score), record.score, record.credits, - Progression.FormatPlaytime(record.playtime), - CatalogueItemName("model", record.equippedModel), - CatalogueItemName("weapon", record.equippedWeapon), - CatalogueItemName("trail", record.equippedTrail), - #(record.purchases or {}) - ) - row.CustomCoopAdminPlayer = record - row:SetTooltip(DescribeAdminPlayer(record)) + Progression.FormatPlaytime(record.playtime) + } + local row + if shopState.playersHaveDetails then + row = playersList:AddLine( + position, + record.playerName, + record.online and "Online" or "Offline", + Progression.GetRank(record.score), + record.score, + record.credits, + Progression.FormatPlaytime(record.playtime), + record.steamID64, + CatalogueItemName("model", record.equippedModel), + CatalogueItemName("weapon", record.equippedWeapon), + CatalogueItemName("trail", record.equippedTrail), + #(record.purchases or {}) + ) + row:SetTooltip(DescribeAdminPlayer(record)) + else + row = playersList:AddLine(unpack(baseColumns)) + end + row.CustomCoopPlayer = record end - if IsValid(adminPlayersStatus) then - local suffix = shopState.adminPlayersLoading and " (loading...)" or "" - adminPlayersStatus:SetText(string.format("%d player record%s%s", #shopState.adminPlayers, #shopState.adminPlayers == 1 and "" or "s", suffix)) + if IsValid(playersStatus) then + local suffix = shopState.playersLoading and " (loading...)" or "" + playersStatus:SetText(string.format( + "%d all-time player%s%s — live ranking; right-click a row to open its Steam profile", + #shopState.players, + #shopState.players == 1 and "" or "s", + suffix + )) end end -local function CreateAdminPlayersPage(sheet) +local function CreatePlayersPage(sheet) local page = vgui.Create("DPanel", sheet) page:DockPadding(10, 10, 10, 10) page.Paint = function(_, width, height) @@ -491,85 +536,168 @@ local function CreateAdminPlayersPage(sheet) toolbar:DockMargin(0, 0, 0, 8) toolbar.Paint = nil - local refresh = vgui.Create("DButton", toolbar) - refresh:Dock(RIGHT) - refresh:SetWide(150) - refresh:SetText("Refresh players") - refresh:SetFont("CustomCoopProgressionBody") - refresh.DoClick = RequestAdminPlayers + playersStatus = AddText(toolbar, "Loading all-time player records...", "CustomCoopProgressionSmall", colors.muted) + playersStatus:Dock(FILL) + playersStatus:SetContentAlignment(4) - adminPlayersStatus = AddText(toolbar, "Loading all player records...", "CustomCoopProgressionSmall", colors.muted) - adminPlayersStatus:Dock(FILL) - adminPlayersStatus:SetContentAlignment(4) + if shopState.isAdmin then + local detailsPanel = vgui.Create("DPanel", page) + detailsPanel:Dock(BOTTOM) + detailsPanel:SetTall(92) + detailsPanel:DockMargin(0, 8, 0, 0) + detailsPanel:DockPadding(10, 8, 10, 8) + detailsPanel.Paint = function(_, width, height) + draw.RoundedBox(5, 0, 0, width, height, colors.panel) + end - local detailsPanel = vgui.Create("DPanel", page) - detailsPanel:Dock(BOTTOM) - detailsPanel:SetTall(92) - detailsPanel:DockMargin(0, 8, 0, 0) - detailsPanel:DockPadding(10, 8, 10, 8) - detailsPanel.Paint = function(_, width, height) - draw.RoundedBox(5, 0, 0, width, height, colors.panel) + playerDetails = AddText( + detailsPanel, + "Select a player to inspect equipped items, enabled behaviours, and permanent purchases.", + "CustomCoopProgressionSmall", + colors.muted + ) + playerDetails:Dock(FILL) end - adminPlayerDetails = AddText( - detailsPanel, - "Select a player to inspect equipped items, enabled behaviours, and permanent purchases.", - "CustomCoopProgressionSmall", - colors.muted - ) - adminPlayerDetails:Dock(FILL) - - adminPlayersList = vgui.Create("DListView", page) - adminPlayersList:Dock(FILL) - adminPlayersList:SetMultiSelect(false) - adminPlayersList:AddColumn("Name") - adminPlayersList:AddColumn("Status"):SetFixedWidth(58) - adminPlayersList:AddColumn("SteamID64"):SetFixedWidth(145) - adminPlayersList:AddColumn("XP / Score"):SetFixedWidth(78) - adminPlayersList:AddColumn("Credits"):SetFixedWidth(68) - adminPlayersList:AddColumn("Played"):SetFixedWidth(78) - adminPlayersList:AddColumn("Model"):SetFixedWidth(115) - adminPlayersList:AddColumn("Weapon"):SetFixedWidth(125) - adminPlayersList:AddColumn("Trail"):SetFixedWidth(110) - adminPlayersList:AddColumn("Owned"):SetFixedWidth(55) - adminPlayersList.OnRowSelected = function(_, _, row) - if IsValid(adminPlayerDetails) and row.CustomCoopAdminPlayer then - adminPlayerDetails:SetText(DescribeAdminPlayer(row.CustomCoopAdminPlayer)) + playersList = vgui.Create("DListView", page) + playersList:Dock(FILL) + playersList:SetMultiSelect(false) + playersList:AddColumn("#"):SetFixedWidth(36) + playersList:AddColumn("Name") + playersList:AddColumn("Status"):SetFixedWidth(65) + playersList:AddColumn("Rank"):SetFixedWidth(105) + playersList:AddColumn("XP / Score"):SetFixedWidth(90) + playersList:AddColumn("Credits"):SetFixedWidth(75) + playersList:AddColumn("Played"):SetFixedWidth(90) + if shopState.isAdmin then + playersList:AddColumn("SteamID64"):SetFixedWidth(145) + playersList:AddColumn("Model"):SetFixedWidth(115) + playersList:AddColumn("Weapon"):SetFixedWidth(125) + playersList:AddColumn("Trail"):SetFixedWidth(110) + playersList:AddColumn("Owned"):SetFixedWidth(55) + playersList.OnRowSelected = function(_, _, row) + if IsValid(playerDetails) and row.CustomCoopPlayer then + playerDetails:SetText(DescribeAdminPlayer(row.CustomCoopPlayer)) + end end end + playersList.OnRowRightClick = function(_, _, row) + local record = row.CustomCoopPlayer + if not record then return end - sheet:AddSheet("Admin Players", page) - RefreshAdminPlayersView() - -- The shop-open request has a short server-side rate limit. Wait until it - -- expires before requesting the larger administrator-only dataset. - timer.Simple(0.3, function() - if IsValid(page) then - RequestAdminPlayers() - end - end) + local menu = DermaMenu() + menu:AddOption("Open Steam profile / add friend", function() + OpenSteamProfile(record) + end) + menu:AddOption("Copy SteamID64", function() + SetClipboardText(record.steamID64) + end) + menu:Open() + end + playersList.DoDoubleClick = function(_, _, row) + if row.CustomCoopPlayer then OpenSteamProfile(row.CustomCoopPlayer) end + end + + sheet:AddSheet("Players", page) + RefreshPlayersView() + timer.Simple(0.3, function() + if IsValid(page) then RequestPlayers() end + end) end -local function CaptureShopViewState() - if not IsValid(shopFrame) or not IsValid(shopFrame.CustomCoopSheet) then return end +local nextLivePlayerUpdate = 0 +hook.Add("Think", "CustomCoopProgressionLivePlayerList", function() + if not IsValid(playersList) or RealTime() < nextLivePlayerUpdate then return end + nextLivePlayerUpdate = RealTime() + 0.25 - local sheet = shopFrame.CustomCoopSheet - local activeTab = sheet:GetActiveTab() - if IsValid(activeTab) then shopViewState.activeTab = activeTab:GetText() end + local recordsBySteamID = {} + for _, record in ipairs(shopState.players) do + recordsBySteamID[record.steamID64] = record + end - for _, item in ipairs(sheet.Items or {}) do - if IsValid(item.Tab) and IsValid(item.Panel) and IsValid(item.Panel.CustomCoopScroll) then - shopViewState.scroll[item.Tab:GetText()] = item.Panel.CustomCoopScroll:GetVBar():GetScroll() + local onlineSteamIDs = {} + local changed = false + for _, ply in ipairs(player.GetAll()) do + if IsValid(ply) and not ply:IsBot() then + local steamID64 = ply:SteamID64() + if steamID64 and steamID64 ~= "" and steamID64 ~= "0" then + onlineSteamIDs[steamID64] = true + local record = recordsBySteamID[steamID64] + if not record then + record = { + steamID64 = steamID64, + playerName = ply:Nick(), + online = true, + score = 0, + credits = 0, + playtime = 0 + } + shopState.players[#shopState.players + 1] = record + recordsBySteamID[steamID64] = record + changed = true + end + + local playerName = ply:Nick() + local score = ply:GetNW2Int("CustomCoopScore", record.score) + local credits = ply:GetNW2Int("CustomCoopCredits", record.credits) + local playtime = ply:GetNW2Int("CustomCoopPlaytime", record.playtime) + if record.playerName ~= playerName or not record.online + or record.score ~= score or record.credits ~= credits or record.playtime ~= playtime then + record.playerName = playerName + record.online = true + record.score = score + record.credits = credits + record.playtime = playtime + changed = true + end + end end end + + for _, record in ipairs(shopState.players) do + if record.online and not onlineSteamIDs[record.steamID64] then + record.online = false + changed = true + end + end + + if not changed then return end + table.sort(shopState.players, function(left, right) + if left.score ~= right.score then return left.score > right.score end + if left.playtime ~= right.playtime then return left.playtime > right.playtime end + return string.lower(left.playerName) < string.lower(right.playerName) + end) + RefreshPlayersView() +end) + +RefreshShopFrame = function() + if not IsValid(shopFrame) then return end + + for _, card in ipairs(shopFrame.CustomCoopCards or {}) do + if IsValid(card) and card.CustomCoopRefresh then card.CustomCoopRefresh(true) end + end + for _, row in ipairs(shopFrame.CustomCoopPriceRows or {}) do + if IsValid(row) and row.CustomCoopItem then + row:SetColumnText(4, CurrentPrice(row.CustomCoopItemType, row.CustomCoopItem)) + end + end + if IsValid(shopFrame.CustomCoopPriceSave) then + shopFrame.CustomCoopPriceSave:SetText("Save selected price") + shopFrame.CustomCoopPriceSave:SetEnabled( + IsValid(shopFrame.CustomCoopSelectedPriceRow and shopFrame.CustomCoopSelectedPriceRow()) + ) + end end local function OpenShop() - CaptureShopViewState() - if IsValid(shopFrame) then shopFrame:Remove() end + if IsValid(shopFrame) then + RefreshShopFrame() + return + end - adminPlayersList = nil - adminPlayersStatus = nil - adminPlayerDetails = nil + playersList = nil + playersStatus = nil + playerDetails = nil shopFrame = vgui.Create("DFrame") shopFrame:SetSize(math.min(ScrW() - 40, shopState.isAdmin and 1240 or 850), math.min(ScrH() - 40, 650)) @@ -577,6 +705,8 @@ local function OpenShop() shopFrame:SetTitle("") shopFrame:ShowCloseButton(true) shopFrame:MakePopup() + shopFrame.CustomCoopCards = {} + shopFrame.CustomCoopPriceRows = {} shopFrame.OnKeyCodePressed = function(self, keyCode) if keyCode == KEY_F3 then ToggleShop() @@ -644,9 +774,9 @@ local function OpenShop() CreateCataloguePage(sheet, "Player Trails", "trail", Progression.Trails) CreateCataloguePage(sheet, "Entities", "entity", Progression.Entities) CreateCataloguePage(sheet, "Helping NPCs", "helper", Progression.Helpers) + CreatePlayersPage(sheet) if shopState.isAdmin then CreateAdminPage(sheet) - CreateAdminPlayersPage(sheet) end sheet.OnActiveTabChanged = function(_, _, newTab) @@ -707,23 +837,31 @@ net.Receive("CustomCoopProgressionOpenShop", function() shopState.isAdmin = net.ReadBool() shopState.prices = ReadShopPrices() - OpenShop() + if IsValid(shopFrame) then + RefreshShopFrame() + else + OpenShop() + end end) net.Receive("CustomCoopProgressionPricesChanged", function() shopState.prices = ReadShopPrices() - if IsValid(shopFrame) then OpenShop() end + RefreshShopFrame() notification.AddLegacy("Shop prices were updated by an administrator.", NOTIFY_GENERIC, 4) end) -net.Receive("CustomCoopProgressionAdminPlayers", function() +net.Receive("CustomCoopProgressionPlayers", function() local requestID = net.ReadUInt(16) - if requestID ~= adminPlayerRequestID then return end + if requestID ~= playerRequestID then return end local reset = net.ReadBool() local finished = net.ReadBool() + local hasDetails = net.ReadBool() local count = net.ReadUInt(8) - if reset then shopState.adminPlayers = {} end + if reset then + shopState.players = {} + shopState.playersHaveDetails = hasDetails + end for _ = 1, count do local record = { @@ -732,28 +870,30 @@ net.Receive("CustomCoopProgressionAdminPlayers", function() online = net.ReadBool(), score = net.ReadUInt(32), credits = net.ReadUInt(32), - playtime = net.ReadUInt(32), - equippedModel = net.ReadString(), - equippedWeapon = net.ReadString(), - equippedTrail = net.ReadString(), - purchases = {}, - enabledBehaviours = {} + playtime = net.ReadUInt(32) } - for _ = 1, net.ReadUInt(8) do - record.purchases[#record.purchases + 1] = { - itemType = net.ReadString(), - itemID = net.ReadString(), - purchasedAt = net.ReadUInt(32) - } + if hasDetails then + record.equippedModel = net.ReadString() + record.equippedWeapon = net.ReadString() + record.equippedTrail = net.ReadString() + record.purchases = {} + record.enabledBehaviours = {} + for _ = 1, net.ReadUInt(8) do + record.purchases[#record.purchases + 1] = { + itemType = net.ReadString(), + itemID = net.ReadString(), + purchasedAt = net.ReadUInt(32) + } + end + for _ = 1, net.ReadUInt(8) do + record.enabledBehaviours[#record.enabledBehaviours + 1] = net.ReadString() + end end - for _ = 1, net.ReadUInt(8) do - record.enabledBehaviours[#record.enabledBehaviours + 1] = net.ReadString() - end - shopState.adminPlayers[#shopState.adminPlayers + 1] = record + shopState.players[#shopState.players + 1] = record end - shopState.adminPlayersLoading = not finished - RefreshAdminPlayersView() + shopState.playersLoading = not finished + RefreshPlayersView() end) net.Receive("CustomCoopProgressionNotice", function() diff --git a/gamemode/progression_shared.lua b/gamemode/progression_shared.lua index 76a61fc..ae80741 100644 --- a/gamemode/progression_shared.lua +++ b/gamemode/progression_shared.lua @@ -211,6 +211,20 @@ Progression.Weapons = { price = 1000, description = "A permanent weapon from a galaxy far, far away." }, + { + id = "haax_gun", + name = "HAAAX! SWEP", + weaponClass = "weapon_haxgun", + price = 2000, + description = "Weaponized error spam and terminal trouble. Unlocks permanently." + }, + { + id = "popcorn_spammable", + name = "Popcorn Spammable", + weaponClass = "weapon_popcorn_spam", + price = 750, + description = "Cover the battlefield in an unreasonable amount of popcorn." + }, { id = "orbital_friendship_cannon", name = "Orbital Friendship Cannon", diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 407446a..8f45c8c 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -13,6 +13,7 @@ end runtime.playerStates = runtime.playerStates or setmetatable({}, { __mode = "k" }) runtime.disconnectedStates = runtime.disconnectedStates or {} runtime.requestCooldowns = runtime.requestCooldowns or setmetatable({}, { __mode = "k" }) +runtime.publicPlayerRequestCooldowns = runtime.publicPlayerRequestCooldowns or setmetatable({}, { __mode = "k" }) runtime.purchasedSpawns = runtime.purchasedSpawns or setmetatable({}, { __mode = "k" }) runtime.shopPrices = runtime.shopPrices or {} runtime.dirtyShopPrices = runtime.dirtyShopPrices or {} @@ -21,6 +22,7 @@ runtime.availableMusic = runtime.availableMusic or {} local playerStates = runtime.playerStates local disconnectedStates = runtime.disconnectedStates local requestCooldowns = runtime.requestCooldowns +local publicPlayerRequestCooldowns = runtime.publicPlayerRequestCooldowns local purchasedSpawns = runtime.purchasedSpawns local shopPrices = runtime.shopPrices local dirtyShopPrices = runtime.dirtyShopPrices @@ -43,8 +45,8 @@ util.AddNetworkString("CustomCoopProgressionSetPrice") util.AddNetworkString("CustomCoopProgressionPricesChanged") util.AddNetworkString("CustomCoopProgressionPlayMusic") util.AddNetworkString("CustomCoopProgressionToggleMusicMute") -util.AddNetworkString("CustomCoopProgressionRequestAdminPlayers") -util.AddNetworkString("CustomCoopProgressionAdminPlayers") +util.AddNetworkString("CustomCoopProgressionRequestPlayers") +util.AddNetworkString("CustomCoopProgressionPlayers") local function Query(query, context) local startedAt = SysTime() @@ -916,18 +918,83 @@ local function LoadAdminPlayerRecords() end table.sort(records, function(left, right) if left.score ~= right.score then return left.score > right.score end - if left.credits ~= right.credits then return left.credits > right.credits end + if left.playtime ~= right.playtime then return left.playtime > right.playtime end return string.lower(left.playerName) < string.lower(right.playerName) end) return records end -local function SendAdminPlayerBatch(ply, requestID, records, firstIndex, lastIndex) +local function LoadPublicPlayerRecords() + local recordsBySteamID = {} + local records = {} + + local function GetRecord(steamID64) + steamID64 = tostring(steamID64 or "") + if steamID64 == "" then return end + if recordsBySteamID[steamID64] then return recordsBySteamID[steamID64] end + + local record = { + steamID64 = steamID64, + playerName = steamID64, + online = false, + score = 0, + credits = 0, + playtime = 0 + } + recordsBySteamID[steamID64] = record + records[#records + 1] = record + return record + end + + local rows = Query( + "SELECT steamid64, player_name, lifetime_score, credits, playtime_seconds " .. + "FROM custom_coop_players", + "could not load public player list" + ) + if istable(rows) then + for _, row in ipairs(rows) do + local record = GetRecord(row.steamid64) + if record then + record.playerName = tostring(row.player_name or record.steamID64) + record.score = math.max(math.floor(tonumber(row.lifetime_score) or 0), 0) + record.credits = math.max(math.floor(tonumber(row.credits) or 0), 0) + record.playtime = math.max(math.floor(tonumber(row.playtime_seconds) or 0), 0) + end + end + end + + local function ApplyState(record, state, playerName, online) + if not record or not state then return end + record.playerName = tostring(playerName or state.playerName or record.playerName) + record.online = online == true + record.score = math.max(math.floor(tonumber(state.score) or 0), 0) + record.credits = math.max(math.floor(tonumber(state.credits) or 0), 0) + record.playtime = math.max(math.floor(tonumber(state.playtimeSeconds) or 0), 0) + end + + for ply, state in pairs(playerStates) do + if IsValid(ply) then AccruePlaytime(ply, state) end + ApplyState(GetRecord(state.steamID64), state, IsValid(ply) and ply:Nick() or nil, IsValid(ply)) + end + for steamID64, state in pairs(disconnectedStates) do + ApplyState(GetRecord(steamID64), state, nil, false) + end + + table.sort(records, function(left, right) + if left.score ~= right.score then return left.score > right.score end + if left.playtime ~= right.playtime then return left.playtime > right.playtime end + return string.lower(left.playerName) < string.lower(right.playerName) + end) + return records +end + +local function SendPlayerBatch(ply, requestID, records, firstIndex, lastIndex, includeDetails) local count = math.max(lastIndex - firstIndex + 1, 0) - net.Start("CustomCoopProgressionAdminPlayers") + net.Start("CustomCoopProgressionPlayers") net.WriteUInt(requestID, 16) net.WriteBool(firstIndex == 1) net.WriteBool(lastIndex >= #records) + net.WriteBool(includeDetails) net.WriteUInt(math.min(count, 255), 8) for index = firstIndex, lastIndex do local record = records[index] @@ -937,34 +1004,44 @@ local function SendAdminPlayerBatch(ply, requestID, records, firstIndex, lastInd net.WriteUInt(math.min(record.score, 4294967295), 32) net.WriteUInt(math.min(record.credits, 4294967295), 32) net.WriteUInt(math.min(record.playtime, 4294967295), 32) - net.WriteString(record.equippedModel) - net.WriteString(record.equippedWeapon) - net.WriteString(record.equippedTrail) - net.WriteUInt(math.min(#record.purchases, 255), 8) - for purchaseIndex = 1, math.min(#record.purchases, 255) do - local purchase = record.purchases[purchaseIndex] - net.WriteString(purchase.itemType) - net.WriteString(purchase.itemID) - net.WriteUInt(math.min(purchase.purchasedAt, 4294967295), 32) - end - net.WriteUInt(math.min(#record.enabledBehaviours, 255), 8) - for behaviourIndex = 1, math.min(#record.enabledBehaviours, 255) do - net.WriteString(record.enabledBehaviours[behaviourIndex]) + if includeDetails then + net.WriteString(record.equippedModel) + net.WriteString(record.equippedWeapon) + net.WriteString(record.equippedTrail) + net.WriteUInt(math.min(#record.purchases, 255), 8) + for purchaseIndex = 1, math.min(#record.purchases, 255) do + local purchase = record.purchases[purchaseIndex] + net.WriteString(purchase.itemType) + net.WriteString(purchase.itemID) + net.WriteUInt(math.min(purchase.purchasedAt, 4294967295), 32) + end + net.WriteUInt(math.min(#record.enabledBehaviours, 255), 8) + for behaviourIndex = 1, math.min(#record.enabledBehaviours, 255) do + net.WriteString(record.enabledBehaviours[behaviourIndex]) + end end end net.Send(ply) end -local function SendAdminPlayers(ply, requestID) - local records = LoadAdminPlayerRecords() +local function SendPlayers(ply, requestID) + local includeDetails = ply:IsAdmin() + local records = includeDetails and LoadAdminPlayerRecords() or LoadPublicPlayerRecords() if #records == 0 then - SendAdminPlayerBatch(ply, requestID, records, 1, 0) + SendPlayerBatch(ply, requestID, records, 1, 0, includeDetails) return end - local batchSize = 25 + local batchSize = includeDetails and 25 or 50 for firstIndex = 1, #records, batchSize do - SendAdminPlayerBatch(ply, requestID, records, firstIndex, math.min(firstIndex + batchSize - 1, #records)) + SendPlayerBatch( + ply, + requestID, + records, + firstIndex, + math.min(firstIndex + batchSize - 1, #records), + includeDetails + ) end end @@ -1611,15 +1688,12 @@ net.Receive("CustomCoopProgressionRequestShop", function(_, ply) SendShop(ply) end) -net.Receive("CustomCoopProgressionRequestAdminPlayers", function(_, ply) +net.Receive("CustomCoopProgressionRequestPlayers", function(_, ply) if not RequestAllowed(ply) then return end - - local requestID = net.ReadUInt(16) - if not ply:IsAdmin() then - SendNotice(ply, false, "Only server administrators can view all player records.") - return - end - SendAdminPlayers(ply, requestID) + local now = CurTime() + if (publicPlayerRequestCooldowns[ply] or 0) > now then return end + publicPlayerRequestCooldowns[ply] = now + 2 + SendPlayers(ply, net.ReadUInt(16)) end) net.Receive("CustomCoopProgressionSelectItem", function(_, ply)