local changeDelay = CreateConVar( "gc_mapchangedelay", "8", FCVAR_ARCHIVE, "Seconds between map completion and loading the next coop map" ) local maxMapTime = CreateConVar( "gc_mapmaxtime", "3600", FCVAR_ARCHIVE, "Maximum map time in seconds before rotating; 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" ) local nextMapVoteRatio = CreateConVar( "gc_nextmapvoteratio", "0.6", FCVAR_ARCHIVE, "Fraction of connected human players required to select the next map" ) local skipChangeDelay = CreateConVar( "gc_mapskipchangedelay", "3", FCVAR_ARCHIVE, "Seconds between a successful player skip vote and changing map" ) local changeQueued = false local skipVotes = {} local nextMapVotes = {} local nextMapChoices = {} local nextMapVoteFinished = false local installedMaps local function RefreshInstalledMaps() installedMaps = {} for _, fileName in ipairs(file.Find("maps/*.bsp", "GAME")) do installedMaps[string.lower(string.StripExtension(fileName))] = true end end local function MapExists(mapName) if not mapName or mapName == "" then return false end if not installedMaps then RefreshInstalledMaps() end return installedMaps[string.lower(string.StripExtension(mapName))] == true end local function SetFreshNextLevel() local nextLevel = GetConVar("nextlevel"):GetString() if nextLevel == "" or not MapExists(nextLevel) or string.lower(nextLevel) == string.lower(game.GetMap()) then nextLevel = NextRandomLevel() 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 humanCount = #player.GetHumans() local ratio = math.Clamp(ratioConVar:GetFloat(), 0.1, 1) return math.max(math.ceil(humanCount * ratio), 1) end local function BuildNextMapChoices() local candidates = {} local currentMap = string.lower(game.GetMap()) for _, mapName in ipairs(GetInstalledCoopLevels()) do if mapName ~= currentMap then candidates[#candidates + 1] = mapName end end for index = #candidates, 2, -1 do local other = math.random(index) candidates[index], candidates[other] = candidates[other], candidates[index] end nextMapChoices = {} local plannedMap = SetFreshNextLevel() if plannedMap then nextMapChoices[1] = string.lower(plannedMap) end for _, mapName in ipairs(candidates) do if #nextMapChoices >= 5 then break end if not table.HasValue(nextMapChoices, mapName) then nextMapChoices[#nextMapChoices + 1] = mapName end end end local function ShowNextMapChoices(ply) if #nextMapChoices == 0 then BuildNextMapChoices() end if #nextMapChoices == 0 then ply:ChatPrint("[CO-OP] No alternative installed co-op map is available.") return 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.") end local function ResolveNextMapChoice(requestedChoice) local choiceNumber = tonumber(requestedChoice) if choiceNumber then return nextMapChoices[math.floor(choiceNumber)] end local normalizedChoice = string.lower(string.Trim(requestedChoice or "")) for _, mapName in ipairs(nextMapChoices) do if normalizedChoice == mapName then return mapName end end end local function CastNextMapVote(ply, requestedChoice) if nextMapVoteFinished then ply:ChatPrint("[CO-OP] The next map has already been selected: " .. GetConVar("nextlevel"):GetString()) return 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 nextMapVotes[PlayerVoteID(ply)] = mapName 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 )) if votesForMap < required then return end nextMapVoteFinished = true RunConsoleCommand("nextlevel", mapName) PrintMessage(HUD_PRINTTALK, "Next map selected: " .. mapName) end local function CountCurrentSkipVotes() local connectedPlayers = {} for _, ply in ipairs(player.GetHumans()) do connectedPlayers[PlayerVoteID(ply)] = true end local count = 0 for voterID in pairs(skipVotes) do if connectedPlayers[voterID] then count = count + 1 else skipVotes[voterID] = nil end end return count end local function CheckSkipVote() if changeQueued then return end local voteCount = CountCurrentSkipVotes() if voteCount >= RequiredVotes(skipVoteRatio) then QueueCoopMapChange("player skip vote") end end local function CastSkipVote(ply) if changeQueued then ply:ChatPrint("[CO-OP] A map change is already on its way.") return end local voterID = PlayerVoteID(ply) if skipVotes[voterID] then ply:ChatPrint("[CO-OP] You have already voted to skip this map.") return end skipVotes[voterID] = true local voteCount = CountCurrentSkipVotes() local required = RequiredVotes(skipVoteRatio) PrintMessage(HUD_PRINTTALK, string.format( "%s voted to skip this map (%d/%d). Type !skip to vote.", ply:Nick(), voteCount, required )) CheckSkipVote() end function QueueCoopMapChange(reason, requestedMap) if changeQueued then return end local destination = requestedMap and string.lower(requestedMap) or nil if not MapExists(destination) or destination == string.lower(game.GetMap()) then destination = SetFreshNextLevel() end if not destination then ErrorNoHalt("[custom_coop] Map completed, but no installed destination map is available\n") return end 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() RunConsoleCommand("changelevel", destination) 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(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.") end) end) hook.Add("PlayerDisconnected", "CustomCoopRecountMapSkipVote", function() timer.Simple(0, CheckSkipVote) end) hook.Add("AcceptInput", "CustomCoopDetectMapCompletion", function(ent, inputName, _, _, value) if not IsValid(ent) then return end local className = string.lower(ent:GetClass()) local input = string.lower(inputName or "") if className == "game_end" and input == "endgame" then QueueCoopMapChange("game_end") 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) 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 if string.lower(key or "") ~= "map" then return end if MapExists(value) and string.lower(value) ~= string.lower(game.GetMap()) then return end local replacement = GetRandomLevel() if replacement then print("[custom_coop] Replacing unavailable changelevel target '" .. tostring(value) .. "' with " .. replacement) return replacement end end) hook.Add("InitPostEntity", "CustomCoopStartMapTimeLimit", function() SetFreshNextLevel() BuildNextMapChoices() local limit = maxMapTime:GetFloat() if limit <= 0 then return end timer.Create("CustomCoopMapTimeLimit", limit, 1, function() QueueCoopMapChange("time limit") end) end)