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 = '
  • Leaderboard temporarily unavailable.
  • '; + } + + function requestLeaderboard() { + var request; + if (state.leaderboardRequested) return; + state.leaderboardRequested = true; + request = new XMLHttpRequest(); + request.open("GET", "/api/leaderboard", true); + request.timeout = 3000; + request.onreadystatechange = function () { + var payload; + if (request.readyState !== 4) return; + if (request.status >= 200 && request.status < 300) { + try { + payload = JSON.parse(request.responseText); + showLeaderboard(payload.players || []); + return; + } catch (ignore) {} + } + showLeaderboardUnavailable(); + }; + request.ontimeout = showLeaderboardUnavailable; + request.onerror = showLeaderboardUnavailable; + request.send(null); + } + + function showSlide(index, immediate) { + var next = screenshots[index % screenshots.length]; + var nextLayer = state.activeLayer ? byId("slide-a") : byId("slide-b"); + var oldLayer = state.activeLayer ? byId("slide-b") : byId("slide-a"); + var image = new Image(); + var url = "/assets/screenshots/" + encodeURIComponent(next.file); + + image.onload = function () { + nextLayer.style.backgroundImage = "url('" + url + "')"; + if (immediate) { + nextLayer.style.transition = "none"; + } + nextLayer.className = "slide slide-visible"; + oldLayer.className = "slide"; + if (immediate) { + window.setTimeout(function () { nextLayer.style.transition = ""; }, 50); + } + state.activeLayer = state.activeLayer ? 0 : 1; + state.currentSlide = index % screenshots.length; + byId("photo-index").textContent = (state.currentSlide + 1 < 10 ? "0" : "") + (state.currentSlide + 1) + " / " + screenshots.length; + byId("photo-title").textContent = next.title; + }; + image.src = url; + } + + window.GameDetails = function (serverName, serverURL, mapName, maxPlayers, steamID, gamemode) { + var players = cleanInteger(maxPlayers); + byId("server-name").textContent = serverName || "brunner.ninja"; + byId("map-title").textContent = titleCaseMap(mapName); + byId("gamemode").textContent = titleCaseMap(gamemode || "feedc0de_coop").toUpperCase(); + byId("slots").textContent = players ? players + " PLAYER SLOTS" : "CO-OPERATIVE CAMPAIGNS"; + byId("server-state").textContent = "SERVER CONTACT ESTABLISHED"; + state.steamID = String(steamID || state.steamID); + setProgress(6); + requestProfile(state.steamID); + }; + + window.SetFilesTotal = function (total) { + state.filesTotal = Math.max(state.filesTotal, cleanInteger(total)); + updateFileProgress(); + }; + + window.SetFilesNeeded = function (needed) { + state.filesNeeded = cleanInteger(needed); + state.maxNeeded = Math.max(state.maxNeeded, state.filesNeeded); + if (state.filesNeeded > 0) setStage(1); + updateFileProgress(); + }; + + window.DownloadingFile = function (fileName) { + fileName = String(fileName || "Preparing content").replace(/\\/g, "/"); + if (!state.filesStarted[fileName]) { + state.filesStarted[fileName] = true; + state.startedCount += 1; + } + byId("phase-label").textContent = sourceForFile(fileName); + byId("status-text").textContent = "Receiving required content"; + byId("file-name").textContent = fileName; + setStage(sourceForFile(fileName) === "WORKSHOP CONTENT" ? 1 : 2); + updateFileProgress(); + }; + + window.SetStatusChanged = function (status) { + var newStage; + status = String(status || "Working…").replace(/^\s+|\s+$/g, ""); + newStage = stageForStatus(status); + setStage(newStage); + byId("status-text").textContent = status; + byId("phase-label").textContent = newStage === 3 ? "PREPARING YOUR SESSION" : (newStage === 2 ? "SERVER CONTENT" : (newStage === 1 ? "WORKSHOP CONTENT" : "JOINING SERVER")); + if (newStage === 3) byId("file-name").textContent = "Downloads complete — initializing game code"; + setProgress(progressForStatus(status, newStage)); + }; + + function initialize() { + var map = queryValue("map"); + var steamID = queryValue("steamid"); + var seed = 0; + var i; + + for (i = 0; i < (map + steamID).length; i += 1) seed += (map + steamID).charCodeAt(i); + state.currentSlide = seed % screenshots.length; + byId("tip").textContent = tips[seed % tips.length]; + if (map) byId("map-title").textContent = titleCaseMap(map); + if (steamID) { + state.steamID = steamID; + requestProfile(steamID); + } + requestLeaderboard(); + showSlide(state.currentSlide, true); + window.setInterval(function () { showSlide(state.currentSlide + 1, false); }, 8000); + + if (queryValue("demo") === "1") { + window.GameDetails("brunner.ninja", window.location.href, map || "tbr_coop_thousand_antlions_v3b", 16, steamID || "76561198171048231", "feedc0de_coop"); + window.SetFilesTotal(148); + window.SetFilesNeeded(37); + window.SetStatusChanged("Downloading Workshop content"); + window.setTimeout(function () { window.DownloadingFile("workshop/706104732/campaign_content.gma"); }, 500); + } + } + + initialize(); +}()); diff --git a/loading-screen/index.html b/loading-screen/index.html new file mode 100644 index 0000000..0c808f4 --- /dev/null +++ b/loading-screen/index.html @@ -0,0 +1,111 @@ + + + + + + + + brunner.ninja // feedc0de Coop Campaign + + + + + +
    +
    +
    + +
    + BRUNNER.NINJA + FEEDC0DE COOP +
    +
    +
    CONNECTING TO SERVER
    +
    + +
    +
    +
    CURRENT DEPLOYMENT
    +

    Preparing mission

    +
    + FEEDC0DE COOP CAMPAIGN + CO-OPERATIVE CAMPAIGNS +
    +

    brunner.ninja

    + +
    + 01 / 12 +
    + FROM THE ARCHIVES + The road ahead +
    +
    +
    + + +
    + +
    +
    +
    + INITIALIZING CONNECTION + Waiting for server details… + Secure channel standing by +
    +
    4%
    +
    + +
    +
    +
    + +
    +
      +
    1. Handshake
    2. +
    3. Workshop
    4. +
    5. Server files
    6. +
    7. Lua & spawn
    8. +
    + Building download manifest +
    +
    +
    + + + + diff --git a/loading-screen/server.py b/loading-screen/server.py new file mode 100644 index 0000000..bbe196b --- /dev/null +++ b/loading-screen/server.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Small, dependency-free web service for the feedc0de co-op loading screen.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import re +import sqlite3 +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + + +HERE = Path(__file__).resolve().parent +DEFAULT_DATABASE = HERE.parents[2] / "sv.db" +DEFAULT_SCREENSHOTS = HERE.parent / "backgrounds" +STEAM_ID = re.compile(r"^7656119\d{10}$") +SCREENSHOT = re.compile(r"^[a-z0-9_-]+\.jpg$", re.IGNORECASE) +RANKS = ( + (0, "Newbie"), + (10, "Survivor"), + (50, "Hunter"), + (150, "Slayer"), + (500, "Veteran"), + (1500, "Exterminator"), + (5000, "Legend"), +) + + +def rank_for(score: int) -> str: + rank = RANKS[0][1] + for threshold, name in RANKS: + if score < threshold: + break + rank = name + return rank + + +class LoadingScreenServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], database: Path, screenshots: Path): + self.database = database.resolve() + self.screenshots = screenshots.resolve() + super().__init__(address, LoadingScreenHandler) + + +class LoadingScreenHandler(BaseHTTPRequestHandler): + server_version = "Feedc0deCoopLoading/1.1" + + def log_message(self, format_string: str, *args: object) -> None: + # Do not put joining players' Steam IDs into an access log. + if args and str(args[1]).startswith(("4", "5")): + super().log_message(format_string, *args) + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + request = urlsplit(self.path) + path = unquote(request.path) + + if path in ("/", "/index.html"): + self.send_file(HERE / "index.html", "text/html; charset=utf-8", no_cache=True) + return + if path == "/assets/style.css": + self.send_file(HERE / "style.css", "text/css; charset=utf-8") + return + if path == "/assets/app.js": + self.send_file(HERE / "app.js", "application/javascript; charset=utf-8") + return + if path.startswith("/assets/screenshots/"): + self.serve_screenshot(path.rsplit("/", 1)[-1]) + return + if path == "/api/player": + steam_id = parse_qs(request.query).get("steamid", [""])[0] + self.serve_player(steam_id) + return + if path == "/api/leaderboard": + self.serve_leaderboard() + return + if path == "/api/health": + self.send_json({"ok": True, "database": self.server.database.is_file()}) + return + self.send_error_json(404, "Not found") + + def do_HEAD(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self.do_GET() + + def serve_screenshot(self, name: str) -> None: + if not SCREENSHOT.fullmatch(name): + self.send_error_json(404, "Not found") + return + path = self.server.screenshots / name + if path.parent != self.server.screenshots or not path.is_file(): + self.send_error_json(404, "Not found") + return + self.send_file(path, "image/jpeg") + + def serve_player(self, steam_id: str) -> None: + if not STEAM_ID.fullmatch(steam_id): + self.send_error_json(400, "Invalid SteamID64") + return + if not self.server.database.is_file(): + self.send_error_json(503, "Progression database unavailable") + return + + try: + database_uri = f"file:{self.server.database.as_posix()}?mode=ro" + with sqlite3.connect(database_uri, uri=True, timeout=0.5) as connection: + connection.execute("PRAGMA query_only = ON") + connection.execute("PRAGMA busy_timeout = 500") + row = connection.execute( + "SELECT player_name, lifetime_score, credits, playtime_seconds " + "FROM custom_coop_players WHERE steamid64 = ? LIMIT 1", + (steam_id,), + ).fetchone() + except sqlite3.Error: + self.send_error_json(503, "Progression record temporarily unavailable") + return + + if row is None: + payload = { + "found": False, + "playerName": "New operative", + "score": 0, + "credits": 0, + "playtimeSeconds": 0, + "rank": "Newbie", + } + else: + score = max(int(row[1] or 0), 0) + payload = { + "found": True, + "playerName": str(row[0] or "Returning operative")[:64], + "score": score, + "credits": max(int(row[2] or 0), 0), + "playtimeSeconds": max(int(row[3] or 0), 0), + "rank": rank_for(score), + } + self.send_json(payload, no_cache=True) + + def serve_leaderboard(self) -> None: + if not self.server.database.is_file(): + self.send_error_json(503, "Progression database unavailable") + return + + try: + database_uri = f"file:{self.server.database.as_posix()}?mode=ro" + with sqlite3.connect(database_uri, uri=True, timeout=0.5) as connection: + connection.execute("PRAGMA query_only = ON") + connection.execute("PRAGMA busy_timeout = 500") + rows = connection.execute( + "SELECT player_name, lifetime_score, playtime_seconds " + "FROM custom_coop_players " + "ORDER BY lifetime_score DESC, playtime_seconds DESC, player_name COLLATE NOCASE ASC " + "LIMIT 5" + ).fetchall() + except sqlite3.Error: + self.send_error_json(503, "Leaderboard temporarily unavailable") + return + + players = [] + for position, row in enumerate(rows, start=1): + score = max(int(row[1] or 0), 0) + players.append( + { + "position": position, + "playerName": str(row[0] or "Unknown operative")[:64], + "score": score, + "playtimeSeconds": max(int(row[2] or 0), 0), + "rank": rank_for(score), + } + ) + self.send_json({"players": players}, no_cache=True) + + def send_file(self, path: Path, content_type: str, no_cache: bool = False) -> None: + try: + content = path.read_bytes() + except OSError: + self.send_error_json(404, "Not found") + return + self.send_response(200) + self.send_common_headers(no_cache=no_cache) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(content) + + def send_json(self, payload: dict[str, object], status: int = 200, no_cache: bool = False) -> None: + content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_common_headers(no_cache=no_cache) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(content) + + def send_error_json(self, status: int, message: str) -> None: + self.send_json({"error": message}, status=status, no_cache=True) + + def send_common_headers(self, no_cache: bool = False) -> None: + self.send_header("Cache-Control", "no-store" if no_cache else "public, max-age=3600") + self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bind", default="0.0.0.0", help="address to listen on (default: all IPv4 interfaces)") + parser.add_argument("--port", type=int, default=8765, help="HTTP port (default: 8765)") + parser.add_argument("--database", type=Path, default=DEFAULT_DATABASE, help="path to garrysmod/sv.db") + parser.add_argument("--screenshots", type=Path, default=DEFAULT_SCREENSHOTS, help="directory of slideshow JPEGs") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not 1 <= args.port <= 65535: + print("loading screen: port must be between 1 and 65535", file=sys.stderr) + return 2 + server = LoadingScreenServer((args.bind, args.port), args.database, args.screenshots) + print(f"loading screen: serving http://{args.bind}:{args.port}", flush=True) + try: + server.serve_forever(poll_interval=0.5) + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loading-screen/style.css b/loading-screen/style.css new file mode 100644 index 0000000..bd7aab8 --- /dev/null +++ b/loading-screen/style.css @@ -0,0 +1,663 @@ +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #070b0d; + color: #f3f7f5; + font-family: "Segoe UI", "Trebuchet MS", Arial, sans-serif; +} + +body { + min-width: 640px; + min-height: 480px; +} + +.backdrop, +.slide, +.shade, +.grain { + position: absolute; + inset: 0; +} + +.backdrop { + background: #0a1113; + overflow: hidden; +} + +.slide { + opacity: 0; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + transform: scale(1.035); + transition: opacity 1.6s ease, transform 9s linear; +} + +.slide-visible { + opacity: 1; + transform: scale(1.085); +} + +.shade { + background: + linear-gradient(90deg, rgba(4, 8, 10, .94) 0%, rgba(5, 9, 11, .72) 35%, rgba(5, 8, 10, .3) 67%, rgba(4, 7, 9, .6) 100%), + linear-gradient(0deg, rgba(4, 8, 9, .94) 0%, rgba(4, 8, 9, .08) 56%, rgba(4, 8, 9, .68) 100%); +} + +.grain { + opacity: .11; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E"); + pointer-events: none; +} + +.shell { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + padding: 4.8vh 5.2vw; +} + +.topbar { + height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, .14); + padding-bottom: 18px; +} + +.brand { + display: flex; + align-items: center; + letter-spacing: .18em; +} + +.brand-mark { + position: relative; + display: inline-block; + width: 34px; + height: 34px; + margin-right: 13px; + border: 2px solid #9bfbd4; + transform: rotate(45deg); + box-shadow: 0 0 24px rgba(94, 255, 192, .25); +} + +.brand-mark i { + position: absolute; + width: 10px; + height: 10px; + top: 10px; + left: 10px; + background: #9bfbd4; +} + +.brand strong, +.brand span { + display: block; +} + +.brand strong { + font-size: 13px; +} + +.brand span { + color: #9bfbd4; + font-size: 9px; + margin-top: 4px; +} + +.live { + display: flex; + align-items: center; + color: rgba(238, 246, 243, .72); + font-size: 10px; + font-weight: 700; + letter-spacing: .15em; +} + +.live i { + width: 7px; + height: 7px; + margin-right: 9px; + border-radius: 50%; + background: #8ff7ce; + box-shadow: 0 0 0 4px rgba(143, 247, 206, .12), 0 0 16px #8ff7ce; + animation: pulse 2s ease-in-out infinite; +} + +.hero { + position: absolute; + top: 17vh; + left: 5.2vw; + right: 5.2vw; + bottom: 29vh; + display: flex; + align-items: center; + justify-content: space-between; +} + +.mission { + width: 58%; + min-width: 420px; +} + +.eyebrow, +.phase-label, +.profile small, +.photo-caption small { + color: #9bfbd4; + font-size: 10px; + font-weight: 800; + letter-spacing: .22em; +} + +.eyebrow:before { + content: ""; + display: inline-block; + width: 34px; + height: 1px; + margin: 0 11px 3px 0; + background: #9bfbd4; +} + +h1 { + max-width: 900px; + margin: 14px 0 12px; + font-size: clamp(42px, 6.6vw, 106px); + line-height: .92; + letter-spacing: -.055em; + text-transform: uppercase; + text-shadow: 0 3px 38px rgba(0, 0, 0, .5); +} + +.mission-meta { + display: flex; + align-items: center; + color: rgba(240, 247, 245, .72); + font-size: 10px; + font-weight: 700; + letter-spacing: .16em; +} + +.mission-meta span + span:before { + content: "/"; + color: #9bfbd4; + margin: 0 11px; +} + +.server-name { + margin: 11px 0 0; + color: rgba(239, 246, 244, .45); + font-size: 13px; +} + +.photo-caption { + display: flex; + align-items: center; + margin-top: 7.5vh; +} + +.photo-caption > span { + width: 54px; + margin-right: 12px; + color: rgba(255, 255, 255, .55); + font-family: Consolas, monospace; + font-size: 11px; +} + +.photo-caption div { + padding-left: 13px; + border-left: 1px solid rgba(255, 255, 255, .24); +} + +.photo-caption small, +.photo-caption strong { + display: block; +} + +.photo-caption small { + color: rgba(255, 255, 255, .44); + font-size: 8px; +} + +.photo-caption strong { + margin-top: 5px; + font-size: 12px; + font-weight: 600; +} + +.profile-column { + width: 350px; + flex: 0 0 350px; +} + +.profile { + width: 100%; + min-height: 272px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, .14); + background: rgba(7, 13, 15, .78); + box-shadow: 0 22px 70px rgba(0, 0, 0, .25); +} + +.profile-head { + display: flex; + align-items: center; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, .1); +} + +.monogram { + display: inline-flex; + width: 44px; + height: 44px; + margin-right: 14px; + align-items: center; + justify-content: center; + border: 1px solid rgba(155, 251, 212, .6); + background: rgba(155, 251, 212, .08); + color: #9bfbd4; + font-weight: 800; + font-size: 17px; +} + +.profile h2 { + overflow: hidden; + max-width: 235px; + margin: 5px 0 0; + font-size: 18px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-loading { + height: 100px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(238, 246, 243, .58); + font-size: 11px; +} + +.profile-loading i { + width: 13px; + height: 13px; + margin-right: 10px; + border: 2px solid rgba(155, 251, 212, .18); + border-top-color: #9bfbd4; + border-radius: 50%; + animation: spin .8s linear infinite; +} + +.stats { + display: flex; + flex-wrap: wrap; + padding: 16px 0 5px; +} + +.stats div { + width: 33.333%; + margin-bottom: 14px; +} + +.stats div.rank { + width: 100%; + margin: 0; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, .08); +} + +.stats strong, +.stats span { + display: block; +} + +.stats strong { + font-family: Consolas, "Courier New", monospace; + font-size: 20px; + font-weight: 500; +} + +.stats span { + margin-top: 4px; + color: rgba(238, 246, 243, .42); + font-size: 7px; + font-weight: 700; + letter-spacing: .12em; +} + +.stats .rank strong { + color: #9bfbd4; + font-family: inherit; + font-size: 14px; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; +} + +.tip { + margin: 15px 0 0; + color: rgba(238, 246, 243, .5); + font-size: 10px; + line-height: 1.55; +} + +.tip:before { + content: "TIP "; + color: #9bfbd4; + font-weight: 800; + letter-spacing: .12em; +} + +.leaderboard { + margin-top: 11px; + padding: 15px 17px 13px; + border: 1px solid rgba(255, 255, 255, .14); + background: rgba(7, 13, 15, .82); + box-shadow: 0 18px 55px rgba(0, 0, 0, .22); +} + +.leaderboard-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.leaderboard-head small { + color: #9bfbd4; + font-size: 8px; + font-weight: 800; + letter-spacing: .18em; +} + +.leaderboard-head span { + color: rgba(238, 246, 243, .36); + font-family: Consolas, "Courier New", monospace; + font-size: 7px; + letter-spacing: .08em; +} + +.leaderboard ol { + margin: 0; + padding: 0; + list-style: none; +} + +.leaderboard li { + min-height: 27px; + display: grid; + grid-template-columns: 27px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + border-top: 1px solid rgba(255, 255, 255, .065); +} + +.leaderboard li:first-child { + border-top: 0; +} + +.leaderboard-position, +.leaderboard-score { + color: #9bfbd4; + font-family: Consolas, "Courier New", monospace; + font-size: 10px; +} + +.leaderboard li div { + min-width: 0; +} + +.leaderboard li strong, +.leaderboard li small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard li strong { + font-size: 10px; + font-weight: 650; +} + +.leaderboard li small { + margin-top: 1px; + color: rgba(238, 246, 243, .38); + font-size: 7px; + letter-spacing: .04em; +} + +.leaderboard-score { + color: rgba(243, 247, 245, .88); + text-align: right; +} + +.leaderboard-loading { + display: block !important; + min-height: 30px !important; + padding-top: 8px; + color: rgba(238, 246, 243, .45); + font-size: 9px; +} + +.hidden { + display: none; +} + +.loader { + position: absolute; + left: 5.2vw; + right: 5.2vw; + bottom: 4.8vh; + padding-top: 19px; + border-top: 1px solid rgba(255, 255, 255, .16); +} + +.status-row { + display: flex; + align-items: flex-end; + justify-content: space-between; +} + +.status-row > div:first-child { + min-width: 0; + padding-right: 35px; +} + +.phase-label, +.status-row strong, +.file-name { + display: block; +} + +.phase-label { + margin-bottom: 6px; + font-size: 8px; +} + +.status-row strong { + overflow: hidden; + max-width: 72vw; + font-size: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-name { + overflow: hidden; + max-width: 70vw; + margin-top: 5px; + color: rgba(238, 246, 243, .42); + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.percent { + display: flex; + align-items: baseline; + color: #9bfbd4; + font-family: Consolas, "Courier New", monospace; +} + +.percent strong { + font-size: 36px; + font-weight: 400; +} + +.percent span { + font-size: 14px; +} + +.progress-track { + height: 4px; + margin-top: 15px; + overflow: hidden; + background: rgba(255, 255, 255, .12); +} + +.progress-bar { + position: relative; + height: 100%; + min-width: 4px; + background: linear-gradient(90deg, #51cda0, #b5ffe3); + box-shadow: 0 0 18px rgba(81, 205, 160, .5); + transition: width .65s ease; +} + +.progress-bar i { + position: absolute; + top: 0; + right: -120px; + width: 120px; + height: 100%; + background: linear-gradient(90deg, rgba(155, 251, 212, 0), rgba(255, 255, 255, .9)); + animation: scan 1.8s ease-in-out infinite; +} + +.loader-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 14px; +} + +.stages { + display: flex; + margin: 0; + padding: 0; + list-style: none; +} + +.stages li { + display: flex; + align-items: center; + margin-right: 25px; + color: rgba(238, 246, 243, .32); + font-size: 8px; + font-weight: 700; + letter-spacing: .11em; + text-transform: uppercase; +} + +.stages i { + width: 6px; + height: 6px; + margin-right: 7px; + border: 1px solid rgba(255, 255, 255, .25); + border-radius: 50%; +} + +.stages li.active { + color: #f3f7f5; +} + +.stages li.active i { + border-color: #9bfbd4; + background: #9bfbd4; + box-shadow: 0 0 10px rgba(155, 251, 212, .75); +} + +.stages li.done { + color: rgba(155, 251, 212, .55); +} + +.stages li.done i { + border-color: #63d6aa; + background: rgba(99, 214, 170, .42); +} + +.file-count { + color: rgba(238, 246, 243, .42); + font-family: Consolas, "Courier New", monospace; + font-size: 9px; + letter-spacing: .04em; +} + +@keyframes pulse { + 0%, 100% { opacity: .55; } + 50% { opacity: 1; } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes scan { + 0% { right: 100%; opacity: 0; } + 30% { opacity: 1; } + 100% { right: -120px; opacity: 0; } +} + +@media (max-width: 920px), (max-height: 650px) { + .shell { padding: 24px 34px; } + .hero { top: 100px; left: 34px; right: 34px; bottom: 190px; } + .loader { left: 34px; right: 34px; bottom: 25px; } + .profile-column { width: 310px; flex-basis: 310px; } + .profile { min-height: 230px; padding: 18px; } + .profile h2 { max-width: 195px; } + .photo-caption { margin-top: 30px; } + .tip { display: none; } + h1 { font-size: 50px; } +} + +@media (max-height: 850px) { + .profile { min-height: 215px; padding: 15px 18px; } + .profile-head { padding-bottom: 10px; } + .stats { padding: 9px 0 2px; } + .stats div { margin-bottom: 7px; } + .stats div.rank { padding-top: 6px; } + .tip { display: none; } + .leaderboard { padding-top: 10px; padding-bottom: 8px; } + .leaderboard li { min-height: 24px; } +} + +@media (max-width: 760px) { + .profile-column { display: none; } + .mission { width: 100%; } + .stages li { margin-right: 13px; } + .file-count { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .slide, + .progress-bar, + .progress-bar i, + .live i, + .profile-loading i { + animation: none; + transition: none; + } +}