Preparing mission
+ +brunner.ninja
+ +diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27db8fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] + diff --git a/README.md b/README.md index a6a4a26..c51ffc5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# garrysmod-coop +# feedc0de Coop Campaign ## Persistent progression and rewards shop @@ -83,6 +83,10 @@ 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 @@ -123,3 +127,6 @@ Keep locally supplied audio outside the gamemode source tree and distribute or 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. diff --git a/custom_coop.txt b/feedc0de_coop.txt similarity index 88% rename from custom_coop.txt rename to feedc0de_coop.txt index df05571..e0876c2 100644 --- a/custom_coop.txt +++ b/feedc0de_coop.txt @@ -1,9 +1,10 @@ -"coopgamemode" -{ - "base" "sandbox" - "title" "Feedc0de's coop Mode" - "menusystem" "1" - "maps" "coop" +"feedc0de_coop" +{ + "base" "sandbox" + "title" "feedc0de Coop Campaign" + "category" "pve" + "menusystem" "1" + "maps" "coop" "settings" { 1 diff --git a/gamemode/cl_progression.lua b/gamemode/cl_progression.lua index 74dbedd..34c8bfb 100644 --- a/gamemode/cl_progression.lua +++ b/gamemode/cl_progression.lua @@ -16,8 +16,19 @@ local shopState = { ownedBehaviours = {}, enabledBehaviours = {}, prices = {}, - isAdmin = false + isAdmin = false, + adminPlayers = {}, + adminPlayersLoading = false } +local shopViewState = { + activeTab = "Player Models", + scroll = {} +} +local adminPlayersList +local adminPlayersStatus +local adminPlayerDetails +local adminPlayerRequestID = 0 +local RefreshAdminPlayersView local activeMusicChannel local activeMusicSource local activeMusicIsSpatial = false @@ -81,6 +92,20 @@ local function RequestShop() net.SendToServer() end +local function RequestAdminPlayers() + if not shopState.isAdmin then return 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.SendToServer() +end + local nextShopToggle = 0 local function ToggleShop() if RealTime() < nextShopToggle then return end @@ -287,7 +312,15 @@ local function CreateCataloguePage(sheet, title, itemType, items) AddItemCard(layout, itemType, item) end + 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) @@ -376,11 +409,170 @@ local function CreateAdminPage(sheet) sheet:AddSheet("Admin Prices", page) end +local function CatalogueItemName(itemType, itemID) + if not itemID or itemID == "" or itemID == "none" then return "None" end + + local item = itemType == "model" and Progression.GetModel(itemID) + or itemType == "weapon" and Progression.GetWeapon(itemID) + or itemType == "music" and Progression.GetMusic(itemID) + or itemType == "behaviour" and Progression.GetBehaviour(itemID) + or itemType == "trail" and Progression.GetTrail(itemID) + or itemType == "entity" and Progression.GetEntity(itemID) + or itemType == "helper" and Progression.GetHelper(itemID) + return item and item.name or itemID +end + +local function DescribeAdminPlayer(record) + local purchases = {} + for _, purchase in ipairs(record.purchases or {}) do + local purchasedAt = purchase.purchasedAt and purchase.purchasedAt > 0 + and os.date("%Y-%m-%d", purchase.purchasedAt) + or "unknown date" + purchases[#purchases + 1] = string.format( + "%s: %s (%s)", + string.upper(string.sub(purchase.itemType or "item", 1, 1)) .. string.sub(purchase.itemType or "item", 2), + CatalogueItemName(purchase.itemType, purchase.itemID), + purchasedAt + ) + end + + local enabled = {} + for _, itemID in ipairs(record.enabledBehaviours or {}) do + enabled[#enabled + 1] = CatalogueItemName("behaviour", itemID) + end + + return string.format( + "Equipped: %s model | %s weapon | %s trail\nEnabled: %s\nPermanent purchases: %s", + CatalogueItemName("model", record.equippedModel), + CatalogueItemName("weapon", record.equippedWeapon), + CatalogueItemName("trail", record.equippedTrail), + #enabled > 0 and table.concat(enabled, ", ") or "None", + #purchases > 0 and table.concat(purchases, ", ") or "None" + ) +end + +RefreshAdminPlayersView = function() + if not IsValid(adminPlayersList) then return end + + adminPlayersList:Clear() + for _, record in ipairs(shopState.adminPlayers) do + local row = adminPlayersList:AddLine( + record.playerName, + record.online and "Online" or "Offline", + record.steamID64, + 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)) + 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)) + end +end + +local function CreateAdminPlayersPage(sheet) + local page = vgui.Create("DPanel", sheet) + page:DockPadding(10, 10, 10, 10) + page.Paint = function(_, width, height) + draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35)) + end + + local toolbar = vgui.Create("DPanel", page) + toolbar:Dock(TOP) + toolbar:SetTall(34) + 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 + + adminPlayersStatus = AddText(toolbar, "Loading all player records...", "CustomCoopProgressionSmall", colors.muted) + adminPlayersStatus:Dock(FILL) + adminPlayersStatus:SetContentAlignment(4) + + 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 + + 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)) + end + 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) +end + +local function CaptureShopViewState() + if not IsValid(shopFrame) or not IsValid(shopFrame.CustomCoopSheet) then return end + + local sheet = shopFrame.CustomCoopSheet + local activeTab = sheet:GetActiveTab() + if IsValid(activeTab) then shopViewState.activeTab = activeTab:GetText() 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() + end + end +end + local function OpenShop() + CaptureShopViewState() if IsValid(shopFrame) then shopFrame:Remove() end + adminPlayersList = nil + adminPlayersStatus = nil + adminPlayerDetails = nil + shopFrame = vgui.Create("DFrame") - shopFrame:SetSize(math.min(ScrW() - 40, 850), math.min(ScrH() - 40, 650)) + shopFrame:SetSize(math.min(ScrW() - 40, shopState.isAdmin and 1240 or 850), math.min(ScrH() - 40, 650)) shopFrame:Center() shopFrame:SetTitle("") shopFrame:ShowCloseButton(true) @@ -443,6 +635,7 @@ local function OpenShop() local sheet = vgui.Create("DPropertySheet", shopFrame) sheet:SetPos(14, 104) sheet:SetSize(shopFrame:GetWide() - 28, shopFrame:GetTall() - 118) + shopFrame.CustomCoopSheet = sheet CreateCataloguePage(sheet, "Player Models", "model", Progression.Models) CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons) @@ -451,7 +644,20 @@ local function OpenShop() CreateCataloguePage(sheet, "Player Trails", "trail", Progression.Trails) CreateCataloguePage(sheet, "Entities", "entity", Progression.Entities) CreateCataloguePage(sheet, "Helping NPCs", "helper", Progression.Helpers) - if shopState.isAdmin then CreateAdminPage(sheet) end + if shopState.isAdmin then + CreateAdminPage(sheet) + CreateAdminPlayersPage(sheet) + end + + sheet.OnActiveTabChanged = function(_, _, newTab) + if IsValid(newTab) then shopViewState.activeTab = newTab:GetText() end + end + for _, item in ipairs(sheet.Items or {}) do + if IsValid(item.Tab) and item.Tab:GetText() == shopViewState.activeTab then + sheet:SetActiveTab(item.Tab) + break + end + end end local function ReadShopPrices() @@ -510,6 +716,46 @@ net.Receive("CustomCoopProgressionPricesChanged", function() notification.AddLegacy("Shop prices were updated by an administrator.", NOTIFY_GENERIC, 4) end) +net.Receive("CustomCoopProgressionAdminPlayers", function() + local requestID = net.ReadUInt(16) + if requestID ~= adminPlayerRequestID then return end + + local reset = net.ReadBool() + local finished = net.ReadBool() + local count = net.ReadUInt(8) + if reset then shopState.adminPlayers = {} end + + for _ = 1, count do + local record = { + steamID64 = net.ReadString(), + playerName = net.ReadString(), + 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 = {} + } + 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 + shopState.adminPlayers[#shopState.adminPlayers + 1] = record + end + + shopState.adminPlayersLoading = not finished + RefreshAdminPlayersView() +end) + net.Receive("CustomCoopProgressionNotice", function() local success = net.ReadBool() local message = net.ReadString() diff --git a/gamemode/maprotation.lua b/gamemode/maprotation.lua index 2ff7d70..f85de11 100644 --- a/gamemode/maprotation.lua +++ b/gamemode/maprotation.lua @@ -39,6 +39,7 @@ local nextMapVotes = {} local nextMapChoices = {} local nextMapVoteFinished = false local installedMaps +local failedMapChanges = {} local function RefreshInstalledMaps() installedMaps = {} @@ -50,13 +51,32 @@ end local function MapExists(mapName) if not mapName or mapName == "" then return false end if not installedMaps then RefreshInstalledMaps() end - return installedMaps[string.lower(string.StripExtension(mapName))] == true + local normalizedName = string.lower(string.StripExtension(mapName)) + return installedMaps[normalizedName] == true and not failedMapChanges[normalizedName] +end + +local function GetRandomAvailableMap(excludeLevel) + local candidates = {} + local excluded = string.lower(excludeLevel or game.GetMap() or "") + + for _, mapName in ipairs(GetInstalledCoopLevels()) do + if mapName ~= excluded and MapExists(mapName) then + candidates[#candidates + 1] = mapName + end + end + + if #candidates == 0 then return nil end + return candidates[math.random(#candidates)] end local function SetFreshNextLevel() local nextLevel = GetConVar("nextlevel"):GetString() if nextLevel == "" or not MapExists(nextLevel) or string.lower(nextLevel) == string.lower(game.GetMap()) then - nextLevel = NextRandomLevel() + nextLevel = GetRandomAvailableMap() + if nextLevel then + print("[custom_coop] Setting nextlevel to " .. nextLevel) + RunConsoleCommand("nextlevel", nextLevel) + end end return nextLevel end @@ -78,7 +98,7 @@ local function BuildNextMapChoices() local currentMap = string.lower(game.GetMap()) for _, mapName in ipairs(GetInstalledCoopLevels()) do - if mapName ~= currentMap then + if mapName ~= currentMap and MapExists(mapName) then candidates[#candidates + 1] = mapName end end @@ -215,7 +235,7 @@ end function QueueCoopMapChange(reason, requestedMap) if changeQueued then return end - local destination = requestedMap and string.lower(requestedMap) or nil + local destination = requestedMap and string.lower(string.StripExtension(requestedMap)) or nil if not MapExists(destination) or destination == string.lower(game.GetMap()) then destination = SetFreshNextLevel() end @@ -233,7 +253,34 @@ function QueueCoopMapChange(reason, requestedMap) print("[custom_coop] Map change queued by " .. (reason or "unknown") .. ": " .. destination) timer.Create("CustomCoopChangeCompletedMap", delay, 1, function() + local sourceMap = string.lower(game.GetMap() or "") RunConsoleCommand("changelevel", destination) + + -- A rejected changelevel leaves this Lua state alive. Without a + -- watchdog, changeQueued remains true forever and all later !skip + -- attempts incorrectly claim that a map change is still pending. + timer.Create("CustomCoopChangeLevelWatchdog", 10, 1, function() + if string.lower(game.GetMap() or "") ~= sourceMap then return end + + failedMapChanges[destination] = true + changeQueued = false + skipVotes = {} + nextMapVotes = {} + nextMapChoices = {} + nextMapVoteFinished = false + + if string.lower(GetConVar("nextlevel"):GetString()) == destination then + RunConsoleCommand("nextlevel", "") + end + + local replacement = SetFreshNextLevel() + local message = "Could not load " .. destination .. ". Map-change voting is available again." + if replacement then + message = message .. " The next candidate is " .. replacement .. "." + end + PrintMessage(HUD_PRINTTALK, "[CO-OP] " .. message) + ErrorNoHalt("[custom_coop] changelevel failed for " .. destination .. "; destination quarantined until restart\n") + end) end) end @@ -300,7 +347,7 @@ hook.Add("EntityKeyValue", "CustomCoopRepairChangelevelTargets", function(ent, k if string.lower(key or "") ~= "map" then return end if MapExists(value) and string.lower(value) ~= string.lower(game.GetMap()) then return end - local replacement = GetRandomLevel() + local replacement = GetRandomAvailableMap() if replacement then print("[custom_coop] Replacing unavailable changelevel target '" .. tostring(value) .. "' with " .. replacement) return replacement diff --git a/gamemode/shared.lua b/gamemode/shared.lua index f5b790a..57899dd 100644 --- a/gamemode/shared.lua +++ b/gamemode/shared.lua @@ -1,4 +1,4 @@ -GM.Name = "Feedc0de's coop Mode" +GM.Name = "feedc0de Coop Campaign" GM.Author = "0xFEEDC0DE64" GM.Email = "daniel@brunner.ninja" GM.Website = "brunner.ninja" @@ -12,11 +12,11 @@ if (SERVER) then function ChangeRandomLevel() local randomLevel = GetRandomLevel() if not randomLevel then - ErrorNoHalt("[custom_coop] No other installed coop map was found in levels.lua\n") + ErrorNoHalt("[feedc0de_coop] No other installed coop map was found in levels.lua\n") return false end - print("[custom_coop] Changing to random level " .. randomLevel) + print("[feedc0de_coop] Changing to random level " .. randomLevel) RunConsoleCommand("changelevel", randomLevel) return true end @@ -24,11 +24,11 @@ if (SERVER) then function NextRandomLevel() local randomLevel = GetRandomLevel() if not randomLevel then - ErrorNoHalt("[custom_coop] Cannot set nextlevel: no other installed coop map was found\n") + ErrorNoHalt("[feedc0de_coop] Cannot set nextlevel: no other installed coop map was found\n") return nil end - print("[custom_coop] Setting nextlevel to " .. randomLevel) + print("[feedc0de_coop] Setting nextlevel to " .. randomLevel) RunConsoleCommand("nextlevel", randomLevel) return randomLevel end diff --git a/gamemode/sv_progression.lua b/gamemode/sv_progression.lua index 5e1aa56..407446a 100644 --- a/gamemode/sv_progression.lua +++ b/gamemode/sv_progression.lua @@ -43,6 +43,8 @@ util.AddNetworkString("CustomCoopProgressionSetPrice") util.AddNetworkString("CustomCoopProgressionPricesChanged") util.AddNetworkString("CustomCoopProgressionPlayMusic") util.AddNetworkString("CustomCoopProgressionToggleMusicMute") +util.AddNetworkString("CustomCoopProgressionRequestAdminPlayers") +util.AddNetworkString("CustomCoopProgressionAdminPlayers") local function Query(query, context) local startedAt = SysTime() @@ -779,6 +781,193 @@ local function SendShop(ply) net.Send(ply) end +local function GetAdminPlayerRecord(recordsBySteamID, records, steamID64) + steamID64 = tostring(steamID64 or "") + if steamID64 == "" then return end + + local record = recordsBySteamID[steamID64] + if record then return record end + + record = { + steamID64 = steamID64, + playerName = steamID64, + online = false, + score = 0, + credits = 0, + playtime = 0, + equippedModel = Progression.DefaultModelID, + equippedWeapon = Progression.DefaultWeaponID, + equippedTrail = Progression.DefaultTrailID, + purchases = {}, + purchaseKeys = {}, + enabledBehaviours = {}, + enabledKeys = {} + } + recordsBySteamID[steamID64] = record + records[#records + 1] = record + return record +end + +local function AddAdminPurchase(record, itemType, itemID, purchasedAt) + itemType = tostring(itemType or "") + itemID = tostring(itemID or "") + if not record or itemType == "" or itemID == "" then return end + + local key = PriceKey(itemType, itemID) + if record.purchaseKeys[key] then return end + record.purchaseKeys[key] = true + record.purchases[#record.purchases + 1] = { + itemType = itemType, + itemID = itemID, + purchasedAt = math.max(math.floor(tonumber(purchasedAt) or 0), 0) + } +end + +local function AddAdminEnabledBehaviour(record, itemID) + itemID = tostring(itemID or "") + if not record or itemID == "" or record.enabledKeys[itemID] then return end + record.enabledKeys[itemID] = true + record.enabledBehaviours[#record.enabledBehaviours + 1] = itemID +end + +local function ApplyRuntimeStateToAdminRecord(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) + record.equippedModel = state.equippedModel or Progression.DefaultModelID + record.equippedWeapon = state.equippedWeapon or Progression.DefaultWeaponID + record.equippedTrail = state.equippedTrail or Progression.DefaultTrailID + + for _, pending in pairs(state.pendingOwnedItems or {}) do + AddAdminPurchase(record, pending.itemType, pending.itemID, pending.purchasedAt) + end + + record.enabledBehaviours = {} + record.enabledKeys = {} + for itemID in pairs(state.enabledBehaviours or {}) do + AddAdminEnabledBehaviour(record, itemID) + end +end + +local function LoadAdminPlayerRecords() + local recordsBySteamID = {} + local records = {} + local playerRows = Query( + "SELECT steamid64, player_name, lifetime_score, credits, playtime_seconds, " .. + "equipped_model, equipped_weapon, equipped_trail FROM custom_coop_players", + "could not load admin player list" + ) + + if istable(playerRows) then + for _, row in ipairs(playerRows) do + local record = GetAdminPlayerRecord(recordsBySteamID, records, 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) + record.equippedModel = row.equipped_model or Progression.DefaultModelID + record.equippedWeapon = row.equipped_weapon or Progression.DefaultWeaponID + record.equippedTrail = row.equipped_trail or Progression.DefaultTrailID + end + end + end + + local purchaseRows = Query( + "SELECT steamid64, item_type, item_id, purchased_at FROM custom_coop_owned_items " .. + "ORDER BY purchased_at, item_type, item_id", + "could not load admin player purchases" + ) + if istable(purchaseRows) then + for _, row in ipairs(purchaseRows) do + AddAdminPurchase(recordsBySteamID[tostring(row.steamid64 or "")], row.item_type, row.item_id, row.purchased_at) + end + end + + local enabledRows = Query( + "SELECT steamid64, item_id FROM custom_coop_enabled_behaviours ORDER BY item_id", + "could not load admin player behaviours" + ) + if istable(enabledRows) then + for _, row in ipairs(enabledRows) do + AddAdminEnabledBehaviour(recordsBySteamID[tostring(row.steamid64 or "")], row.item_id) + end + end + + for ply, state in pairs(playerStates) do + local record = GetAdminPlayerRecord(recordsBySteamID, records, state.steamID64) + ApplyRuntimeStateToAdminRecord(record, state, IsValid(ply) and ply:Nick() or nil, IsValid(ply)) + end + for steamID64, state in pairs(disconnectedStates) do + local record = GetAdminPlayerRecord(recordsBySteamID, records, steamID64) + ApplyRuntimeStateToAdminRecord(record, state, nil, false) + end + + for _, record in ipairs(records) do + table.sort(record.purchases, function(left, right) + if left.itemType == right.itemType then return left.itemID < right.itemID end + return left.itemType < right.itemType + end) + table.sort(record.enabledBehaviours) + 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 + return string.lower(left.playerName) < string.lower(right.playerName) + end) + return records +end + +local function SendAdminPlayerBatch(ply, requestID, records, firstIndex, lastIndex) + local count = math.max(lastIndex - firstIndex + 1, 0) + net.Start("CustomCoopProgressionAdminPlayers") + net.WriteUInt(requestID, 16) + net.WriteBool(firstIndex == 1) + net.WriteBool(lastIndex >= #records) + net.WriteUInt(math.min(count, 255), 8) + for index = firstIndex, lastIndex do + local record = records[index] + net.WriteString(record.steamID64) + net.WriteString(string.sub(record.playerName, 1, 96)) + net.WriteBool(record.online) + 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]) + end + end + net.Send(ply) +end + +local function SendAdminPlayers(ply, requestID) + local records = LoadAdminPlayerRecords() + if #records == 0 then + SendAdminPlayerBatch(ply, requestID, records, 1, 0) + return + end + + local batchSize = 25 + for firstIndex = 1, #records, batchSize do + SendAdminPlayerBatch(ply, requestID, records, firstIndex, math.min(firstIndex + batchSize - 1, #records)) + end +end + local function SendNotice(ply, success, message) net.Start("CustomCoopProgressionNotice") net.WriteBool(success) @@ -1422,6 +1611,17 @@ net.Receive("CustomCoopProgressionRequestShop", function(_, ply) SendShop(ply) end) +net.Receive("CustomCoopProgressionRequestAdminPlayers", 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) +end) + net.Receive("CustomCoopProgressionSelectItem", function(_, ply) if not RequestAllowed(ply) then return end diff --git a/loading-screen/README.md b/loading-screen/README.md new file mode 100644 index 0000000..d4a8fb2 --- /dev/null +++ b/loading-screen/README.md @@ -0,0 +1,24 @@ +# feedc0de Coop loading screen + +This directory contains the loading page and a dependency-free Python HTTP +service. The service exposes only the page, the selected campaign screenshots, +a read-only player-summary query, and a top-five all-time Score leaderboard. It +does not expose Steam IDs, `sv.db`, Lua files, or directory listings. + +The Kubernetes loading-screen sidecar starts and stops this service together +with SRCDS on TCP port `8765`. The configured public URL is: + + https://garrysmod.brunner.ninja/?steamid=%s&map=%m + +`%s` and `%m` are filled in by Garry's Mod. The HTTPS virtual host should +reverse-proxy to the loading-screen service on port `8765`. + +For a browser preview with sample engine callbacks: + + python3 server.py --bind 127.0.0.1 --port 8765 + +Then open `http://127.0.0.1:8765/?demo=1`. + +The progress percentage is based on Garry's Mod's file-count and join-status +callbacks. Garry's Mod does not expose byte-level Workshop progress to loading +web pages, so the interface intentionally does not claim to show byte progress. diff --git a/loading-screen/app.js b/loading-screen/app.js new file mode 100644 index 0000000..106f054 --- /dev/null +++ b/loading-screen/app.js @@ -0,0 +1,358 @@ +(function () { + "use strict"; + + var screenshots = [ + { file: "evac_1.jpg", title: "The road ahead" }, + { file: "evac_2.jpg", title: "Make every round count" }, + { file: "evac_3.jpg", title: "Hold the perimeter" }, + { file: "evac_4.jpg", title: "Extraction route" }, + { file: "abducted_1.jpg", title: "Into the unknown" }, + { file: "abducted_2.jpg", title: "No one fights alone" }, + { file: "abducted_3.jpg", title: "Containment breached" }, + { file: "horror_raid.jpg", title: "After the sirens" }, + { file: "scream_1.jpg", title: "Keep moving" }, + { file: "scream_2.jpg", title: "Lights out" }, + { file: "scream_3.jpg", title: "Something is watching" }, + { file: "southtown.jpg", title: "The last safe block" } + ]; + + var tips = [ + "Stay close. Co-op maps are at their best when the squad moves together.", + "Press F3 or type !shop to spend Credits on gear, abilities and companions.", + "Harder enemies award more Score and Credits. Lifetime Score determines your rank.", + "Type !nextmap to vote for the next mission, or !skip if the current map is stuck.", + "Dying can cost Credits, but it never reduces your lifetime Score or rank.", + "Hold TAB in game to compare lifetime scores with the rest of the squad." + ]; + + var state = { + filesTotal: 0, + filesNeeded: 0, + maxNeeded: 0, + filesStarted: {}, + startedCount: 0, + stage: 0, + progress: 4, + steamID: "", + profileRequested: "", + leaderboardRequested: false, + currentSlide: 0, + activeLayer: 0 + }; + + function byId(id) { + return document.getElementById(id); + } + + function queryValue(name) { + var match = new RegExp("(?:^|&)" + name + "=([^&]*)", "i").exec(window.location.search.substring(1)); + return match ? decodeURIComponent(match[1].replace(/\+/g, " ")) : ""; + } + + function cleanInteger(value) { + value = parseInt(value, 10); + return isFinite(value) && value > 0 ? value : 0; + } + + function formatNumber(value) { + var text = String(Math.max(parseInt(value, 10) || 0, 0)); + return text.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + + function formatPlaytime(seconds) { + var minutes = Math.max(Math.floor((parseInt(seconds, 10) || 0) / 60), 0); + var days = Math.floor(minutes / 1440); + var hours = Math.floor((minutes % 1440) / 60); + var mins = minutes % 60; + if (days > 0) return days + "d " + hours + "h"; + if (hours > 0) return hours + "h " + mins + "m"; + return mins + "m"; + } + + function titleCaseMap(mapName) { + if (!mapName) return "Preparing mission"; + var words = mapName.replace(/\.(bsp|ain)$/i, "").replace(/[_\-]+/g, " ").replace(/\s+/g, " ").trim(); + return words.replace(/\b([a-z])/g, function (_, letter) { return letter.toUpperCase(); }); + } + + function setStage(stage) { + var items = byId("stages").getElementsByTagName("li"); + var index; + state.stage = Math.max(state.stage, Math.min(stage, items.length - 1)); + for (index = 0; index < items.length; index += 1) { + items[index].className = index < state.stage ? "done" : (index === state.stage ? "active" : ""); + } + } + + function setProgress(value) { + value = Math.max(state.progress, Math.min(Math.round(value), 99)); + state.progress = value; + byId("progress-bar").style.width = value + "%"; + byId("percent").textContent = value; + } + + function updateFileProgress() { + var needed = state.maxNeeded || state.filesNeeded; + var completedFromNeeded = needed && state.filesNeeded <= needed ? needed - state.filesNeeded : 0; + var completedFromStarts = Math.max(state.startedCount - 1, 0); + var completed = Math.max(completedFromNeeded, completedFromStarts); + var receiving = Math.min(Math.max(completed + (state.startedCount ? 1 : 0), 0), needed || state.filesTotal || 0); + var ratio; + + if (needed > 0) { + completed = Math.min(completed, needed); + ratio = Math.min((completed + (state.startedCount ? 0.35 : 0)) / needed, 0.98); + setProgress(12 + ratio * 74); + byId("file-count").textContent = "FILE " + receiving + " OF " + needed + " REQUIRED"; + } else if (state.filesTotal > 0) { + byId("file-count").textContent = state.filesTotal + " FILES ON MANIFEST"; + } + } + + function sourceForFile(fileName) { + if (/workshop|\.gma$|^\s*\d{7,}\s*$/i.test(fileName)) return "WORKSHOP CONTENT"; + return "SERVER CONTENT"; + } + + function stageForStatus(status) { + if (/lua|client info|spawn|precach|ready|starting/i.test(status)) return 3; + if (/download|resource|materials|models|sound|fastdl/i.test(status)) return 2; + if (/workshop|addon|mount/i.test(status)) return 1; + return state.stage; + } + + function progressForStatus(status, stage) { + if (stage === 1) return 10; + if (stage === 2) return 14; + if (/client info sent|ready|fully connected/i.test(status)) return 98; + if (/starting lua|lua started/i.test(status)) return 95; + if (/sending client info|precach|creating/i.test(status)) return 90; + if (stage === 3) return 88; + return 6; + } + + function requestProfile(steamID) { + var request; + if (!/^7656119\d{10}$/.test(steamID) || state.profileRequested === steamID) return; + state.profileRequested = steamID; + request = new XMLHttpRequest(); + request.open("GET", "/api/player?steamid=" + encodeURIComponent(steamID), true); + request.timeout = 3000; + request.onreadystatechange = function () { + var profile; + if (request.readyState !== 4) return; + if (request.status >= 200 && request.status < 300) { + try { + profile = JSON.parse(request.responseText); + showProfile(profile); + return; + } catch (ignore) {} + } + showProfileUnavailable(); + }; + request.ontimeout = showProfileUnavailable; + request.onerror = showProfileUnavailable; + request.send(null); + } + + function monogram(name) { + var words = String(name || "?").replace(/[^a-z0-9 ]/gi, " ").trim().split(/\s+/); + var result = words.length > 1 ? words[0].charAt(0) + words[words.length - 1].charAt(0) : words[0].slice(0, 2); + return result.toUpperCase() || "?"; + } + + function showProfile(profile) { + var name = profile.playerName || "New operative"; + byId("player-name").textContent = name; + byId("profile-monogram").textContent = monogram(name); + byId("profile-loading").className = "profile-loading hidden"; + byId("profile-stats").className = "stats"; + byId("player-score").textContent = formatNumber(profile.score); + byId("player-credits").textContent = formatNumber(profile.credits); + byId("player-time").textContent = formatPlaytime(profile.playtimeSeconds); + byId("player-rank").textContent = profile.rank || "Newbie"; + byId("profile-kicker").textContent = profile.found ? "WELCOME BACK, OPERATIVE" : "NEW OPERATIVE RECORD"; + } + + function showProfileUnavailable() { + byId("profile-loading").innerHTML = "Progression record available after deployment."; + } + + function showLeaderboard(players) { + var list = byId("leaderboard-list"); + var index; + var player; + var row; + var position; + var identity; + var name; + var rank; + var score; + + while (list.firstChild) list.removeChild(list.firstChild); + if (!players || !players.length) { + row = document.createElement("li"); + row.className = "leaderboard-loading"; + row.textContent = "No campaign records yet."; + list.appendChild(row); + return; + } + + for (index = 0; index < players.length; index += 1) { + player = players[index]; + row = document.createElement("li"); + + position = document.createElement("span"); + position.className = "leaderboard-position"; + position.textContent = String(player.position || index + 1).replace(/^(\d)$/, "0$1"); + + identity = document.createElement("div"); + name = document.createElement("strong"); + name.textContent = player.playerName || "Unknown operative"; + rank = document.createElement("small"); + rank.textContent = (player.rank || "Newbie") + " · " + formatPlaytime(player.playtimeSeconds); + identity.appendChild(name); + identity.appendChild(rank); + + score = document.createElement("span"); + score.className = "leaderboard-score"; + score.textContent = formatNumber(player.score); + + row.appendChild(position); + row.appendChild(identity); + row.appendChild(score); + list.appendChild(row); + } + } + + function showLeaderboardUnavailable() { + var list = byId("leaderboard-list"); + list.innerHTML = '
brunner.ninja
+ +