Implement map selection with map thumbnail preview

This commit is contained in:
2026-09-21 09:19:23 +02:00
parent 7d2035e4c3
commit c6d490dd56
13 changed files with 1140 additions and 255 deletions
+49 -3
View File
@@ -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)
+24 -7
View File
@@ -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" )
+147 -1
View File
@@ -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
+271 -196
View File
@@ -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)