Renamed whole gamemode to feedc0de_coop and improved F3 menu

This commit is contained in:
2026-09-13 12:45:22 +02:00
parent bcb6d6e357
commit e683249570
12 changed files with 1918 additions and 20 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
+8 -1
View File
@@ -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.
+7 -6
View File
@@ -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
+249 -3
View File
@@ -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()
+52 -5
View File
@@ -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
+5 -5
View File
@@ -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
+200
View File
@@ -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
+24
View File
@@ -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.
+358
View File
@@ -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 = "<span>Progression record available after deployment.</span>";
}
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 = '<li class="leaderboard-loading">Leaderboard temporarily unavailable.</li>';
}
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();
}());
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>brunner.ninja // feedc0de Coop Campaign</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="backdrop" aria-hidden="true">
<div id="slide-a" class="slide slide-visible"></div>
<div id="slide-b" class="slide"></div>
<div class="shade"></div>
<div class="grain"></div>
</div>
<div class="shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark"><i></i></span>
<div>
<strong>BRUNNER.NINJA</strong>
<span>FEEDC0DE COOP</span>
</div>
</div>
<div class="live"><i></i><span id="server-state">CONNECTING TO SERVER</span></div>
</header>
<main class="hero">
<section class="mission" aria-labelledby="map-title">
<div class="eyebrow">CURRENT DEPLOYMENT</div>
<h1 id="map-title">Preparing mission</h1>
<div class="mission-meta">
<span id="gamemode">FEEDC0DE COOP CAMPAIGN</span>
<span id="slots">CO-OPERATIVE CAMPAIGNS</span>
</div>
<p id="server-name" class="server-name">brunner.ninja</p>
<div class="photo-caption">
<span id="photo-index">01 / 12</span>
<div>
<small>FROM THE ARCHIVES</small>
<strong id="photo-title">The road ahead</strong>
</div>
</div>
</section>
<aside class="profile-column">
<section class="profile" aria-live="polite">
<div class="profile-head">
<span id="profile-monogram" class="monogram">?</span>
<div>
<small id="profile-kicker">OPERATIVE RECORD</small>
<h2 id="player-name">Identifying player</h2>
</div>
</div>
<div id="profile-loading" class="profile-loading">
<i></i><span>Reading your co-op record…</span>
</div>
<div id="profile-stats" class="stats hidden">
<div><strong id="player-score">0</strong><span>LIFETIME SCORE</span></div>
<div><strong id="player-credits">0</strong><span>CREDITS</span></div>
<div><strong id="player-time">0m</strong><span>PLAYED</span></div>
<div class="rank"><strong id="player-rank">Newbie</strong><span>RANK</span></div>
</div>
<p id="tip" class="tip">Stay close. Co-op maps are at their best when the squad moves together.</p>
</section>
<section class="leaderboard" aria-live="polite">
<div class="leaderboard-head">
<small>ALL-TIME OPERATIVES</small>
<span>TOP 5 BY SCORE</span>
</div>
<ol id="leaderboard-list">
<li class="leaderboard-loading">Reading campaign records…</li>
</ol>
</section>
</aside>
</main>
<section class="loader" aria-live="polite">
<div class="status-row">
<div>
<span id="phase-label" class="phase-label">INITIALIZING CONNECTION</span>
<strong id="status-text">Waiting for server details…</strong>
<span id="file-name" class="file-name">Secure channel standing by</span>
</div>
<div class="percent"><strong id="percent">4</strong><span>%</span></div>
</div>
<div class="progress-track">
<div id="progress-bar" class="progress-bar" style="width: 4%"><i></i></div>
</div>
<div class="loader-foot">
<ol id="stages" class="stages">
<li class="active"><i></i><span>Handshake</span></li>
<li><i></i><span>Workshop</span></li>
<li><i></i><span>Server files</span></li>
<li><i></i><span>Lua &amp; spawn</span></li>
</ol>
<span id="file-count" class="file-count">Building download manifest</span>
</div>
</section>
</div>
<script src="/assets/app.js"></script>
</body>
</html>
+238
View File
@@ -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())
+663
View File
@@ -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;
}
}