From c6d490dd567f6410b0b17f815a075f8b67d8f5b6 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Mon, 21 Sep 2026 09:19:23 +0200 Subject: [PATCH] Implement map selection with map thumbnail preview --- README.md | 52 +- gamemodes/feedc0de_coop/gamemode/cl_init.lua | 1 + .../feedc0de_coop/gamemode/cl_mapvote.lua | 370 ++++++++++++++ .../feedc0de_coop/gamemode/coop_bots.lua | 31 +- gamemodes/feedc0de_coop/gamemode/init.lua | 2 + gamemodes/feedc0de_coop/gamemode/levels.lua | 148 +++++- .../feedc0de_coop/gamemode/mapperstuff.lua | 19 +- .../feedc0de_coop/gamemode/maprotation.lua | 467 ++++++++++-------- gamemodes/feedc0de_coop/gamemode/shared.lua | 33 -- .../feedc0de_coop/gamemode/workshop_maps.lua | 4 + .../feedc0de_coop/loading-screen/README.md | 11 + gamemodes/feedc0de_coop/loading-screen/app.js | 2 +- .../feedc0de_coop/loading-screen/server.py | 255 +++++++++- 13 files changed, 1140 insertions(+), 255 deletions(-) create mode 100644 gamemodes/feedc0de_coop/gamemode/cl_mapvote.lua create mode 100644 gamemodes/feedc0de_coop/gamemode/workshop_maps.lua diff --git a/README.md b/README.md index 30d3da8..213c118 100644 --- a/README.md +++ b/README.md @@ -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 `.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/.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/.png` inside each GMA so the +standard single-player map browser also has a preview, and put +`maps/graphs/.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. diff --git a/gamemodes/feedc0de_coop/gamemode/cl_init.lua b/gamemodes/feedc0de_coop/gamemode/cl_init.lua index 97291e0..bfdef97 100644 --- a/gamemodes/feedc0de_coop/gamemode/cl_init.lua +++ b/gamemodes/feedc0de_coop/gamemode/cl_init.lua @@ -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") diff --git a/gamemodes/feedc0de_coop/gamemode/cl_mapvote.lua b/gamemodes/feedc0de_coop/gamemode/cl_mapvote.lua new file mode 100644 index 0000000..1513804 --- /dev/null +++ b/gamemodes/feedc0de_coop/gamemode/cl_mapvote.lua @@ -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) diff --git a/gamemodes/feedc0de_coop/gamemode/coop_bots.lua b/gamemodes/feedc0de_coop/gamemode/coop_bots.lua index 1e9146c..b442e07 100644 --- a/gamemodes/feedc0de_coop/gamemode/coop_bots.lua +++ b/gamemodes/feedc0de_coop/gamemode/coop_bots.lua @@ -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 diff --git a/gamemodes/feedc0de_coop/gamemode/init.lua b/gamemodes/feedc0de_coop/gamemode/init.lua index 21bef19..8d3a1b6 100644 --- a/gamemodes/feedc0de_coop/gamemode/init.lua +++ b/gamemodes/feedc0de_coop/gamemode/init.lua @@ -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" ) diff --git a/gamemodes/feedc0de_coop/gamemode/levels.lua b/gamemodes/feedc0de_coop/gamemode/levels.lua index 008da80..eb2e0ab 100644 --- a/gamemodes/feedc0de_coop/gamemode/levels.lua +++ b/gamemodes/feedc0de_coop/gamemode/levels.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() diff --git a/gamemodes/feedc0de_coop/gamemode/mapperstuff.lua b/gamemodes/feedc0de_coop/gamemode/mapperstuff.lua index 9bf1a6c..a581d22 100644 --- a/gamemodes/feedc0de_coop/gamemode/mapperstuff.lua +++ b/gamemodes/feedc0de_coop/gamemode/mapperstuff.lua @@ -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 diff --git a/gamemodes/feedc0de_coop/gamemode/maprotation.lua b/gamemodes/feedc0de_coop/gamemode/maprotation.lua index f85de11..5e2d864 100644 --- a/gamemodes/feedc0de_coop/gamemode/maprotation.lua +++ b/gamemodes/feedc0de_coop/gamemode/maprotation.lua @@ -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 .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 . 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) diff --git a/gamemodes/feedc0de_coop/gamemode/shared.lua b/gamemodes/feedc0de_coop/gamemode/shared.lua index d9749ac..8784ef9 100644 --- a/gamemodes/feedc0de_coop/gamemode/shared.lua +++ b/gamemodes/feedc0de_coop/gamemode/shared.lua @@ -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() diff --git a/gamemodes/feedc0de_coop/gamemode/workshop_maps.lua b/gamemodes/feedc0de_coop/gamemode/workshop_maps.lua new file mode 100644 index 0000000..876aed4 --- /dev/null +++ b/gamemodes/feedc0de_coop/gamemode/workshop_maps.lua @@ -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 {} diff --git a/gamemodes/feedc0de_coop/loading-screen/README.md b/gamemodes/feedc0de_coop/loading-screen/README.md index d4a8fb2..fcb1398 100644 --- a/gamemodes/feedc0de_coop/loading-screen/README.md +++ b/gamemodes/feedc0de_coop/loading-screen/README.md @@ -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/.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/.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 diff --git a/gamemodes/feedc0de_coop/loading-screen/app.js b/gamemodes/feedc0de_coop/loading-screen/app.js index 106f054..1031c38 100644 --- a/gamemodes/feedc0de_coop/loading-screen/app.js +++ b/gamemodes/feedc0de_coop/loading-screen/app.js @@ -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." ]; diff --git a/gamemodes/feedc0de_coop/loading-screen/server.py b/gamemodes/feedc0de_coop/loading-screen/server.py index bbe196b..4d90e87 100644 --- a/gamemodes/feedc0de_coop/loading-screen/server.py +++ b/gamemodes/feedc0de_coop/loading-screen/server.py @@ -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(" 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)