Implement map selection with map thumbnail preview
This commit is contained in:
@@ -39,15 +39,61 @@ Player controls:
|
||||
- `!cp`, `!go`, and `!ucp`: save, return to, or clear a per-map checkpoint
|
||||
- Hold `TAB`: compare connected players' Lifetime Scores, ranks, and total playtime
|
||||
|
||||
## End-of-map voting
|
||||
|
||||
Finishing a mission, reaching the successful `!skip` threshold, or spending 60
|
||||
minutes on one map opens the same server-authoritative map vote for every human
|
||||
player. The responsive Derma window offers eight randomly sampled installed
|
||||
co-op maps by default, uses a scrollable card grid on smaller displays, shows
|
||||
live vote counts, and highlights both the local player's choice and the result.
|
||||
Players may change their choice until the vote resolves. It lasts at most 30
|
||||
seconds and resolves immediately once every connected human has voted.
|
||||
|
||||
A map with more than half of submitted votes wins. Without a strict majority,
|
||||
the server chooses uniformly among the maps that received at least one vote. If
|
||||
nobody votes, it chooses uniformly among all displayed choices. When the server
|
||||
is empty, the 60-minute limit skips the UI and changes immediately to a random
|
||||
eligible installed map so the idle server continues rotating.
|
||||
|
||||
The old `!nextmap` and `!votenext` chat voting commands and automatic
|
||||
`nextlevel` selection are intentionally removed. `!skip` remains the way to
|
||||
request an early end to the current mission. Configuration is available through
|
||||
these archived server ConVars:
|
||||
|
||||
- `gc_mapvoteduration` (default `30`, clamped to 5–120 seconds)
|
||||
- `gc_mapvotechoices` (default `8`, clamped to 2–12 maps)
|
||||
- `gc_mapskipvoteratio` (default `0.6`)
|
||||
- `gc_mapmaxtime` (default `3600`; `0` disables the safety limit)
|
||||
- `gc_mapchangedelay` (default `8`, applied after an online vote result)
|
||||
- `gc_mapvotepreviewurl` (fallback HTTPS directory for `<mapname>.jpg`)
|
||||
|
||||
The loading-screen sidecar automatically scans the server's mounted Workshop
|
||||
GMA cache and associates each contained BSP with its Workshop item. If the GMA
|
||||
contains `maps/thumb/<exact_map_name>.png`, the sidecar serves that image to the
|
||||
vote UI directly. Otherwise it resolves and proxies the Workshop item's Steam
|
||||
preview icon. This also covers newly added third-party map items without a
|
||||
manually maintained map-to-Workshop table, and clients do not subscribe to or
|
||||
download all displayed map addons. The scan runs in the background at startup
|
||||
and refreshes every five minutes.
|
||||
|
||||
Continue putting `maps/thumb/<exact_map_name>.png` inside each GMA so the
|
||||
standard single-player map browser also has a preview, and put
|
||||
`maps/graphs/<exact_map_name>.ain` there when that map has a node graph. A JPEG
|
||||
under the server's `garrysmod/map-previews/` directory remains an optional
|
||||
highest-priority override. `gamemode/workshop_maps.lua` is reserved only for
|
||||
unusual packages that cannot be identified from the normal Workshop cache
|
||||
layout; it is empty by default.
|
||||
|
||||
Managed cooperative bots also show a score, rank, and increasing playtime in the
|
||||
TAB scoreboard. Their score uses the same class-weighted monster rewards as a
|
||||
human player's score, but both values are session-only and kept exclusively in
|
||||
memory. Bots are never inserted into SQLite and therefore never appear in the
|
||||
loading-page high scores or the F3 Players list.
|
||||
|
||||
Bots use Steam-style handles, a randomly selected player model and trail from
|
||||
the shop catalogue, and a randomized in-memory score/playtime offset when they
|
||||
join. They may very rarely play Trolololo through the normal server music path:
|
||||
Bots use Steam-style handles, a randomly selected player model, and a 50% chance
|
||||
of a randomly selected visible trail from the shop catalogue, plus a randomized
|
||||
in-memory score/playtime offset when they join. They may very rarely play
|
||||
Trolololo through the normal server music path:
|
||||
the check is once per minute at a 1% chance and it has a shared 30-minute
|
||||
minimum interval.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
include( "shared.lua" )
|
||||
include( "cl_progression.lua" )
|
||||
include( "cl_mapvote.lua" )
|
||||
|
||||
-- Clear render hooks left by the scanner's former gamemode-local implementation.
|
||||
hook.Remove("Think", "CustomCoopFriendlyScannerRenderInterpolation")
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
local previewCacheDirectory = "feedc0de_coop/map_previews"
|
||||
local voteFrame
|
||||
local voteState
|
||||
|
||||
surface.CreateFont("CustomCoopMapVoteTitle", {
|
||||
font = "Roboto",
|
||||
size = math.Clamp(math.floor(ScrH() * 0.035), 24, 40),
|
||||
weight = 800,
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopMapVoteHeading", {
|
||||
font = "Roboto",
|
||||
size = math.Clamp(math.floor(ScrH() * 0.023), 18, 28),
|
||||
weight = 700,
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopMapVoteBody", {
|
||||
font = "Roboto",
|
||||
size = math.Clamp(math.floor(ScrH() * 0.018), 15, 22),
|
||||
weight = 500,
|
||||
})
|
||||
|
||||
surface.CreateFont("CustomCoopMapVoteSmall", {
|
||||
font = "Roboto",
|
||||
size = math.Clamp(math.floor(ScrH() * 0.014), 12, 17),
|
||||
weight = 500,
|
||||
})
|
||||
|
||||
local colors = {
|
||||
backdrop = Color(8, 12, 18, 248),
|
||||
panel = Color(18, 27, 38, 250),
|
||||
card = Color(29, 41, 55, 255),
|
||||
cardHover = Color(38, 55, 72, 255),
|
||||
border = Color(71, 92, 112, 255),
|
||||
accent = Color(77, 176, 255, 255),
|
||||
selected = Color(255, 181, 71, 255),
|
||||
winner = Color(84, 210, 139, 255),
|
||||
text = Color(238, 244, 250, 255),
|
||||
muted = Color(159, 176, 191, 255),
|
||||
}
|
||||
|
||||
local function DisplayMapName(mapName)
|
||||
local words = string.Replace(mapName or "", "_", " ")
|
||||
words = string.Replace(words, "-", " ")
|
||||
return string.upper(string.sub(words, 1, 1)) .. string.sub(words, 2)
|
||||
end
|
||||
|
||||
local function EnsurePreviewDirectory()
|
||||
file.CreateDir("feedc0de_coop")
|
||||
file.CreateDir(previewCacheDirectory)
|
||||
end
|
||||
|
||||
local function SetCachedPreview(card, cachePath)
|
||||
if not IsValid(card) or not file.Exists(cachePath, "DATA") then return end
|
||||
local material = Material("data/" .. cachePath, "smooth")
|
||||
if not material:IsError() then card.PreviewMaterial = material end
|
||||
end
|
||||
|
||||
local function ExistingCachePath(cacheStem)
|
||||
for _, extension in ipairs({ ".jpg", ".png" }) do
|
||||
local path = cacheStem .. extension
|
||||
if file.Exists(path, "DATA") then return path end
|
||||
end
|
||||
end
|
||||
|
||||
local function PreviewCachePath(cacheStem, body, headers)
|
||||
local contentType = string.lower(tostring((headers or {})["content-type"] or ""))
|
||||
if string.sub(body or "", 1, 8) == "\137PNG\r\n\26\n" or string.find(contentType, "image/png", 1, true) then
|
||||
return cacheStem .. ".png"
|
||||
end
|
||||
return cacheStem .. ".jpg"
|
||||
end
|
||||
|
||||
local function EncodeURLPathComponent(value)
|
||||
local encoded = string.gsub(tostring(value or ""), "[^%w%-._~]", function(character)
|
||||
return string.format("%%%02X", string.byte(character))
|
||||
end)
|
||||
return encoded
|
||||
end
|
||||
|
||||
local function FetchHTTPPreview(card, url, cacheStem, fallback)
|
||||
http.Fetch(url, function(body, _, headers, code)
|
||||
local cachePath = PreviewCachePath(cacheStem, body, headers)
|
||||
if code == 200 and body and #body > 0 and file.Write(cachePath, body) then
|
||||
SetCachedPreview(card, cachePath)
|
||||
return
|
||||
end
|
||||
if fallback then fallback() end
|
||||
end, function()
|
||||
if fallback then fallback() end
|
||||
end)
|
||||
end
|
||||
|
||||
local function FetchPreview(card, baseURL, mapName, workshopID)
|
||||
EnsurePreviewDirectory()
|
||||
|
||||
local cacheKey = workshopID ~= "" and ("workshop:" .. workshopID) or (baseURL .. "/" .. mapName)
|
||||
local cacheStem = previewCacheDirectory .. "/" .. util.CRC(cacheKey)
|
||||
local cachePath = ExistingCachePath(cacheStem)
|
||||
if cachePath then
|
||||
SetCachedPreview(card, cachePath)
|
||||
if os.time() - file.Time(cachePath, "DATA") < 86400 then return end
|
||||
end
|
||||
|
||||
local function fetchPVCPreview()
|
||||
if not baseURL or baseURL == "" then return end
|
||||
local root = string.gsub(baseURL, "/+$", "")
|
||||
FetchHTTPPreview(card, root .. "/" .. EncodeURLPathComponent(mapName) .. ".jpg", cacheStem)
|
||||
end
|
||||
|
||||
if workshopID == "" then
|
||||
fetchPVCPreview()
|
||||
return
|
||||
end
|
||||
|
||||
steamworks.FileInfo(workshopID, function(info)
|
||||
if not info then
|
||||
fetchPVCPreview()
|
||||
return
|
||||
end
|
||||
|
||||
if info.previewurl and info.previewurl ~= "" then
|
||||
FetchHTTPPreview(card, info.previewurl, cacheStem, fetchPVCPreview)
|
||||
return
|
||||
end
|
||||
|
||||
if info.previewid then
|
||||
steamworks.Download(info.previewid, true, function(path)
|
||||
if not path then
|
||||
fetchPVCPreview()
|
||||
return
|
||||
end
|
||||
local material = AddonMaterial(path)
|
||||
if IsValid(card) and material and not material:IsError() then
|
||||
card.PreviewMaterial = material
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
fetchPVCPreview()
|
||||
end)
|
||||
end
|
||||
|
||||
local function DrawPreviewCover(material, x, y, width, height)
|
||||
local sourceWidth = math.max(material:Width(), 1)
|
||||
local sourceHeight = math.max(material:Height(), 1)
|
||||
local sourceAspect = sourceWidth / sourceHeight
|
||||
local targetAspect = width / height
|
||||
local u0, v0, u1, v1 = 0, 0, 1, 1
|
||||
|
||||
if sourceAspect > targetAspect then
|
||||
local visible = targetAspect / sourceAspect
|
||||
u0 = (1 - visible) / 2
|
||||
u1 = 1 - u0
|
||||
elseif sourceAspect < targetAspect then
|
||||
local visible = sourceAspect / targetAspect
|
||||
v0 = (1 - visible) / 2
|
||||
v1 = 1 - v0
|
||||
end
|
||||
|
||||
surface.SetDrawColor(255, 255, 255)
|
||||
surface.SetMaterial(material)
|
||||
surface.DrawTexturedRectUV(x, y, width, height, u0, v0, u1, v1)
|
||||
end
|
||||
|
||||
local function DrawCard(card, width, height)
|
||||
local state = voteState
|
||||
local selected = state and state.selected == card.ChoiceIndex
|
||||
local winner = state and state.winner == card.ChoiceIndex
|
||||
local border = winner and colors.winner or (selected and colors.selected or colors.border)
|
||||
local background = card:IsHovered() and colors.cardHover or colors.card
|
||||
|
||||
draw.RoundedBox(8, 0, 0, width, height, border)
|
||||
draw.RoundedBox(7, 2, 2, width - 4, height - 4, background)
|
||||
|
||||
local imageHeight = math.floor((width - 4) * 9 / 16)
|
||||
imageHeight = math.min(imageHeight, height - 68)
|
||||
if card.PreviewMaterial then
|
||||
DrawPreviewCover(card.PreviewMaterial, 2, 2, width - 4, imageHeight)
|
||||
else
|
||||
surface.SetDrawColor(20, 30, 42)
|
||||
surface.DrawRect(2, 2, width - 4, imageHeight)
|
||||
draw.SimpleText("PREVIEW UNAVAILABLE", "CustomCoopMapVoteSmall", width / 2, imageHeight / 2, colors.muted, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER)
|
||||
end
|
||||
|
||||
draw.SimpleText(DisplayMapName(card.MapName), "CustomCoopMapVoteHeading", 12, imageHeight + 12, colors.text, TEXT_ALIGN_LEFT, TEXT_ALIGN_TOP)
|
||||
draw.SimpleText(card.MapName, "CustomCoopMapVoteSmall", 12, height - 25, colors.muted, TEXT_ALIGN_LEFT, TEXT_ALIGN_TOP)
|
||||
|
||||
local voteCount = state and state.counts[card.ChoiceIndex] or 0
|
||||
local badge = tostring(voteCount) .. (voteCount == 1 and " vote" or " votes")
|
||||
surface.SetFont("CustomCoopMapVoteSmall")
|
||||
local badgeWidth = surface.GetTextSize(badge) + 16
|
||||
draw.RoundedBox(10, width - badgeWidth - 10, height - 29, badgeWidth, 21, Color(8, 13, 19, 220))
|
||||
draw.SimpleText(badge, "CustomCoopMapVoteSmall", width - badgeWidth / 2 - 10, height - 19, colors.text, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER)
|
||||
|
||||
if selected or winner then
|
||||
local label = winner and "WINNER" or "YOUR CHOICE"
|
||||
draw.RoundedBox(4, 8, 8, 92, 24, border)
|
||||
draw.SimpleText(label, "CustomCoopMapVoteSmall", 54, 20, Color(8, 13, 19), TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER)
|
||||
end
|
||||
end
|
||||
|
||||
local function CloseVoteFrame()
|
||||
if IsValid(voteFrame) then voteFrame:Remove() end
|
||||
voteFrame = nil
|
||||
end
|
||||
|
||||
local function OpenVoteFrame()
|
||||
CloseVoteFrame()
|
||||
if not voteState then return end
|
||||
|
||||
local frameWidth = math.min(math.floor(ScrW() * 0.92), 1320)
|
||||
local frameHeight = math.min(math.floor(ScrH() * 0.9), 860)
|
||||
voteFrame = vgui.Create("DFrame")
|
||||
voteFrame:SetSize(frameWidth, frameHeight)
|
||||
voteFrame:Center()
|
||||
voteFrame:SetTitle("")
|
||||
voteFrame:SetDraggable(false)
|
||||
voteFrame:ShowCloseButton(false)
|
||||
voteFrame:MakePopup()
|
||||
voteFrame.Paint = function(_, width, height)
|
||||
Derma_DrawBackgroundBlur(voteFrame, voteState.openedAt)
|
||||
draw.RoundedBox(10, 0, 0, width, height, colors.backdrop)
|
||||
surface.SetDrawColor(colors.accent)
|
||||
surface.DrawRect(0, 0, width, 4)
|
||||
end
|
||||
|
||||
local header = vgui.Create("DPanel", voteFrame)
|
||||
header:Dock(TOP)
|
||||
header:SetTall(math.Clamp(math.floor(ScrH() * 0.13), 92, 126))
|
||||
header:DockMargin(20, 14, 20, 0)
|
||||
header.Paint = function(_, width)
|
||||
draw.SimpleText("CHOOSE THE NEXT MISSION", "CustomCoopMapVoteTitle", 0, 4, colors.text, TEXT_ALIGN_LEFT, TEXT_ALIGN_TOP)
|
||||
draw.SimpleText(voteState.reason, "CustomCoopMapVoteBody", 0, 48, colors.muted, TEXT_ALIGN_LEFT, TEXT_ALIGN_TOP)
|
||||
local remaining = math.max(math.ceil(voteState.endsAt - CurTime()), 0)
|
||||
local timerText = voteState.finished and "DECIDED" or tostring(remaining) .. "s"
|
||||
draw.SimpleText(timerText, "CustomCoopMapVoteTitle", width, 6, voteState.finished and colors.winner or colors.accent, TEXT_ALIGN_RIGHT, TEXT_ALIGN_TOP)
|
||||
draw.SimpleText(
|
||||
string.format("%d/%d players voted", voteState.submitted, voteState.players),
|
||||
"CustomCoopMapVoteSmall",
|
||||
width,
|
||||
51,
|
||||
colors.muted,
|
||||
TEXT_ALIGN_RIGHT,
|
||||
TEXT_ALIGN_TOP
|
||||
)
|
||||
end
|
||||
|
||||
local footer = vgui.Create("DPanel", voteFrame)
|
||||
footer:Dock(BOTTOM)
|
||||
footer:SetTall(50)
|
||||
footer:DockMargin(20, 0, 20, 12)
|
||||
footer.Paint = function(_, width, height)
|
||||
draw.RoundedBox(6, 0, 5, width, height - 5, colors.panel)
|
||||
local text = voteState.resultText or "Select one map. Your choice is highlighted; the vote ends early when everyone has chosen."
|
||||
draw.SimpleText(text, "CustomCoopMapVoteBody", width / 2, height / 2 + 2, voteState.finished and colors.winner or colors.muted, TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER)
|
||||
end
|
||||
|
||||
local scroll = vgui.Create("DScrollPanel", voteFrame)
|
||||
scroll:Dock(FILL)
|
||||
scroll:DockMargin(18, 8, 10, 8)
|
||||
|
||||
local grid = vgui.Create("DPanel", scroll)
|
||||
grid:Dock(TOP)
|
||||
grid.Paint = nil
|
||||
|
||||
local availableWidth = math.max(frameWidth - 60, 220)
|
||||
local columns = availableWidth >= 1120 and 4 or (availableWidth >= 760 and 3 or (availableWidth >= 500 and 2 or 1))
|
||||
local spacing = 12
|
||||
local cardWidth = math.floor((availableWidth - (columns - 1) * spacing) / columns)
|
||||
local cardHeight = math.floor(cardWidth * 9 / 16) + 68
|
||||
local rows = math.ceil(#voteState.choices / columns)
|
||||
grid:SetTall(rows * cardHeight + math.max(rows - 1, 0) * spacing)
|
||||
|
||||
voteState.cards = {}
|
||||
for index, mapName in ipairs(voteState.choices) do
|
||||
local card = vgui.Create("DButton", grid)
|
||||
local column = (index - 1) % columns
|
||||
local row = math.floor((index - 1) / columns)
|
||||
card:SetPos(column * (cardWidth + spacing), row * (cardHeight + spacing))
|
||||
card:SetSize(cardWidth, cardHeight)
|
||||
card:SetText("")
|
||||
card.ChoiceIndex = index
|
||||
card.MapName = mapName
|
||||
card:SetTooltip(mapName)
|
||||
card.Paint = DrawCard
|
||||
card.DoClick = function()
|
||||
if voteState.finished then return end
|
||||
voteState.selected = index
|
||||
net.Start("CustomCoopMapVoteCast")
|
||||
net.WriteUInt(voteState.id, 16)
|
||||
net.WriteUInt(index, 4)
|
||||
net.SendToServer()
|
||||
end
|
||||
voteState.cards[index] = card
|
||||
end
|
||||
|
||||
for index, card in ipairs(voteState.cards) do
|
||||
local mapName = voteState.choices[index]
|
||||
local succeeded, message = pcall(
|
||||
FetchPreview,
|
||||
card,
|
||||
voteState.previewBaseURL,
|
||||
mapName,
|
||||
voteState.workshopIDs[index] or ""
|
||||
)
|
||||
if not succeeded then
|
||||
ErrorNoHalt("[custom_coop] Could not request preview for " .. mapName .. ": " .. tostring(message) .. "\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
net.Receive("CustomCoopMapVoteStart", function()
|
||||
local id = net.ReadUInt(16)
|
||||
local duration = net.ReadFloat()
|
||||
local reason = net.ReadString()
|
||||
local previewBaseURL = net.ReadString()
|
||||
local choiceCount = net.ReadUInt(4)
|
||||
local choices = {}
|
||||
local workshopIDs = {}
|
||||
for index = 1, choiceCount do
|
||||
choices[index] = net.ReadString()
|
||||
workshopIDs[index] = net.ReadString()
|
||||
end
|
||||
|
||||
voteState = {
|
||||
id = id,
|
||||
openedAt = SysTime(),
|
||||
endsAt = CurTime() + duration,
|
||||
reason = reason,
|
||||
previewBaseURL = previewBaseURL,
|
||||
choices = choices,
|
||||
workshopIDs = workshopIDs,
|
||||
counts = {},
|
||||
players = 0,
|
||||
submitted = 0,
|
||||
finished = false,
|
||||
}
|
||||
for index = 1, choiceCount do voteState.counts[index] = 0 end
|
||||
OpenVoteFrame()
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopMapVoteUpdate", function()
|
||||
local id = net.ReadUInt(16)
|
||||
local players = net.ReadUInt(8)
|
||||
local submitted = net.ReadUInt(8)
|
||||
local choiceCount = net.ReadUInt(4)
|
||||
local counts = {}
|
||||
for index = 1, choiceCount do counts[index] = net.ReadUInt(8) end
|
||||
if not voteState or voteState.id ~= id then return end
|
||||
voteState.players = players
|
||||
voteState.submitted = submitted
|
||||
voteState.counts = counts
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopMapVoteResult", function()
|
||||
local id = net.ReadUInt(16)
|
||||
local winner = net.ReadString()
|
||||
local resolution = net.ReadString()
|
||||
local delay = net.ReadFloat()
|
||||
if not voteState or voteState.id ~= id then return end
|
||||
|
||||
voteState.finished = true
|
||||
voteState.resultText = resolution .. ". Loading " .. DisplayMapName(winner) .. "…"
|
||||
for index, mapName in ipairs(voteState.choices) do
|
||||
if mapName == winner then voteState.winner = index break end
|
||||
end
|
||||
timer.Create("CustomCoopMapVoteClose", math.max(delay + 4, 4), 1, CloseVoteFrame)
|
||||
end)
|
||||
@@ -58,6 +58,20 @@ local function ManagedBots()
|
||||
return result
|
||||
end
|
||||
|
||||
local function SelectRandomBotTrail()
|
||||
-- Half the bots deliberately have no trail. The other half choose only
|
||||
-- visible, effect-producing entries from the player shop catalogue.
|
||||
if math.random(2) ~= 1 then return nil end
|
||||
|
||||
local progression = CustomCoopProgression
|
||||
if not progression or not istable(progression.Trails) then return nil end
|
||||
local trails = {}
|
||||
for _, trail in ipairs(progression.Trails) do
|
||||
if trail.material and trail.material ~= "" then trails[#trails + 1] = trail end
|
||||
end
|
||||
return #trails > 0 and trails[math.random(#trails)].id or nil
|
||||
end
|
||||
|
||||
local function BotProfile(bot)
|
||||
if not IsManagedBot(bot) then return end
|
||||
local key = bot:Nick()
|
||||
@@ -76,16 +90,15 @@ local function BotProfile(bot)
|
||||
if istable(progression.Models) and #progression.Models > 0 then
|
||||
profile.modelID = progression.Models[math.random(#progression.Models)].id
|
||||
end
|
||||
if istable(progression.Trails) and #progression.Trails > 0 then
|
||||
local trails = {}
|
||||
for _, trail in ipairs(progression.Trails) do
|
||||
if trail.material and trail.material ~= "" then trails[#trails + 1] = trail end
|
||||
end
|
||||
if #trails > 0 then profile.trailID = trails[math.random(#trails)].id end
|
||||
end
|
||||
end
|
||||
fakeProfiles[key] = profile
|
||||
end
|
||||
-- Versioning applies the corrected 50/50 selection to profiles created
|
||||
-- before this behaviour was introduced, including a live Lua refresh.
|
||||
if profile.cosmeticVersion ~= 2 then
|
||||
profile.trailID = SelectRandomBotTrail()
|
||||
profile.cosmeticVersion = 2
|
||||
end
|
||||
return profile
|
||||
end
|
||||
|
||||
@@ -148,6 +161,10 @@ local function UpdateBotProfiles()
|
||||
profile.playtime = math.max(profile.playtime or 0, 0) + math.max(now - previous, 0)
|
||||
profile.lastUpdated = now
|
||||
SyncBotProfile(bot, profile)
|
||||
if profile.cosmeticsAppliedVersion ~= profile.cosmeticVersion and bot:Alive() then
|
||||
ApplyBotCosmetics(bot, profile)
|
||||
profile.cosmeticsAppliedVersion = profile.cosmeticVersion
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ AddCSLuaFile( "shared.lua" )
|
||||
AddCSLuaFile( "levels.lua" )
|
||||
AddCSLuaFile( "progression_shared.lua" )
|
||||
AddCSLuaFile( "cl_progression.lua" )
|
||||
AddCSLuaFile( "cl_mapvote.lua" )
|
||||
include( "map_compatibility.lua" )
|
||||
include( "spawninit.lua" )
|
||||
include( "weaponsystem.lua" )
|
||||
@@ -18,6 +19,7 @@ include( "dynamicmovement.lua" )
|
||||
include( "mapperstuff.lua" )
|
||||
include( "noheadcrabs.lua" )
|
||||
include( "shared.lua" )
|
||||
include( "workshop_maps.lua" )
|
||||
include( "maprotation.lua" )
|
||||
include( "sv_progression.lua" )
|
||||
include( "coop_bots.lua" )
|
||||
|
||||
@@ -410,7 +410,153 @@ COOP_LEVELS = {
|
||||
"cl_coop_cages",
|
||||
"cl_coop_breakable",
|
||||
"cl_coop_breakable_b2",
|
||||
"ad_coop_mara_final"
|
||||
"ad_coop_mara_final",
|
||||
"cl_coop_pushup_b1",
|
||||
"coop_6",
|
||||
"coop_arn_labyrinth_b3",
|
||||
"coop_d3_c17_08_d_2y_cube",
|
||||
"coop_meows_kill_em_all_z1",
|
||||
"coop_rebel_fix",
|
||||
"coop_rocks_l4d_offices_v1",
|
||||
"coop_station17_v3",
|
||||
"coop_stroll_in_forest_v1",
|
||||
"coop_swampthing_v4",
|
||||
"coop_town_level_3i_cube",
|
||||
"coop_tropic_v2",
|
||||
"coop_yla_bridge",
|
||||
"fixb2_underground",
|
||||
"js_coop_blueberry_ob_v1_d",
|
||||
"js_coop_combine2_sg_b",
|
||||
"js_coop_hanhack_v6",
|
||||
"js_coop_killer_lost_final",
|
||||
"js_coop_level3_x_v4",
|
||||
"js_coop_monsterkill_v1c",
|
||||
"js_coop_p1_waterworks_r1",
|
||||
"js_coop_shore_v6_bb",
|
||||
"js_coop_ultraviolet_x_v2",
|
||||
"level04_across_the_darkness3a",
|
||||
"mm_coop_coastline_v3",
|
||||
"mm_coop_facility_v2",
|
||||
"mm_coop_rebels_vs_combine_v7",
|
||||
"mm_coop_rgbcircle_v1a",
|
||||
"mph_rebel_incursion5",
|
||||
"mph_zombie_invasion_v0",
|
||||
"oc_adventure_part1_dev",
|
||||
"oc_atfor_cityfight_v3",
|
||||
"oc_bossrush_v3b",
|
||||
"oc_breakout",
|
||||
"oc_combinebase_hl2mp_1",
|
||||
"oc_dday+1_betav2",
|
||||
"oc_dirtraceb2",
|
||||
"oc_docks",
|
||||
"oc_forest_house_fix",
|
||||
"oc_garginfestion",
|
||||
"oc_helicave",
|
||||
"oc_hivoltage_v2_x_gor",
|
||||
"oc_hl2portal_mix_final",
|
||||
"oc_kokkai_v1a",
|
||||
"oc_l4d_trainstation_01_v1",
|
||||
"oc_l4d_trainstation_02_v5",
|
||||
"oc_longways",
|
||||
"oc_moegladosfactory_v2",
|
||||
"oc_oxgen_02",
|
||||
"oc_r-dayfix",
|
||||
"oc_ratshouse_part1fix3",
|
||||
"oc_snowstorm",
|
||||
"oc_sogeki_v2b",
|
||||
"oc_sso_v1j",
|
||||
"oc_starship_troopers",
|
||||
"oc_suiyousp_v2b2",
|
||||
"oc_systems2-1",
|
||||
"oc_systems2-2",
|
||||
"oc_systems2-3",
|
||||
"oc_systems2-4",
|
||||
"oc_temple_1_d",
|
||||
"oc_trainride_a",
|
||||
"oc_wgh",
|
||||
"oc_wildfire",
|
||||
"oc_zelda02_g",
|
||||
"stk_coopbox_v1a",
|
||||
"syn_across_the_darkness_b3",
|
||||
"syn_anotherworldwar01_b1",
|
||||
"syn_apprehension",
|
||||
"syn_atfp1",
|
||||
"syn_bluevelvet_surv_mission_v2",
|
||||
"syn_breenoffice_v1c",
|
||||
"syn_bridge_b5",
|
||||
"syn_broken_escape_part1",
|
||||
"syn_bunker",
|
||||
"syn_burlybrawl",
|
||||
"syn_canal_escape_part1",
|
||||
"syn_canal_escape_part2",
|
||||
"syn_canal_escape_part3",
|
||||
"syn_catacombs",
|
||||
"syn_city_riot_alpha2",
|
||||
"syn_country_train",
|
||||
"syn_d_crimson_v1c",
|
||||
"syn_def_jailbreak",
|
||||
"syn_defiance_beta4",
|
||||
"syn_desert_strikes_b3",
|
||||
"syn_falling_east_final",
|
||||
"syn_farm",
|
||||
"syn_fdg_hostage_b22",
|
||||
"syn_fireteam_1_b1",
|
||||
"syn_fireteam_2_b1",
|
||||
"syn_fogisland",
|
||||
"syn_forgotten_evilsv2",
|
||||
"syn_future_b2",
|
||||
"syn_graveyardshift_gt",
|
||||
"syn_grigori_s_death",
|
||||
"syn_heist01",
|
||||
"syn_horror_hl2mpru_v2",
|
||||
"syn_indianaj1alpha2",
|
||||
"syn_interchange_overrun",
|
||||
"syn_invasion",
|
||||
"syn_kab_escape_part1_v4",
|
||||
"syn_kab_escape_part2_v2",
|
||||
"syn_littletowndefense_hl2mp",
|
||||
"syn_mountaineer_v39",
|
||||
"syn_oldcanals",
|
||||
"syn_op_flashpoint_hl2mp",
|
||||
"syn_portlands_beta2",
|
||||
"syn_resistance_b3",
|
||||
"syn_roof_zip",
|
||||
"syn_silent_house",
|
||||
"syn_starship_oblivion",
|
||||
"syn_takeover",
|
||||
"syn_takeover_new",
|
||||
"syn_trials",
|
||||
"syn_trials2_b",
|
||||
"syn_trials][lite",
|
||||
"syn_ufo_beta2",
|
||||
"syn_ufo_start_beta2",
|
||||
"syn_urbanchaos",
|
||||
"syn_waroftheclones_beta2",
|
||||
"syn_world02_v5",
|
||||
"syn_zombiemall_b3_hl2mp",
|
||||
"synb2_a_place_to_die",
|
||||
"synb2_across_the_darkness",
|
||||
"synb2_ammuu_b1",
|
||||
"synb2_catacombs_b3",
|
||||
"synb2_die_assault_b2",
|
||||
"synb2_diehard",
|
||||
"synb2_doom_arena",
|
||||
"synb2_end_of_evil_part1_b1",
|
||||
"synb2_entryway_of_doom",
|
||||
"synb2_fast_df_extinction_hl2mp",
|
||||
"synb2_grand_bleu_b1",
|
||||
"synb2_lost_temple_b1",
|
||||
"synb2_operation_blackhole_b2",
|
||||
"synb2_road_of_the_temple_fix",
|
||||
"synb2_scary_dark_house",
|
||||
"synb2_school_for_freshman_fix",
|
||||
"synb2_tricks_and_traps",
|
||||
"synob_hive_resevil_3",
|
||||
"synob_nautilus_1r",
|
||||
"synob_nautilus_2r",
|
||||
"synr1_qk_dark_tower_b2",
|
||||
"tbr_coop_red_keops_v3",
|
||||
"z_umi_boatz_roketz"
|
||||
}
|
||||
|
||||
function GetInstalledCoopLevels()
|
||||
|
||||
@@ -22,17 +22,11 @@ function RemoveSpeedMods()
|
||||
end
|
||||
end
|
||||
|
||||
function MapChange(mapname)
|
||||
function CompleteCoopMapChange(mapname)
|
||||
mapname = string.lower(tostring(mapname or ""))
|
||||
if not file.Exists("maps/" .. mapname .. ".bsp", "GAME") then
|
||||
local fallback = GetRandomLevel()
|
||||
if not fallback then
|
||||
ErrorNoHalt("[custom_coop] Cannot change map: '" .. mapname .. "' is unavailable and no fallback is installed\n")
|
||||
return false
|
||||
end
|
||||
|
||||
ErrorNoHalt("[custom_coop] Map '" .. mapname .. "' is unavailable; using " .. fallback .. " instead\n")
|
||||
mapname = fallback
|
||||
ErrorNoHalt("[custom_coop] Refusing unavailable voted map '" .. mapname .. "'\n")
|
||||
return false
|
||||
end
|
||||
|
||||
file.Write("weptranslevel.txt",string.lower(mapname))
|
||||
@@ -85,6 +79,13 @@ function MapChange(mapname)
|
||||
timer.Simple( 2, function() RunConsoleCommand("changelevel",string.lower(mapname)) end )
|
||||
return true
|
||||
end
|
||||
|
||||
-- Map scripts call this when their mission is complete. Destination arguments
|
||||
-- are deliberately ignored: every completion now opens the common GUI vote.
|
||||
function MapChange()
|
||||
if not QueueCoopMapChange then return false end
|
||||
return QueueCoopMapChange("Mission complete")
|
||||
end
|
||||
|
||||
function LoadWeapons()
|
||||
if(game.SinglePlayer() && file.Read("weptransgametype.txt","DATA") == "singleplayer") then
|
||||
|
||||
@@ -2,44 +2,66 @@ local changeDelay = CreateConVar(
|
||||
"gc_mapchangedelay",
|
||||
"8",
|
||||
FCVAR_ARCHIVE,
|
||||
"Seconds between map completion and loading the next coop map"
|
||||
"Seconds between resolving the end-of-map vote and loading its winner"
|
||||
)
|
||||
|
||||
local maxMapTime = CreateConVar(
|
||||
"gc_mapmaxtime",
|
||||
"3600",
|
||||
FCVAR_ARCHIVE,
|
||||
"Maximum map time in seconds before rotating; 0 disables the time limit"
|
||||
"Maximum map time in seconds before opening the map vote; 0 disables the time limit"
|
||||
)
|
||||
|
||||
local skipVoteRatio = CreateConVar(
|
||||
"gc_mapskipvoteratio",
|
||||
"0.6",
|
||||
FCVAR_ARCHIVE,
|
||||
"Fraction of connected human players required to skip the current map"
|
||||
"Fraction of connected human players required to end the current map"
|
||||
)
|
||||
|
||||
local nextMapVoteRatio = CreateConVar(
|
||||
"gc_nextmapvoteratio",
|
||||
"0.6",
|
||||
local mapVoteDuration = CreateConVar(
|
||||
"gc_mapvoteduration",
|
||||
"30",
|
||||
FCVAR_ARCHIVE,
|
||||
"Fraction of connected human players required to select the next map"
|
||||
"Seconds players have to choose the next map"
|
||||
)
|
||||
|
||||
local skipChangeDelay = CreateConVar(
|
||||
"gc_mapskipchangedelay",
|
||||
"3",
|
||||
local mapVoteChoiceCount = CreateConVar(
|
||||
"gc_mapvotechoices",
|
||||
"8",
|
||||
FCVAR_ARCHIVE,
|
||||
"Seconds between a successful player skip vote and changing map"
|
||||
"Number of installed maps offered by the end-of-map vote (2-12)"
|
||||
)
|
||||
|
||||
local changeQueued = false
|
||||
local mapPreviewBaseURL = CreateConVar(
|
||||
"gc_mapvotepreviewurl",
|
||||
"https://garrysmod.brunner.ninja/assets/map-previews",
|
||||
FCVAR_ARCHIVE,
|
||||
"Public HTTPS directory containing <mapname>.jpg vote previews"
|
||||
)
|
||||
|
||||
util.AddNetworkString("CustomCoopMapVoteStart")
|
||||
util.AddNetworkString("CustomCoopMapVoteCast")
|
||||
util.AddNetworkString("CustomCoopMapVoteUpdate")
|
||||
util.AddNetworkString("CustomCoopMapVoteResult")
|
||||
|
||||
local MAP_VOTE_TIMER = "CustomCoopMapVoteDeadline"
|
||||
local MAP_CHANGE_TIMER = "CustomCoopChangeCompletedMap"
|
||||
local MAP_CHANGE_WATCHDOG = "CustomCoopChangeLevelWatchdog"
|
||||
local TRANSITION_RELAY_NAME = "custom_coop_map_vote_relay"
|
||||
local TRANSITION_SENTINEL = "__custom_coop_vote__"
|
||||
|
||||
local skipVotes = {}
|
||||
local nextMapVotes = {}
|
||||
local nextMapChoices = {}
|
||||
local nextMapVoteFinished = false
|
||||
local installedMaps
|
||||
local failedMapChanges = {}
|
||||
local installedMaps
|
||||
local changeQueued = false
|
||||
local voteActive = false
|
||||
local voteID = math.random(1, 65535)
|
||||
local voteEndsAt = 0
|
||||
local voteReason = ""
|
||||
local voteChoices = {}
|
||||
local mapVotes = {}
|
||||
local lastVoteAt = {}
|
||||
|
||||
local function RefreshInstalledMaps()
|
||||
installedMaps = {}
|
||||
@@ -55,47 +77,21 @@ local function MapExists(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 = GetRandomAvailableMap()
|
||||
if nextLevel then
|
||||
print("[custom_coop] Setting nextlevel to " .. nextLevel)
|
||||
RunConsoleCommand("nextlevel", nextLevel)
|
||||
end
|
||||
end
|
||||
return nextLevel
|
||||
end
|
||||
|
||||
local function PlayerVoteID(ply)
|
||||
local steamID64 = ply:SteamID64()
|
||||
if steamID64 and steamID64 ~= "" then return steamID64 end
|
||||
return "user:" .. tostring(ply:UserID())
|
||||
end
|
||||
|
||||
local function RequiredVotes(ratioConVar)
|
||||
local function RequiredSkipVotes()
|
||||
local humanCount = #player.GetHumans()
|
||||
local ratio = math.Clamp(ratioConVar:GetFloat(), 0.1, 1)
|
||||
local ratio = math.Clamp(skipVoteRatio:GetFloat(), 0.1, 1)
|
||||
return math.max(math.ceil(humanCount * ratio), 1)
|
||||
end
|
||||
|
||||
local function BuildNextMapChoices()
|
||||
local function BuildMapVoteChoices()
|
||||
local candidates = {}
|
||||
local currentMap = string.lower(game.GetMap())
|
||||
local currentMap = string.lower(game.GetMap() or "")
|
||||
|
||||
for _, mapName in ipairs(GetInstalledCoopLevels()) do
|
||||
if mapName ~= currentMap and MapExists(mapName) then
|
||||
@@ -108,80 +104,179 @@ local function BuildNextMapChoices()
|
||||
candidates[index], candidates[other] = candidates[other], candidates[index]
|
||||
end
|
||||
|
||||
nextMapChoices = {}
|
||||
local plannedMap = SetFreshNextLevel()
|
||||
if plannedMap then
|
||||
nextMapChoices[1] = string.lower(plannedMap)
|
||||
local choices = {}
|
||||
local limit = math.Clamp(mapVoteChoiceCount:GetInt(), 2, 12)
|
||||
for index = 1, math.min(limit, #candidates) do
|
||||
choices[index] = candidates[index]
|
||||
end
|
||||
return choices
|
||||
end
|
||||
|
||||
for _, mapName in ipairs(candidates) do
|
||||
if #nextMapChoices >= 5 then break end
|
||||
if not table.HasValue(nextMapChoices, mapName) then
|
||||
nextMapChoices[#nextMapChoices + 1] = mapName
|
||||
local function ConnectedVoteCounts()
|
||||
local counts = {}
|
||||
local submitted = 0
|
||||
for index = 1, #voteChoices do counts[index] = 0 end
|
||||
|
||||
for _, ply in ipairs(player.GetHumans()) do
|
||||
local choice = mapVotes[PlayerVoteID(ply)]
|
||||
if choice and voteChoices[choice] then
|
||||
counts[choice] = counts[choice] + 1
|
||||
submitted = submitted + 1
|
||||
end
|
||||
end
|
||||
return counts, submitted
|
||||
end
|
||||
|
||||
local function ShowNextMapChoices(ply)
|
||||
if #nextMapChoices == 0 then BuildNextMapChoices() end
|
||||
local function SendVoteStart(ply)
|
||||
if not voteActive or not IsValid(ply) or ply:IsBot() then return end
|
||||
|
||||
if #nextMapChoices == 0 then
|
||||
ply:ChatPrint("[CO-OP] No alternative installed co-op map is available.")
|
||||
return
|
||||
net.Start("CustomCoopMapVoteStart")
|
||||
net.WriteUInt(voteID, 16)
|
||||
net.WriteFloat(math.max(voteEndsAt - CurTime(), 0))
|
||||
net.WriteString(voteReason)
|
||||
net.WriteString(mapPreviewBaseURL:GetString())
|
||||
net.WriteUInt(#voteChoices, 4)
|
||||
for _, mapName in ipairs(voteChoices) do
|
||||
net.WriteString(mapName)
|
||||
net.WriteString(tostring((COOP_WORKSHOP_IDS or {})[mapName] or ""))
|
||||
end
|
||||
|
||||
ply:ChatPrint("[CO-OP] Vote for the map that should follow this one:")
|
||||
for index, mapName in ipairs(nextMapChoices) do
|
||||
ply:ChatPrint(string.format("[CO-OP] %d. %s", index, mapName))
|
||||
end
|
||||
ply:ChatPrint("[CO-OP] Type !votenext <number>. Type !skip if this map is stuck.")
|
||||
net.Send(ply)
|
||||
end
|
||||
|
||||
local function ResolveNextMapChoice(requestedChoice)
|
||||
local choiceNumber = tonumber(requestedChoice)
|
||||
if choiceNumber then
|
||||
return nextMapChoices[math.floor(choiceNumber)]
|
||||
end
|
||||
local function BroadcastVoteCounts()
|
||||
if not voteActive then return end
|
||||
local counts, submitted = ConnectedVoteCounts()
|
||||
|
||||
local normalizedChoice = string.lower(string.Trim(requestedChoice or ""))
|
||||
for _, mapName in ipairs(nextMapChoices) do
|
||||
if normalizedChoice == mapName then return mapName end
|
||||
end
|
||||
net.Start("CustomCoopMapVoteUpdate")
|
||||
net.WriteUInt(voteID, 16)
|
||||
net.WriteUInt(math.min(#player.GetHumans(), 255), 8)
|
||||
net.WriteUInt(math.min(submitted, 255), 8)
|
||||
net.WriteUInt(#counts, 4)
|
||||
for _, count in ipairs(counts) do net.WriteUInt(math.min(count, 255), 8) end
|
||||
net.Broadcast()
|
||||
end
|
||||
|
||||
local function CastNextMapVote(ply, requestedChoice)
|
||||
if nextMapVoteFinished then
|
||||
ply:ChatPrint("[CO-OP] The next map has already been selected: " .. GetConVar("nextlevel"):GetString())
|
||||
return
|
||||
local function EveryoneHasVoted()
|
||||
local humans = player.GetHumans()
|
||||
if #humans == 0 then return false end
|
||||
for _, ply in ipairs(humans) do
|
||||
if not mapVotes[PlayerVoteID(ply)] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local mapName = ResolveNextMapChoice(requestedChoice)
|
||||
if not mapName then
|
||||
ply:ChatPrint("[CO-OP] That is not one of the current choices.")
|
||||
ShowNextMapChoices(ply)
|
||||
return
|
||||
end
|
||||
local function BeginMapChange(destination, resolution)
|
||||
changeQueued = true
|
||||
RunConsoleCommand("nextlevel", destination)
|
||||
|
||||
nextMapVotes[PlayerVoteID(ply)] = mapName
|
||||
local serverIsEmpty = #player.GetHumans() == 0
|
||||
local delay = serverIsEmpty and 0 or math.max(changeDelay:GetFloat(), 0)
|
||||
net.Start("CustomCoopMapVoteResult")
|
||||
net.WriteUInt(voteID, 16)
|
||||
net.WriteString(destination)
|
||||
net.WriteString(resolution)
|
||||
net.WriteFloat(delay)
|
||||
net.Broadcast()
|
||||
|
||||
local votesForMap = 0
|
||||
for _, connectedPlayer in ipairs(player.GetHumans()) do
|
||||
if nextMapVotes[PlayerVoteID(connectedPlayer)] == mapName then
|
||||
votesForMap = votesForMap + 1
|
||||
end
|
||||
end
|
||||
|
||||
local required = RequiredVotes(nextMapVoteRatio)
|
||||
PrintMessage(HUD_PRINTTALK, string.format(
|
||||
"%s voted for %s as the next map (%d/%d).",
|
||||
ply:Nick(), mapName, votesForMap, required
|
||||
print(string.format(
|
||||
"[custom_coop] Map vote selected %s (%s); changing in %.1f seconds",
|
||||
destination,
|
||||
resolution,
|
||||
delay
|
||||
))
|
||||
|
||||
if votesForMap < required then return end
|
||||
timer.Create(MAP_CHANGE_TIMER, delay, 1, function()
|
||||
local sourceMap = string.lower(game.GetMap() or "")
|
||||
if serverIsEmpty then
|
||||
RunConsoleCommand("changelevel", destination)
|
||||
elseif CompleteCoopMapChange then
|
||||
CompleteCoopMapChange(destination)
|
||||
else
|
||||
RunConsoleCommand("changelevel", destination)
|
||||
end
|
||||
|
||||
nextMapVoteFinished = true
|
||||
RunConsoleCommand("nextlevel", mapName)
|
||||
PrintMessage(HUD_PRINTTALK, "Next map selected: " .. mapName)
|
||||
timer.Create(MAP_CHANGE_WATCHDOG, 12, 1, function()
|
||||
if string.lower(game.GetMap() or "") ~= sourceMap then return end
|
||||
|
||||
failedMapChanges[destination] = true
|
||||
changeQueued = false
|
||||
skipVotes = {}
|
||||
if string.lower(GetConVar("nextlevel"):GetString()) == destination then
|
||||
RunConsoleCommand("nextlevel", "")
|
||||
end
|
||||
|
||||
PrintMessage(HUD_PRINTTALK, "[CO-OP] Could not load " .. destination .. "; opening a new map vote.")
|
||||
ErrorNoHalt("[custom_coop] changelevel failed for " .. destination .. "; destination quarantined until restart\n")
|
||||
timer.Simple(1, function() QueueCoopMapChange("Map load failed") end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function ResolveMapVote()
|
||||
if not voteActive then return end
|
||||
voteActive = false
|
||||
timer.Remove(MAP_VOTE_TIMER)
|
||||
|
||||
local counts, submitted = ConnectedVoteCounts()
|
||||
local winnerIndex
|
||||
local resolution
|
||||
|
||||
if submitted > 0 then
|
||||
for index, count in ipairs(counts) do
|
||||
if count > submitted / 2 then
|
||||
winnerIndex = index
|
||||
resolution = "Majority choice"
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not winnerIndex then
|
||||
local selected = {}
|
||||
for index, count in ipairs(counts) do
|
||||
if count > 0 then selected[#selected + 1] = index end
|
||||
end
|
||||
winnerIndex = selected[math.random(#selected)]
|
||||
resolution = "No majority — randomly selected from the voted maps"
|
||||
end
|
||||
else
|
||||
winnerIndex = math.random(#voteChoices)
|
||||
resolution = "No votes — randomly selected from all choices"
|
||||
end
|
||||
|
||||
BeginMapChange(voteChoices[winnerIndex], resolution)
|
||||
end
|
||||
|
||||
local function StartMapVote(reason)
|
||||
if voteActive or changeQueued then return false end
|
||||
|
||||
voteChoices = BuildMapVoteChoices()
|
||||
if #voteChoices == 0 then
|
||||
ErrorNoHalt("[custom_coop] Map completed, but no alternative installed co-op map is available\n")
|
||||
return false
|
||||
end
|
||||
|
||||
voteID = (voteID % 65535) + 1
|
||||
voteActive = true
|
||||
voteReason = reason or "Mission complete"
|
||||
mapVotes = {}
|
||||
lastVoteAt = {}
|
||||
voteEndsAt = CurTime() + math.Clamp(mapVoteDuration:GetFloat(), 5, 120)
|
||||
RunConsoleCommand("nextlevel", "")
|
||||
|
||||
if #player.GetHumans() == 0 then
|
||||
ResolveMapVote()
|
||||
return true
|
||||
end
|
||||
|
||||
for _, ply in ipairs(player.GetHumans()) do SendVoteStart(ply) end
|
||||
BroadcastVoteCounts()
|
||||
timer.Create(MAP_VOTE_TIMER, math.max(voteEndsAt - CurTime(), 0), 1, ResolveMapVote)
|
||||
print("[custom_coop] End-of-map vote started: " .. voteReason)
|
||||
return true
|
||||
end
|
||||
|
||||
function QueueCoopMapChange(reason)
|
||||
return StartMapVote(reason)
|
||||
end
|
||||
|
||||
local function CountCurrentSkipVotes()
|
||||
@@ -202,17 +297,15 @@ local function CountCurrentSkipVotes()
|
||||
end
|
||||
|
||||
local function CheckSkipVote()
|
||||
if changeQueued then return end
|
||||
|
||||
local voteCount = CountCurrentSkipVotes()
|
||||
if voteCount >= RequiredVotes(skipVoteRatio) then
|
||||
QueueCoopMapChange("player skip vote")
|
||||
if voteActive or changeQueued then return end
|
||||
if CountCurrentSkipVotes() >= RequiredSkipVotes() then
|
||||
StartMapVote("Players voted to skip")
|
||||
end
|
||||
end
|
||||
|
||||
local function CastSkipVote(ply)
|
||||
if changeQueued then
|
||||
ply:ChatPrint("[CO-OP] A map change is already on its way.")
|
||||
if voteActive or changeQueued then
|
||||
ply:ChatPrint("[CO-OP] The end-of-map vote is already underway.")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -224,7 +317,7 @@ local function CastSkipVote(ply)
|
||||
|
||||
skipVotes[voterID] = true
|
||||
local voteCount = CountCurrentSkipVotes()
|
||||
local required = RequiredVotes(skipVoteRatio)
|
||||
local required = RequiredSkipVotes()
|
||||
PrintMessage(HUD_PRINTTALK, string.format(
|
||||
"%s voted to skip this map (%d/%d). Type !skip to vote.",
|
||||
ply:Nick(), voteCount, required
|
||||
@@ -232,88 +325,54 @@ local function CastSkipVote(ply)
|
||||
CheckSkipVote()
|
||||
end
|
||||
|
||||
function QueueCoopMapChange(reason, requestedMap)
|
||||
if changeQueued then return end
|
||||
net.Receive("CustomCoopMapVoteCast", function(_, ply)
|
||||
if not voteActive or not IsValid(ply) or ply:IsBot() then return end
|
||||
if net.ReadUInt(16) ~= voteID then return end
|
||||
|
||||
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
|
||||
local now = CurTime()
|
||||
local voterID = PlayerVoteID(ply)
|
||||
if now < (lastVoteAt[voterID] or 0) + 0.1 then return end
|
||||
lastVoteAt[voterID] = now
|
||||
|
||||
if not destination then
|
||||
ErrorNoHalt("[custom_coop] Map completed, but no installed destination map is available\n")
|
||||
return
|
||||
end
|
||||
local choice = net.ReadUInt(4)
|
||||
if not voteChoices[choice] then return end
|
||||
mapVotes[voterID] = choice
|
||||
BroadcastVoteCounts()
|
||||
|
||||
changeQueued = true
|
||||
local delay = reason == "player skip vote"
|
||||
and math.max(skipChangeDelay:GetFloat(), 0)
|
||||
or math.max(changeDelay:GetFloat(), 0)
|
||||
PrintMessage(HUD_PRINTTALK, "Loading " .. destination .. " in " .. math.ceil(delay) .. " seconds.")
|
||||
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
|
||||
if EveryoneHasVoted() then ResolveMapVote() end
|
||||
end)
|
||||
|
||||
hook.Add("PlayerSay", "CustomCoopMapVoting", function(ply, text)
|
||||
local command = string.lower(string.Trim(text or ""))
|
||||
|
||||
if command == "!skip" or command == "/skip" or command == "!skipmap" then
|
||||
CastSkipVote(ply)
|
||||
return ""
|
||||
end
|
||||
|
||||
if command == "!nextmap" or command == "/nextmap"
|
||||
or command == "!votenext" or command == "/votenext" then
|
||||
ShowNextMapChoices(ply)
|
||||
return ""
|
||||
end
|
||||
|
||||
local requestedChoice = string.match(command, "^[!/]votenext%s+(.+)$")
|
||||
if requestedChoice then
|
||||
CastNextMapVote(ply, requestedChoice)
|
||||
return ""
|
||||
end
|
||||
end)
|
||||
|
||||
hook.Add("PlayerInitialSpawn", "CustomCoopExplainMapVoting", function(ply)
|
||||
timer.Simple(2, function()
|
||||
if IsValid(ply) and voteActive then
|
||||
SendVoteStart(ply)
|
||||
BroadcastVoteCounts()
|
||||
end
|
||||
end)
|
||||
timer.Simple(6, function()
|
||||
if not IsValid(ply) then return end
|
||||
ply:ChatPrint("[CO-OP] Type !nextmap to vote for what comes next, or !skip if the current map is stuck.")
|
||||
ply:ChatPrint("[CO-OP] Type !skip if the current map is stuck. The next map is chosen on-screen when the mission ends.")
|
||||
end)
|
||||
end)
|
||||
|
||||
hook.Add("PlayerDisconnected", "CustomCoopRecountMapSkipVote", function()
|
||||
timer.Simple(0, CheckSkipVote)
|
||||
hook.Add("PlayerDisconnected", "CustomCoopRecountMapVotes", function(ply)
|
||||
skipVotes[PlayerVoteID(ply)] = nil
|
||||
mapVotes[PlayerVoteID(ply)] = nil
|
||||
timer.Simple(0, function()
|
||||
CheckSkipVote()
|
||||
if voteActive then
|
||||
BroadcastVoteCounts()
|
||||
if EveryoneHasVoted() then ResolveMapVote() end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
hook.Add("AcceptInput", "CustomCoopDetectMapCompletion", function(ent, inputName, _, _, value)
|
||||
@@ -323,45 +382,61 @@ hook.Add("AcceptInput", "CustomCoopDetectMapCompletion", function(ent, inputName
|
||||
local input = string.lower(inputName or "")
|
||||
|
||||
if className == "game_end" and input == "endgame" then
|
||||
QueueCoopMapChange("game_end")
|
||||
StartMapVote("Mission complete")
|
||||
return true
|
||||
end
|
||||
|
||||
if className == "point_changelevel" and (input == "changelevel" or input == "changelevelnow") then
|
||||
StartMapVote("Mission complete")
|
||||
return true
|
||||
end
|
||||
|
||||
if className ~= "point_servercommand" or input ~= "command" then return end
|
||||
|
||||
local command = string.lower(string.Trim(tostring(value or "")))
|
||||
local requestedMap = string.match(command, "^changelevel%s+[%\"]?([%w_%-%[%]!]+)")
|
||||
or string.match(command, "^map%s+[%\"]?([%w_%-%[%]!]+)")
|
||||
|
||||
if requestedMap and (not MapExists(requestedMap) or string.lower(requestedMap) == string.lower(game.GetMap())) then
|
||||
QueueCoopMapChange("point_servercommand", requestedMap)
|
||||
if string.match(command, "^changelevel%s+") or string.match(command, "^map%s+") then
|
||||
StartMapVote("Mission complete")
|
||||
return true
|
||||
end
|
||||
end)
|
||||
|
||||
-- Repair old trigger_changelevel/point_changelevel entities that refer to a map
|
||||
-- which is not mounted. Valid campaign destinations remain untouched.
|
||||
hook.Add("EntityKeyValue", "CustomCoopRepairChangelevelTargets", function(ent, key, value)
|
||||
local className = string.lower(ent:GetClass())
|
||||
if className ~= "trigger_changelevel" and className ~= "point_changelevel" then return end
|
||||
-- A trigger_changelevel performs its transition inside the engine instead of
|
||||
-- issuing an interceptable console command. Point it at a guaranteed-missing
|
||||
-- sentinel and add a Lua output which opens the normal vote when touched.
|
||||
hook.Add("EntityKeyValue", "CustomCoopInterceptChangelevelTargets", function(ent, key, value)
|
||||
if string.lower(ent:GetClass()) ~= "trigger_changelevel" then return end
|
||||
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 = GetRandomAvailableMap()
|
||||
if replacement then
|
||||
print("[custom_coop] Replacing unavailable changelevel target '" .. tostring(value) .. "' with " .. replacement)
|
||||
return replacement
|
||||
end
|
||||
ent.CustomCoopOriginalTransition = value
|
||||
return TRANSITION_SENTINEL
|
||||
end)
|
||||
|
||||
hook.Add("InitPostEntity", "CustomCoopStartMapTimeLimit", function()
|
||||
SetFreshNextLevel()
|
||||
BuildNextMapChoices()
|
||||
local function ArmChangelevelTriggers()
|
||||
local relay = ents.FindByName(TRANSITION_RELAY_NAME)[1]
|
||||
if not IsValid(relay) then
|
||||
relay = ents.Create("lua_run")
|
||||
if not IsValid(relay) then return end
|
||||
relay:SetName(TRANSITION_RELAY_NAME)
|
||||
relay:Spawn()
|
||||
end
|
||||
|
||||
for _, ent in ipairs(ents.FindByClass("trigger_changelevel")) do
|
||||
ent:Fire(
|
||||
"AddOutput",
|
||||
"OnStartTouch " .. TRANSITION_RELAY_NAME .. ":RunPassedCode:QueueCoopMapChange('Mission complete'):0:-1"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
hook.Add("InitPostEntity", "CustomCoopStartMapRotation", function()
|
||||
RunConsoleCommand("nextlevel", "")
|
||||
ArmChangelevelTriggers()
|
||||
|
||||
local limit = maxMapTime:GetFloat()
|
||||
if limit <= 0 then return end
|
||||
|
||||
timer.Create("CustomCoopMapTimeLimit", limit, 1, function()
|
||||
QueueCoopMapChange("time limit")
|
||||
StartMapVote("Map time limit reached")
|
||||
end)
|
||||
end)
|
||||
|
||||
hook.Add("PostCleanupMap", "CustomCoopRearmChangelevelTriggers", function()
|
||||
timer.Simple(0, ArmChangelevelTriggers)
|
||||
end)
|
||||
|
||||
@@ -8,39 +8,6 @@ DeriveGamemode("sandbox")
|
||||
include( "levels.lua" )
|
||||
include( "progression_shared.lua" )
|
||||
|
||||
if (SERVER) then
|
||||
function ChangeRandomLevel()
|
||||
local randomLevel = GetRandomLevel()
|
||||
if not randomLevel then
|
||||
ErrorNoHalt("[feedc0de_coop] No other installed coop map was found in levels.lua\n")
|
||||
return false
|
||||
end
|
||||
|
||||
print("[feedc0de_coop] Changing to random level " .. randomLevel)
|
||||
RunConsoleCommand("changelevel", randomLevel)
|
||||
return true
|
||||
end
|
||||
|
||||
function NextRandomLevel()
|
||||
local randomLevel = GetRandomLevel()
|
||||
if not randomLevel then
|
||||
ErrorNoHalt("[feedc0de_coop] Cannot set nextlevel: no other installed coop map was found\n")
|
||||
return nil
|
||||
end
|
||||
|
||||
print("[feedc0de_coop] Setting nextlevel to " .. randomLevel)
|
||||
RunConsoleCommand("nextlevel", randomLevel)
|
||||
return randomLevel
|
||||
end
|
||||
|
||||
concommand.Add("randomlevel", ChangeRandomLevel)
|
||||
concommand.Add("randomnextlevel", NextRandomLevel)
|
||||
|
||||
hook.Add("Initialize", "SetupRandomLevels", function()
|
||||
timer.Simple(0, NextRandomLevel)
|
||||
end)
|
||||
end
|
||||
|
||||
-- timer.Simple(
|
||||
-- 1,
|
||||
-- function()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Optional exceptions for unusual Workshop packages may be added here.
|
||||
-- Normal map previews are discovered automatically from the server's mounted
|
||||
-- Workshop GMA cache by the loading-screen service.
|
||||
COOP_WORKSHOP_IDS = COOP_WORKSHOP_IDS or {}
|
||||
@@ -13,6 +13,17 @@ with SRCDS on TCP port `8765`. The configured public URL is:
|
||||
`%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`.
|
||||
|
||||
The same service resolves map-vote images at
|
||||
`/assets/map-previews/<exact_map_name>.jpg`. A loose JPEG in the directory
|
||||
passed with `--map-previews` is the highest-priority override. Otherwise the
|
||||
background indexer scans the mounted `--workshop-cache` for GMAs containing the
|
||||
requested BSP. It serves `maps/thumb/<exact_map_name>.png` straight from that
|
||||
GMA when available, or proxies the owning Workshop item's Steam preview icon.
|
||||
The index refreshes every five minutes, so newly downloaded first-party and
|
||||
third-party map addons require no map-to-Workshop table or duplicate CephFS
|
||||
thumbnail copy. Clients fetch only the small selected images and do not
|
||||
subscribe to the candidate map addons.
|
||||
|
||||
For a browser preview with sample engine callbacks:
|
||||
|
||||
python3 server.py --bind 127.0.0.1 --port 8765
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"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.",
|
||||
"Type !skip if the current mission is stuck. The next map is chosen on-screen when the mission ends.",
|
||||
"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."
|
||||
];
|
||||
|
||||
@@ -5,20 +5,26 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DEFAULT_DATABASE = HERE.parents[2] / "sv.db"
|
||||
DEFAULT_SCREENSHOTS = HERE.parent / "backgrounds"
|
||||
DEFAULT_MAP_PREVIEWS = HERE / "map-previews"
|
||||
DEFAULT_WORKSHOP_CACHE = HERE.parents[5] / "steam_cache/content/4000"
|
||||
STEAM_ID = re.compile(r"^7656119\d{10}$")
|
||||
SCREENSHOT = re.compile(r"^[a-z0-9_-]+\.jpg$", re.IGNORECASE)
|
||||
MAP_PREVIEW = re.compile(r"^[a-z0-9_!+\[\]-]+\.jpg$", re.IGNORECASE)
|
||||
RANKS = (
|
||||
(0, "Newbie"),
|
||||
(10, "Survivor"),
|
||||
@@ -28,6 +34,191 @@ RANKS = (
|
||||
(1500, "Exterminator"),
|
||||
(5000, "Legend"),
|
||||
)
|
||||
MAX_GMA_STRING = 8 * 1024 * 1024
|
||||
MAX_PREVIEW_BYTES = 5 * 1024 * 1024
|
||||
WORKSHOP_DETAILS_URL = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/"
|
||||
"GetPublishedFileDetails/v1/"
|
||||
)
|
||||
|
||||
|
||||
class WorkshopPreviewIndex:
|
||||
"""Discover embedded thumbnails and Workshop IDs from cached GMAs."""
|
||||
|
||||
def __init__(self, root: Path, refresh_seconds: int = 300):
|
||||
self.root = root.resolve()
|
||||
self.refresh_seconds = refresh_seconds
|
||||
self.sources: dict[str, tuple[Path, int | None, int, str, str]] = {}
|
||||
self.preview_cache: dict[str, tuple[bytes, str]] = {}
|
||||
self.lock = threading.Lock()
|
||||
self.ready = threading.Event()
|
||||
self.stop_event = threading.Event()
|
||||
self.last_error = ""
|
||||
self.last_scan = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._scan_loop,
|
||||
name="workshop-preview-index",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
@staticmethod
|
||||
def _read_exact(source, size: int) -> bytes:
|
||||
content = source.read(size)
|
||||
if len(content) != size:
|
||||
raise ValueError("truncated GMA")
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _read_cstring(source) -> str:
|
||||
content = bytearray()
|
||||
while len(content) <= MAX_GMA_STRING:
|
||||
character = source.read(1)
|
||||
if not character:
|
||||
raise ValueError("unterminated GMA string")
|
||||
if character == b"\0":
|
||||
return content.decode("utf-8", errors="replace")
|
||||
content.extend(character)
|
||||
raise ValueError("oversized GMA string")
|
||||
|
||||
@classmethod
|
||||
def inspect_gma(cls, path: Path) -> dict[str, tuple[Path, int | None, int, str, str]]:
|
||||
with path.open("rb") as source:
|
||||
if cls._read_exact(source, 4) != b"GMAD":
|
||||
raise ValueError("invalid GMA magic")
|
||||
cls._read_exact(source, 1 + 8 + 8)
|
||||
for _ in range(4):
|
||||
cls._read_cstring(source)
|
||||
cls._read_exact(source, 4)
|
||||
|
||||
entries: list[tuple[str, int]] = []
|
||||
while True:
|
||||
number = struct.unpack("<I", cls._read_exact(source, 4))[0]
|
||||
if number == 0:
|
||||
break
|
||||
name = cls._read_cstring(source).replace("\\", "/").lower()
|
||||
size = struct.unpack("<Q", cls._read_exact(source, 8))[0]
|
||||
cls._read_exact(source, 4)
|
||||
entries.append((name, size))
|
||||
|
||||
data_offset = source.tell()
|
||||
|
||||
workshop_id = path.parent.name if path.parent.name.isdigit() else path.stem
|
||||
if not workshop_id.isdigit():
|
||||
workshop_id = ""
|
||||
offsets: dict[str, tuple[int, int]] = {}
|
||||
offset = data_offset
|
||||
maps = []
|
||||
for name, size in entries:
|
||||
offsets[name] = (offset, size)
|
||||
match = re.fullmatch(r"maps/([^/]+)\.bsp", name, re.IGNORECASE)
|
||||
if match:
|
||||
maps.append(match.group(1).lower())
|
||||
offset += size
|
||||
|
||||
result = {}
|
||||
for map_name in maps:
|
||||
preview = None
|
||||
content_type = ""
|
||||
for extension, candidate_type in (("png", "image/png"), ("jpg", "image/jpeg"), ("jpeg", "image/jpeg")):
|
||||
candidate = offsets.get(f"maps/thumb/{map_name}.{extension}")
|
||||
if candidate:
|
||||
preview = candidate
|
||||
content_type = candidate_type
|
||||
break
|
||||
if preview:
|
||||
result[map_name] = (path, preview[0], preview[1], content_type, workshop_id)
|
||||
elif workshop_id:
|
||||
result[map_name] = (path, None, 0, "", workshop_id)
|
||||
return result
|
||||
|
||||
def scan(self) -> None:
|
||||
discovered = {}
|
||||
errors = 0
|
||||
if self.root.is_dir():
|
||||
for path in sorted(self.root.glob("*/*.gma")):
|
||||
try:
|
||||
for map_name, source in self.inspect_gma(path).items():
|
||||
current = discovered.get(map_name)
|
||||
if current is None or (current[1] is None and source[1] is not None):
|
||||
discovered[map_name] = source
|
||||
except (OSError, ValueError, struct.error):
|
||||
errors += 1
|
||||
with self.lock:
|
||||
self.sources = discovered
|
||||
self.last_error = f"{errors} unreadable GMA(s)" if errors else ""
|
||||
self.last_scan = time.time()
|
||||
self.ready.set()
|
||||
|
||||
def _scan_loop(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
self.scan()
|
||||
self.stop_event.wait(self.refresh_seconds)
|
||||
|
||||
@staticmethod
|
||||
def _download_workshop_preview(workshop_id: str) -> tuple[bytes, str] | None:
|
||||
request = Request(
|
||||
WORKSHOP_DETAILS_URL,
|
||||
data=urlencode({"itemcount": "1", "publishedfileids[0]": workshop_id}).encode("ascii"),
|
||||
headers={"User-Agent": "feedc0de-coop-map-preview/1"},
|
||||
)
|
||||
with urlopen(request, timeout=10) as response:
|
||||
details = json.load(response)["response"]["publishedfiledetails"][0]
|
||||
preview_url = str(details.get("preview_url") or "")
|
||||
if details.get("result") != 1 or not preview_url.startswith(("https://", "http://")):
|
||||
return None
|
||||
with urlopen(Request(preview_url, headers={"User-Agent": "feedc0de-coop-map-preview/1"}), timeout=15) as response:
|
||||
content = response.read(MAX_PREVIEW_BYTES + 1)
|
||||
content_type = response.headers.get_content_type()
|
||||
if not content or len(content) > MAX_PREVIEW_BYTES or not content_type.startswith("image/"):
|
||||
return None
|
||||
return content, content_type
|
||||
|
||||
def get(self, map_name: str) -> tuple[bytes, str] | None:
|
||||
self.ready.wait(timeout=15)
|
||||
with self.lock:
|
||||
source = self.sources.get(map_name.lower())
|
||||
if source is None:
|
||||
return None
|
||||
path, offset, size, content_type, workshop_id = source
|
||||
if offset is not None:
|
||||
if size <= 0 or size > MAX_PREVIEW_BYTES:
|
||||
return None
|
||||
try:
|
||||
with path.open("rb") as gma:
|
||||
gma.seek(offset)
|
||||
content = gma.read(size)
|
||||
except OSError:
|
||||
return None
|
||||
return (content, content_type) if len(content) == size else None
|
||||
if not workshop_id:
|
||||
return None
|
||||
with self.lock:
|
||||
cached = self.preview_cache.get(workshop_id)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
downloaded = self._download_workshop_preview(workshop_id)
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
||||
return None
|
||||
if downloaded:
|
||||
with self.lock:
|
||||
self.preview_cache[workshop_id] = downloaded
|
||||
return downloaded
|
||||
|
||||
def health(self) -> dict[str, object]:
|
||||
with self.lock:
|
||||
return {
|
||||
"root": self.root.is_dir(),
|
||||
"maps": len(self.sources),
|
||||
"scanComplete": self.ready.is_set(),
|
||||
"lastError": self.last_error,
|
||||
}
|
||||
|
||||
|
||||
def rank_for(score: int) -> str:
|
||||
@@ -43,10 +234,24 @@ class LoadingScreenServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], database: Path, screenshots: Path):
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
database: Path,
|
||||
screenshots: Path,
|
||||
map_previews: Path,
|
||||
workshop_cache: Path,
|
||||
):
|
||||
self.database = database.resolve()
|
||||
self.screenshots = screenshots.resolve()
|
||||
self.map_previews = map_previews.resolve()
|
||||
self.workshop_previews = WorkshopPreviewIndex(workshop_cache)
|
||||
super().__init__(address, LoadingScreenHandler)
|
||||
self.workshop_previews.start()
|
||||
|
||||
def server_close(self) -> None:
|
||||
self.workshop_previews.stop()
|
||||
super().server_close()
|
||||
|
||||
|
||||
class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
@@ -73,6 +278,9 @@ class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
if path.startswith("/assets/screenshots/"):
|
||||
self.serve_screenshot(path.rsplit("/", 1)[-1])
|
||||
return
|
||||
if path.startswith("/assets/map-previews/"):
|
||||
self.serve_map_preview(path.rsplit("/", 1)[-1])
|
||||
return
|
||||
if path == "/api/player":
|
||||
steam_id = parse_qs(request.query).get("steamid", [""])[0]
|
||||
self.serve_player(steam_id)
|
||||
@@ -81,7 +289,14 @@ class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
self.serve_leaderboard()
|
||||
return
|
||||
if path == "/api/health":
|
||||
self.send_json({"ok": True, "database": self.server.database.is_file()})
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"database": self.server.database.is_file(),
|
||||
"mapPreviews": self.server.map_previews.is_dir(),
|
||||
"workshopPreviews": self.server.workshop_previews.health(),
|
||||
}
|
||||
)
|
||||
return
|
||||
self.send_error_json(404, "Not found")
|
||||
|
||||
@@ -98,6 +313,20 @@ class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self.send_file(path, "image/jpeg")
|
||||
|
||||
def serve_map_preview(self, name: str) -> None:
|
||||
if not MAP_PREVIEW.fullmatch(name):
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
path = self.server.map_previews / name
|
||||
if path.parent == self.server.map_previews and path.is_file():
|
||||
self.send_file(path, "image/jpeg")
|
||||
return
|
||||
preview = self.server.workshop_previews.get(Path(name).stem)
|
||||
if not preview:
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
self.send_bytes(*preview)
|
||||
|
||||
def serve_player(self, steam_id: str) -> None:
|
||||
if not STEAM_ID.fullmatch(steam_id):
|
||||
self.send_error_json(400, "Invalid SteamID64")
|
||||
@@ -181,6 +410,9 @@ class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
except OSError:
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
self.send_bytes(content, content_type, no_cache=no_cache)
|
||||
|
||||
def send_bytes(self, content: bytes, content_type: str, no_cache: bool = False) -> None:
|
||||
self.send_response(200)
|
||||
self.send_common_headers(no_cache=no_cache)
|
||||
self.send_header("Content-Type", content_type)
|
||||
@@ -215,6 +447,13 @@ def parse_args() -> argparse.Namespace:
|
||||
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")
|
||||
parser.add_argument("--map-previews", type=Path, default=DEFAULT_MAP_PREVIEWS, help="directory of map-vote JPEGs")
|
||||
parser.add_argument(
|
||||
"--workshop-cache",
|
||||
type=Path,
|
||||
default=DEFAULT_WORKSHOP_CACHE,
|
||||
help="Workshop content/4000 directory scanned for map previews",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -223,7 +462,13 @@ def main() -> int:
|
||||
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)
|
||||
server = LoadingScreenServer(
|
||||
(args.bind, args.port),
|
||||
args.database,
|
||||
args.screenshots,
|
||||
args.map_previews,
|
||||
args.workshop_cache,
|
||||
)
|
||||
print(f"loading screen: serving http://{args.bind}:{args.port}", flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
|
||||
Reference in New Issue
Block a user