diff --git a/ConfigConvars.cs b/ConfigConvars.cs index 1398811..69f3f2c 100644 --- a/ConfigConvars.cs +++ b/ConfigConvars.cs @@ -90,6 +90,7 @@ public void MatchZyDemoPath(CCSPlayerController? player, CommandInfo command) } } + [ConsoleCommand("get5_demo_upload_url", "If defined, recorded demos will be uploaded to this URL once the map ends.")] [ConsoleCommand("matchzy_demo_upload_url", "If defined, recorded demos will be uploaded to this URL once the map ends.")] public void MatchZyDemoUploadURL(CCSPlayerController? player, CommandInfo command) { diff --git a/ConsoleCommands.cs b/ConsoleCommands.cs index c2bd5e4..699621e 100644 --- a/ConsoleCommands.cs +++ b/ConsoleCommands.cs @@ -11,6 +11,7 @@ using CounterStrikeSharp.API.Modules.Memory; using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.Modules.Timers; +using System.Text.RegularExpressions; namespace MatchZy { @@ -49,7 +50,7 @@ public void OnSaveNadesAsGlobalCommand(CCSPlayerController? player, CommandInfo? [ConsoleCommand("css_ready", "Marks the player ready")] public void OnPlayerReady(CCSPlayerController? player, CommandInfo? command) { if (player == null) return; - Log($"[!ready command] Sent by: {player.UserId}, connectedPlayers: {connectedPlayers}"); + Log($"[!ready command] Sent by: {player.UserId} readyAvailable: {readyAvailable} matchStarted: {matchStarted}"); if (readyAvailable && !matchStarted) { if (player.UserId.HasValue) { if (!playerReadyStatus.ContainsKey(player.UserId.Value)) { @@ -101,6 +102,7 @@ public void OnTeamStay(CCSPlayerController? player, CommandInfo? command) { } [ConsoleCommand("css_switch", "Switch after knife round")] + [ConsoleCommand("css_swap", "Switch after knife round")] public void OnTeamSwitch(CCSPlayerController? player, CommandInfo? command) { if (player == null) return; @@ -132,14 +134,16 @@ public void OnPauseCommand(CCSPlayerController? player, CommandInfo? command) { } } - [ConsoleCommand("css_fp", "Pause the matchas an admin")] + [ConsoleCommand("css_fp", "Pause the match an admin")] [ConsoleCommand("css_forcepause", "Pause the match as an admin")] + [ConsoleCommand("sm_pause", "Pause the match as an admin")] public void OnForcePauseCommand(CCSPlayerController? player, CommandInfo? command) { ForcePauseMatch(player, command); } - [ConsoleCommand("css_fup", "Pause the matchas an admin")] - [ConsoleCommand("css_forceunpause", "Pause the match as an admin")] + [ConsoleCommand("css_fup", "Unpause the match an admin")] + [ConsoleCommand("css_forceunpause", "Unpause the match as an admin")] + [ConsoleCommand("sm_unpause", "Unpause the match as an admin")] public void OnForceUnpauseCommand(CCSPlayerController? player, CommandInfo? command) { ForceUnpauseMatch(player, command); } @@ -253,13 +257,36 @@ public void OnMatchSettingsCommand(CCSPlayerController? player, CommandInfo? com string playoutStatus = isPlayOutEnabled ? "Enabled" : "Disabled"; player.PrintToChat($"{chatPrefix} Current Settings:"); player.PrintToChat($"{chatPrefix} Knife: {ChatColors.Green}{knifeStatus}{ChatColors.Default}"); - player.PrintToChat($"{chatPrefix} Minimum Ready Required: {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}"); + if (isMatchSetup) + { + player.PrintToChat($"{chatPrefix} Minimum Ready Players Required (Per Team): {ChatColors.Green}{matchConfig.MinPlayersToReady}{ChatColors.Default}"); + player.PrintToChat($"{chatPrefix} Minimum Ready Spectators Required: {ChatColors.Green}{matchConfig.MinSpectatorsToReady}{ChatColors.Default}"); + } + else + { + player.PrintToChat($"{chatPrefix} Minimum Ready Required: {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}"); + } player.PrintToChat($"{chatPrefix} Playout: {ChatColors.Green}{playoutStatus}{ChatColors.Default}"); } else { SendPlayerNotAdminMessage(player); } } + [ConsoleCommand("css_endmatch", "Ends and resets the current match")] + [ConsoleCommand("get5_endmatch", "Ends and resets the current match")] + public void OnEndMatchCommand(CCSPlayerController? player, CommandInfo? command) { + if (IsPlayerAdmin(player, "css_endmatch", "@css/config")) { + if (!isPractice) { + Server.PrintToChatAll($"{chatPrefix} An admin force-ended the match."); + ResetMatch(); + } else { + ReplyToUserCommand(player, "Practice mode is active, cannot end the match."); + } + } else { + SendPlayerNotAdminMessage(player); + } + } + [ConsoleCommand("css_restart", "Restarts the match")] public void OnRestartMatchCommand(CCSPlayerController? player, CommandInfo? command) { if (IsPlayerAdmin(player, "css_restart", "@css/config")) { @@ -415,5 +442,26 @@ public void OnPlayoutCommand(CCSPlayerController? player, CommandInfo? command) SendPlayerNotAdminMessage(player); } } + + [ConsoleCommand("version", "Returns server version")] + public void OnVersionCommand(CCSPlayerController? player, CommandInfo? command) { + if (command == null) return; + string steamInfFilePath = Path.Combine(Server.GameDirectory, "csgo", "steam.inf"); + + if (!File.Exists(steamInfFilePath)) + { + command.ReplyToCommand("Unable to locate steam.inf file!"); + } + var steamInfContent = File.ReadAllText(steamInfFilePath); + + Regex regex = new(@"ServerVersion=(\d+)"); + Match match = regex.Match(steamInfContent); + + // Extract the version number + string? serverVersion = match.Success ? match.Groups[1].Value : null; + + // Currently returning only server version to show server status as available on Get5 + command.ReplyToCommand((serverVersion != null) ? $"Protocol version {serverVersion} [{serverVersion}/{serverVersion}]" : "Unable to get server version"); + } } } diff --git a/DemoManagement.cs b/DemoManagement.cs index 03e7c9a..61a3b1c 100644 --- a/DemoManagement.cs +++ b/DemoManagement.cs @@ -1,6 +1,11 @@ using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Cvars; using System.IO.Compression; +using System.Net.Http.Json; +using System.Text; namespace MatchZy @@ -9,6 +14,8 @@ public partial class MatchZy { public string demoPath = "MatchZy/"; public string demoUploadURL = ""; + public string demoUploadHeaderKey = ""; + public string demoUploadHeaderValue = ""; public string activeDemoFile = ""; @@ -82,53 +89,46 @@ public async Task UploadDemoAsync(string? demoPath, long matchId, int mapNumber) try { - using (var httpClient = new HttpClient()) - using (var formData = new MultipartFormDataContent()) - { - Log($"[UploadDemoAsync] Going to upload demo on {demoUploadURL}. Complete path: {demoPath}"); + using var httpClient = new HttpClient(); + Log($"[UploadDemoAsync] Going to upload demo on {demoUploadURL}. Complete path: {demoPath}"); - if (!File.Exists(demoPath)) - { - Log($"[UploadDemoAsync ERROR] File not found: {demoPath}"); - return; - } + if (!File.Exists(demoPath)) + { + Log($"[UploadDemoAsync ERROR] File not found: {demoPath}"); + return; + } - var compressedFilePath = Path.ChangeExtension(demoPath, "zip"); // Change to ".gz" for GZip compression + using FileStream fileStream = File.OpenRead(demoPath); - using (var compressedFileStream = new FileStream(compressedFilePath, FileMode.Create)) - using (var zipArchive = new ZipArchive(compressedFileStream, ZipArchiveMode.Create)) - { - // Add the .dem file to the zip archive - var zipEntry = zipArchive.CreateEntry(Path.GetFileName(demoPath)); - using (var entryStream = zipEntry.Open()) - using (var demoFileStream = new FileStream(demoPath, FileMode.Open, FileAccess.Read)) - { - await demoFileStream.CopyToAsync(entryStream); - } - } + byte[] fileContent = new byte[fileStream.Length]; + await fileStream.ReadAsync(fileContent, 0, (int)fileStream.Length); - var compressedFileStreamContent = new StreamContent(new FileStream(compressedFilePath, FileMode.Open, FileAccess.Read)); - compressedFileStreamContent.Headers.Add("Content-Type", "application/zip"); + using ByteArrayContent content = new ByteArrayContent(fileContent); + content.Headers.Add("Content-Type", "application/octet-stream"); - formData.Add(compressedFileStreamContent, "file", Path.GetFileName(compressedFilePath)); + content.Headers.Add("MatchZy-FileName", Path.GetFileName(demoPath)); + content.Headers.Add("MatchZy-MatchId", matchId.ToString()); + content.Headers.Add("MatchZy-MapNumber", mapNumber.ToString()); - formData.Headers.Add("MatchZy-FileName", Path.GetFileName(compressedFilePath)); - formData.Headers.Add("MatchZy-MatchId", matchId.ToString()); - formData.Headers.Add("MatchZy-MapNumber", mapNumber.ToString()); + // For Get5 Panel + content.Headers.Add("Get5-FileName", Path.GetFileName(demoPath)); + content.Headers.Add("Get5-MatchId", matchId.ToString()); + content.Headers.Add("Get5-MapNumber", mapNumber.ToString()); - var response = await httpClient.PostAsync(demoUploadURL, formData).ConfigureAwait(false); + if (!string.IsNullOrEmpty(demoUploadHeaderKey)) + { + httpClient.DefaultRequestHeaders.Add(demoUploadHeaderKey, demoUploadHeaderValue); + } - if (response.IsSuccessStatusCode) - { - Log($"[UploadDemoAsync] File upload successful for matchId: {matchId} mapNumber: {mapNumber} fileName: {Path.GetFileName(compressedFilePath)}."); - } - else - { - Log($"[UploadDemoAsync ERROR] Failed to upload file. Status code: {response.StatusCode}"); - } + HttpResponseMessage response = await httpClient.PostAsync(demoUploadURL, content); - // Clean up: Delete the temporary compressed file - File.Delete(compressedFilePath); + if (response.IsSuccessStatusCode) + { + Log($"[UploadDemoAsync] File upload successful for matchId: {matchId} mapNumber: {mapNumber} fileName: {Path.GetFileName(demoPath)}."); + } + else + { + Log($"[UploadDemoAsync ERROR] Failed to upload file. Status code: {response.StatusCode} Response: {await response.Content.ReadAsStringAsync()}"); } } catch (Exception e) @@ -136,5 +136,25 @@ public async Task UploadDemoAsync(string? demoPath, long matchId, int mapNumber) Log($"[UploadDemoAsync FATAL] An error occurred: {e.Message}"); } } + + [ConsoleCommand("get5_demo_upload_header_key", "If defined, a custom HTTP header with this name is added to the HTTP requests for demos")] + [ConsoleCommand("matchzy_demo_upload_header_key", "If defined, a custom HTTP header with this name is added to the HTTP requests for demos")] + public void DemoUploadHeaderKeyCommand(CCSPlayerController? player, CommandInfo command) + { + if (player != null) return; + string header = command.ArgByIndex(1).Trim(); + + if (header != "") demoUploadHeaderKey = header; + } + + [ConsoleCommand("get5_demo_upload_header_value", "If defined, the value of the custom header added to the demos sent over HTTP")] + [ConsoleCommand("matchzy_demo_upload_header_value", "If defined, the value of the custom header added to the demos sent over HTTP")] + public void DemoUploadHeaderValueCommand(CCSPlayerController? player, CommandInfo command) + { + if (player != null) return; + string headerValue = command.ArgByIndex(1).Trim(); + + if (headerValue != "") demoUploadHeaderValue = headerValue; + } } } diff --git a/Events.cs b/Events.cs new file mode 100644 index 0000000..2a480d7 --- /dev/null +++ b/Events.cs @@ -0,0 +1,206 @@ +using System.Text.Json.Serialization; + +namespace MatchZy; +public class MatchZyEvent +{ + public MatchZyEvent(string eventName) + { + EventName = eventName; + } + + [JsonPropertyName("event")] + public string EventName { get; } +} + +public class MatchZyMatchEvent : MatchZyEvent +{ + [JsonPropertyName("matchid")] + public required string MatchId { get; init; } + + protected MatchZyMatchEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyMatchTeamEvent : MatchZyMatchEvent +{ + [JsonPropertyName("team")] + public required string Team { get; init; } + + protected MatchZyMatchTeamEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyMapEvent : MatchZyMatchEvent +{ + [JsonPropertyName("map_number")] + public required int MapNumber { get; init; } + + protected MatchZyMapEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyMapTeamEvent : MatchZyMapEvent +{ + [JsonPropertyName("team_int")] + public required int TeamNumber { get; init; } + + protected MatchZyMapTeamEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyRoundEvent : MatchZyMapEvent +{ + [JsonPropertyName("round_number")] + public required int RoundNumber { get; init; } + + protected MatchZyRoundEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyTimedRoundEvent : MatchZyRoundEvent +{ + [JsonPropertyName("round_time")] + public required int RoundTime { get; init; } + + protected MatchZyTimedRoundEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyPlayerRoundEvent : MatchZyRoundEvent +{ + + [JsonPropertyName("player")] + public required int Player { get; init; } + + protected MatchZyPlayerRoundEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyPlayerTimedRoundEvent : MatchZyTimedRoundEvent +{ + [JsonPropertyName("player")] + public required int Player { get; init; } + + protected MatchZyPlayerTimedRoundEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyPlayerDisconnectedEvent : MatchZyMatchEvent +{ + [JsonPropertyName("player")] + public required int Player { get; init; } + + public MatchZyPlayerDisconnectedEvent() : base("player_disconnect") + { + } +} + +public class MatchZySeriesResultEvent : MatchZyMatchEvent +{ + [JsonPropertyName("time_until_restore")] + public required int TimeUntilRestore { get; init; } + + [JsonPropertyName("winner")] + public required Winner Winner { get; init; } + + [JsonPropertyName("team1_series_score")] + public required int Team1SeriesScore { get; init; } + + [JsonPropertyName("team2_series_score")] + public required int Team2SeriesScore { get; init; } + + public MatchZySeriesResultEvent() : base("series_end") + { + } +} + +public class GoingLiveEvent : MatchZyMapEvent +{ + public GoingLiveEvent() : base("going_live") + { + } +} + +public class MatchZyRoundEndedEvent : MatchZyTimedRoundEvent +{ + + [JsonPropertyName("reason")] + public required int Reason { get; init; } + + [JsonPropertyName("winner")] + public required Winner Winner { get; init; } + + [JsonPropertyName("team1")] + public required MatchZyStatsTeam StatsTeam1 { get; init; } + + [JsonPropertyName("team2")] + public required MatchZyStatsTeam StatsTeam2 { get; init; } + + public MatchZyRoundEndedEvent() : base("round_end") + { + } +} + +public class MapResultEvent : MatchZyMapEvent +{ + [JsonPropertyName("winner")] + public required Winner Winner { get; init; } + + [JsonPropertyName("team1")] + public required MatchZyStatsTeam StatsTeam1 { get; init; } + + [JsonPropertyName("team2")] + public required MatchZyStatsTeam StatsTeam2 { get; init; } + + public MapResultEvent() : base("map_result") + { + } +} + +public class MatchZyMapSelectionEvent : MatchZyMatchTeamEvent +{ + [JsonPropertyName("map_name")] + public required string MapName { get; init; } + + protected MatchZyMapSelectionEvent(string eventName) : base(eventName) + { + } +} + +public class MatchZyMapPickedEvent : MatchZyMapSelectionEvent +{ + [JsonPropertyName("map_number")] + public required int MapNumber { get; init; } + + public MatchZyMapPickedEvent() : base("map_picked") + { + } +} + +public class MatchZyMapVetoedEvent : MatchZyMapSelectionEvent +{ + public MatchZyMapVetoedEvent() : base("map_vetoed") + { + } +} + +public class MatchZySidePickedEvent : MatchZyMapSelectionEvent +{ + [JsonPropertyName("map_number")] + public required int MapNumber { get; init; } + + [JsonPropertyName("side")] + public required string Side { get; init; } + + public MatchZySidePickedEvent() : base("side_picked") + { + } +} diff --git a/G5API.cs b/G5API.cs new file mode 100644 index 0000000..6e8cb9a --- /dev/null +++ b/G5API.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using System.Text.Json.Serialization; + +namespace MatchZy +{ + public class Get5Status + { + [JsonPropertyName("plugin_version")] + public required string PluginVersion { get; set; } + + [JsonPropertyName("gamestate")] + public int GameState { get; set; } + } + + public class G5WebAvailable + { + [JsonPropertyName("gamestate")] + public int GameState { get; init; } + + [JsonPropertyName("available")] + public int Available { get; } = 1; + + [JsonPropertyName("plugin_version")] + public string PluginVersion { get; } = "0.15.0"; + } + public partial class MatchZy + { + [ConsoleCommand("get5_status", "Returns get5 status")] + public void Get5StatusCommand(CCSPlayerController? player, CommandInfo command) + { + command.ReplyToCommand(JsonSerializer.Serialize(new Get5Status { PluginVersion = "0.15.0", GameState = 0 })); + } + + [ConsoleCommand("get5_web_available", "Returns get5 web available")] + public void Get5WebAvailable(CCSPlayerController? player, CommandInfo command) + { + command.ReplyToCommand(JsonSerializer.Serialize(new G5WebAvailable())); + } + } +} diff --git a/MapVeto.cs b/MapVeto.cs new file mode 100644 index 0000000..01b620d --- /dev/null +++ b/MapVeto.cs @@ -0,0 +1,642 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Modules.Timers; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using System; + +namespace MatchZy +{ + public partial class MatchZy + { + public bool isPreVeto = false; + public bool isVeto = false; + public int warningsPrinted = 0; + public int vetoCountdownTime = 5; // In Seconds + + public bool mapChangePending = false; + public CounterStrikeSharp.API.Modules.Timers.Timer? vetoStateTimer = null; + public Dictionary vetoCaptains = new(){ + {"team1", -1}, + {"team2", -1} + }; + + public CsTeam lastVetoTeam = CsTeam.None; + + public void CreateVeto() + { + vetoCaptains["team1"] = GetTeamCaptain("team1"); + vetoCaptains["team2"] = GetTeamCaptain("team2"); + // Todo: Implement pauseOnVeto CVAR + // if (pauseOnVeto) { + // Server.ExecuteCommand("mp_pause_match"); + // isPaused = true; + // unpauseData["pauseTeam"] = "Admin"; + // } + Server.ExecuteCommand("mp_warmup_end"); + Server.ExecuteCommand("mp_pause_match"); + isPaused = true; + unpauseData["pauseTeam"] = "Admin"; + vetoStateTimer = AddTimer(1, VetoCountdown, TimerFlags.REPEAT); + isVeto = true; + readyAvailable = false; + isWarmup = false; + KillPhaseTimers(); + } + + public void VetoCountdown() + { + if (!isVeto) + { + warningsPrinted = 0; + vetoStateTimer?.Kill(); + vetoStateTimer = null; + return; + } + if (warningsPrinted >= vetoCountdownTime) + { + int team1Captain = vetoCaptains["team1"]; + int team2Captain = vetoCaptains["team2"]; + warningsPrinted = 0; + if (!playerData.ContainsKey(team1Captain) || !playerData.ContainsKey(team2Captain) || !playerData[team1Captain].IsValid || !playerData[team2Captain].IsValid) + { + AbortVeto(); + vetoStateTimer?.Kill(); + vetoStateTimer = null; + return; + } + Server.PrintToChatAll($"{chatPrefix} Captain for {ChatColors.Green}{matchzyTeam1.teamName}{ChatColors.Default}: {ChatColors.Green}{playerData[team1Captain].PlayerName}{ChatColors.Default}"); + Server.PrintToChatAll($"{chatPrefix} Captain for {ChatColors.Green}{matchzyTeam2.teamName}{ChatColors.Default}: {ChatColors.Green}{playerData[team2Captain].PlayerName}{ChatColors.Default}"); + + HandleVetoStep(); + vetoStateTimer?.Kill(); + vetoStateTimer = null; + return; + } + warningsPrinted++; + int secondsRemaining = vetoCountdownTime - warningsPrinted + 1; + Server.PrintToChatAll($"{chatPrefix} Map selection commencing in {secondsRemaining}"); + } + + public void HandleVetoStep() + { + // As long as sides are not set for a map, either give side pick or auto-decide sides and recursively call this. + if (matchConfig.MapSides.Count < matchConfig.Maplist.Count) + { + if (matchConfig.MatchSideType == "standard") + { + CsTeam otherMatchTeam = lastVetoTeam; + if (lastVetoTeam == CsTeam.Terrorist) otherMatchTeam = CsTeam.CounterTerrorist; + else if (lastVetoTeam == CsTeam.CounterTerrorist) otherMatchTeam = CsTeam.Terrorist; + PromptForSideSelectionInChat(otherMatchTeam); + } + else + { + HandleAutomaticSideSelection(); + HandleVetoStep(); + } + } + else if (matchConfig.NumMaps > matchConfig.Maplist.Count) + { + if (matchConfig.MapsLeftInVetoPool.Count == 1) + { + // Only 1 map left in the pool, add it by deduction and determine knife logic. + string mapName = matchConfig.MapsLeftInVetoPool[0]; + PickMap(mapName, 0); + HandleAutomaticSideSelection(); + FinishVeto(); + } + else + { + // More than 1 map in the pool and not all maps are picked; present choices as determine by config. + PromptForMapSelectionInChat(GetCurrentMapSelectionOption()); + } + } + else + { + FinishVeto(); + } + } + + public void PromptForMapSelectionInChat(string option) { + string action = ""; + int client = -1; + string stepMessage = ""; + switch (option) + { + case "team1_ban": + action = $"{ChatColors.Green}{matchzyTeam1.teamName}{ChatColors.Default} must now {ChatColors.Red}BAN{ChatColors.Default} a map."; + client = vetoCaptains["team1"]; + stepMessage = $"Use .ban to ban a map"; + break; + case "team2_ban": + action = $"{ChatColors.Green}{matchzyTeam2.teamName}{ChatColors.Default} must now {ChatColors.Red}BAN{ChatColors.Default} a map."; + client = vetoCaptains["team2"]; + stepMessage = $"Use .ban to ban a map"; + break; + case "team1_pick": + action = $"{ChatColors.Green}{matchzyTeam1.teamName}{ChatColors.Default} must now {ChatColors.Green}PICK{ChatColors.Default} a map to play as map {matchConfig.Maplist.Count + 1}."; + client = vetoCaptains["team1"]; + stepMessage = $"Use .pick to pick a map."; + break; + case "team2_pick": + action = $"{ChatColors.Green}{matchzyTeam2.teamName}{ChatColors.Default} must now {ChatColors.Green}PICK{ChatColors.Default} a map to play as map {matchConfig.Maplist.Count + 1}."; + client = vetoCaptains["team2"]; + stepMessage = $"Use .pick to pick a map."; + break; + } + if (!playerData.ContainsKey(client) || !playerData[client].IsValid) + { + Log($"[PromptForMapSelectionInChat] Invalid captain found with ID: {client}"); + return; + } + Server.PrintToChatAll($"{chatPrefix} {action}"); + + string mapListAsString = string.Join(", ", matchConfig.MapsLeftInVetoPool); + Server.PrintToChatAll($"{chatPrefix} Remaining Maps: {mapListAsString}"); + + playerData[client].PrintToChat($"{chatPrefix} {stepMessage}"); + } + + [ConsoleCommand("css_pick", "Picks map")] + public void OnPickMapCommand(CCSPlayerController? player, CommandInfo? command) { + if (player == null || command == null) return; + if (command.ArgCount < 1) return; + string mapArg = command.ArgByIndex(1); + HandeMapPickCommand(player, mapArg); + } + + [ConsoleCommand("css_ban", "Bans map")] + public void OnBanMapCommand(CCSPlayerController? player, CommandInfo? command) { + if (player == null || command == null) return; + if (command.ArgCount < 1) return; + string mapArg = command.ArgByIndex(1); + HandeMapBanCommand(player, mapArg); + } + + public void HandeMapBanCommand(CCSPlayerController player, string map) + { + if (!isVeto || SidePickPending() || player == null || map == null) return; + + int playerTeam = player.TeamNum; + string currentTeamToBan; + switch (GetCurrentMapSelectionOption()) { + case "team1_ban": + currentTeamToBan = "team1"; + break; + case "team2_ban": + currentTeamToBan = "team2"; + break; + case "invalid": + case "team1_pick": + case "team2_pick": + default: + return; + } + + if (player.UserId != vetoCaptains[currentTeamToBan]) return; + + if (!BanMap(map, playerTeam)) { + player.PrintToChat($"{chatPrefix} {map} is not a valid map."); + } else { + HandleVetoStep(); + } + } + + public void HandeMapPickCommand(CCSPlayerController player, string map) + { + if (!isVeto || SidePickPending() || player == null || map == null) return; + + int playerTeam = player.TeamNum; + string currentTeamToPick; + switch (GetCurrentMapSelectionOption()) + { + case "team1_pick": + currentTeamToPick = "team1"; + break; + case "team2_pick": + currentTeamToPick = "team2"; + break; + case "invalid": + case "team1_ban": + case "team2_ban": + default: + return; + } + + if (player.UserId != vetoCaptains[currentTeamToPick]) return; + + if (!PickMap(map, playerTeam)) { + player.PrintToChat($"{chatPrefix} {map} is not a valid map."); + } else { + HandleVetoStep(); + } + } + + + public bool PickMap(string mapName, int team) { + (bool mapRemoved, string mapRemovedName) = RemoveMapFromMapPool(mapName); + + if (!mapRemoved) return false; + + Team matchzyTeam = matchzyTeam1; + + if (team != 0) { + matchzyTeam = (team == 2) ? reverseTeamSides["TERRORIST"] : reverseTeamSides["CT"]; + Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}{matchzyTeam.teamName}{ChatColors.Default} picked {ChatColors.Green}{mapRemovedName}{ChatColors.Default} as map {matchConfig.Maplist.Count + 1}"); + } + + matchConfig.Maplist.Add(mapRemovedName); + + var mapPickedEvent = new MatchZyMapPickedEvent + { + MatchId = liveMatchId.ToString(), + MapName = mapRemovedName, + MapNumber = matchConfig.Maplist.Count, + Team = (matchzyTeam == matchzyTeam1) ? "team1" : "team2", + }; + + Task.Run(async () => { + await SendEventAsync(mapPickedEvent); + }); + + lastVetoTeam = (CsTeam)team; + + return true; + } + + public bool BanMap(string mapName, int team) + { + (bool mapRemoved, string mapRemovedName) = RemoveMapFromMapPool(mapName); + + if (!mapRemoved) return false; + + Team matchzyTeam = matchzyTeam1; + + if (team != 0) { + matchzyTeam = (team == 2) ? reverseTeamSides["TERRORIST"] : reverseTeamSides["CT"]; + Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}{matchzyTeam.teamName}{ChatColors.Default} banned {ChatColors.LightRed}{mapRemovedName}{ChatColors.Default}"); + } + + var mapMapVetoedEvent = new MatchZyMapVetoedEvent + { + MatchId = liveMatchId.ToString(), + MapName = mapRemovedName, + Team = (matchzyTeam == matchzyTeam1) ? "team1" : "team2", + }; + + Task.Run(async () => { + await SendEventAsync(mapMapVetoedEvent); + }); + + lastVetoTeam = (CsTeam)team; + + return true; + } + + public void AbortVeto() + { + // Todo: Add AbortVeto() when captain is disconnecting in-between veto + Server.PrintToChatAll($"{chatPrefix} A team captain left during map selection. Map selection is paused."); + Server.PrintToChatAll($"{chatPrefix} Type .ready when you are ready to resume map selection."); + isPreVeto = true; + isVeto = false; + if (isPaused) + { + UnpauseMatch(); + } + vetoCaptains = new(){ + {"team1", -1}, + {"team2", -1} + }; + foreach (var key in playerReadyStatus.Keys) { + playerReadyStatus[key] = false; + } + readyAvailable = true; + isWarmup = true; + StartWarmup(); + } + + public void FinishVeto() + { + Server.PrintToChatAll($"{chatPrefix} The maps have been decided:"); + matchConfig.MapsLeftInVetoPool.Clear(); + + if (isPaused) { + UnpauseMatch(); + } + + // If a team has a map advantage, don't print that map. + int mapNumber = matchConfig.CurrentMapNumber; + + for (int i = mapNumber; i < matchConfig.Maplist.Count; i++) { + Server.PrintToChatAll($"{chatPrefix} Map {i + 1 - mapNumber}: {matchConfig.Maplist[i]}."); + } + + string currentMapName = Server.MapName; + string mapToPlay = matchConfig.Maplist[0]; + + // In case the sides don't match after selection, we check it here before writing the backup. + // Also required if the map doesn't need to change. + SetMapSides(); + ExecuteChangedConvars(); + foreach (var key in playerReadyStatus.Keys) { + playerReadyStatus[key] = false; + } + + if (IsMapReloadRequiredForGameMode(matchConfig.Wingman) || mapReloadRequired || currentMapName != mapToPlay) { + + SetCorrectGameMode(); + float delay = 7.0f; + mapChangePending = true; + // Todo: Implement displayGotvVeto cvar + // if (displayGotvVeto) { + // delay += GetTvDelay(); + // } + AddTimer(delay, () => { + string nextMap = matchConfig.Maplist[matchConfig.CurrentMapNumber]; + ChangeMap(nextMap, 3); + }); + } + isWarmup = true; + readyAvailable = true; + isPreVeto = false; + isVeto = false; + StartWarmup(); + } + + + public int GetTeamCaptain(string team) + { + Team matchzyTeam = team == "team1" ? matchzyTeam1 : matchzyTeam2; + int teamSide = teamSides[matchzyTeam] == "CT" ? 3 : 2; + foreach (var key in playerData.Keys) + { + if (!playerData[key].IsValid || playerData[key].IsBot) continue; + if (playerData[key].TeamNum == teamSide) return key; + } + + return -1; + } + + public string GetCurrentMapSelectionOption() + { + // Number of banned maps must be: original pool - (current pool + picked); + // 7 - (4 + 2) = 1; if 4 are left and 2 were picked, 1 must have been banned. + int mapsBanned = matchConfig.MapsPool.Count - (matchConfig.MapsLeftInVetoPool.Count + matchConfig.Maplist.Count); + int index = matchConfig.Maplist.Count + mapsBanned; + if (index > matchConfig.MapBanOrder.Count - 1) + { + return "invalid"; + } + return matchConfig.MapBanOrder[index]; + } + public bool SidePickPending() { + return matchConfig.MapSides.Count < matchConfig.Maplist.Count && matchConfig.MatchSideType == "standard"; + } + + public void HandleAutomaticSideSelection() { + if (matchConfig.MatchSideType == "random") { + matchConfig.MapSides.Add(new Random().Next(0, 2) == 0 ? "team1_ct" : "team1_t"); + } else { + matchConfig.MapSides.Add(matchConfig.MatchSideType == "never_knife" ? "team1_ct" : "knife"); + } + } + + public (bool, string) RemoveMapFromMapPool(string mapName) { + string mapRemoved = ""; + int eraseIndex = -1; + // First check if we have a single match with a substring. + if (mapName.Length >= 4) { + for (int i = 0; i < matchConfig.MapsLeftInVetoPool.Count; i++) { + mapRemoved = matchConfig.MapsLeftInVetoPool[i]; + if (mapRemoved.IndexOf(mapName, StringComparison.OrdinalIgnoreCase) > -1) { + if (eraseIndex >= 0) { + eraseIndex = -1; // If more than one match, reset and break. + break; + } + eraseIndex = i; + } + } + } + // If no match or more than one match on substring, restart, this time only matching the full string. + if (eraseIndex == -1) { + for (int i = 0; i < matchConfig.MapsLeftInVetoPool.Count; i++) { + mapRemoved = matchConfig.MapsLeftInVetoPool[i]; + if (mapRemoved == mapName) { + eraseIndex = i; + break; + } + } + } + if (eraseIndex >= 0) { + mapRemoved = matchConfig.MapsLeftInVetoPool[eraseIndex]; + matchConfig.MapsLeftInVetoPool.RemoveAt(eraseIndex); + return (true, mapRemoved); + } + if (mapName.IndexOf("cobble", StringComparison.OrdinalIgnoreCase) > -1) { + // Because Cobblestone is the only map that's actually misspelled, we re-run the code if the input contained + // "cobble" but there was no match, this time using "cbble" instead, which will match de_cbble. + return RemoveMapFromMapPool("cbble"); + } + return (false, mapRemoved); + } + public void PromptForSideSelectionInChat(CsTeam team) { + string mapName = matchConfig.Maplist[^1]; + Team matchzyTeam = (team == CsTeam.CounterTerrorist) ? reverseTeamSides["CT"] : reverseTeamSides["TERRORIST"]; + string teamString = (matchzyTeam == matchzyTeam1) ? "team1" : "team2"; + + Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}{matchzyTeam.teamName}{ChatColors.Default} must now pick a side to play on {ChatColors.Green}{mapName}{ChatColors.Default}"); + + int client = vetoCaptains[teamString]; + if (!playerData.ContainsKey(client) || !playerData[client].IsValid) return; + + playerData[client].PrintToChat($"{chatPrefix} Use .ct or .t to pick a side"); + } + + public bool ValidateMapBanLogic() + { + int numberOfPicks = 0; + string option; + for (int i = 0; i < matchConfig.MapBanOrder.Count; i++) { + option = matchConfig.MapBanOrder[i]; + if (option == "team1_pick" || option == "team2_pick") { + numberOfPicks++; + } + if (numberOfPicks == matchConfig.NumMaps || i == matchConfig.MapsPool.Count - 2) { + break; + } + } + + // Example: In a Bo3, at least 2 of the options must be picks to avoid randomly selecting map order of remaining maps. + if (matchConfig.NumMaps > 1 && numberOfPicks < matchConfig.NumMaps - 1) { + Log($"[ValidateMapBanLogic] In a series of {matchConfig.NumMaps} maps, at least {matchConfig.NumMaps - 1} veto options must be picks. Found {numberOfPicks} pick(s)."); + return false; + } + + if (matchConfig.MapsPool.Count - 1 != matchConfig.MapBanOrder.Count && numberOfPicks != matchConfig.NumMaps) { + // Example: Map pool of 7 requires 6 picks/bans *unless* we have picks for all maps. + Log($"[ValidateMapBanLogic] The number of maps in the pool {matchConfig.MapsPool.Count} must be one larger than the number of map picks/bans {matchConfig.MapBanOrder.Count}, unless the number of picks {numberOfPicks} matches the series length {matchConfig.NumMaps}."); + return false; + } + + return true; + } + + public void HandleSideChoice(CsTeam side, int client) { + if (!SidePickPending()) { + // No side selection is done by players in this case. + return; + } + Team team = matchzyTeam1; + + if (lastVetoTeam == CsTeam.Terrorist) team = reverseTeamSides["CT"]; + else if (lastVetoTeam == CsTeam.CounterTerrorist) team = reverseTeamSides["TERRORIST"]; + + string pickingTeam = (team == matchzyTeam1) ? "team1" : "team2"; + + if (client != vetoCaptains[pickingTeam]) { + // Only captain can select a side. + return; + } + PickSide(side, pickingTeam); + HandleVetoStep(); + } + + public void PickSide(CsTeam side, string team) { + if (side == CsTeam.CounterTerrorist) { + matchConfig.MapSides.Add(team == "team1" ? "team1_ct" : "team1_t"); + } else { + matchConfig.MapSides.Add(team == "team1" ? "team1_t" : "team1_ct"); + } + + int mapNumber = matchConfig.Maplist.Count - 1; + + string mapName = matchConfig.Maplist[mapNumber]; + + string sideFormatted = (side == CsTeam.CounterTerrorist) ? "CT" : "T"; + + Team matchzyTeam = (team == "team1") ? matchzyTeam1 : matchzyTeam2; + + Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}{matchzyTeam.teamName}{ChatColors.Default} elected to start as {ChatColors.Green}{sideFormatted}{ChatColors.Default} on {ChatColors.Green}{mapName}{ChatColors.Default}."); + + var sidePickedEvent = new MatchZySidePickedEvent + { + MatchId = liveMatchId.ToString(), + MapName = mapName, + MapNumber = matchConfig.Maplist.Count, + Team = (matchzyTeam == matchzyTeam1) ? "team1" : "team2", + Side = sideFormatted.ToLower() + }; + Task.Run(async () => { + await SendEventAsync(sidePickedEvent); + }); + } + + public void GenerateDefaultVetoSetup() + { + Team startingVetoTeam = matchzyTeam1; + if (lastVetoTeam == CsTeam.CounterTerrorist) + { + if (reverseTeamSides["CT"] == matchzyTeam1) startingVetoTeam = matchzyTeam2; + if (reverseTeamSides["CT"] == matchzyTeam2) startingVetoTeam = matchzyTeam1; + } + else if (lastVetoTeam == CsTeam.Terrorist) + { + if (reverseTeamSides["TERRORIST"] == matchzyTeam1) startingVetoTeam = matchzyTeam2; + if (reverseTeamSides["TERRORIST"] == matchzyTeam2) startingVetoTeam = matchzyTeam1; + } + switch (matchConfig.NumMaps) + { + case 1: + int numberOfBans = matchConfig.MapsPool.Count - 1; // Last map either played by default or ignored. + for (int i = 0; i < numberOfBans; i++) + { + matchConfig.MapBanOrder.Add( + i % 2 == 0 + ? (startingVetoTeam == matchzyTeam1 ? "team1_ban" : "team2_ban") + : (startingVetoTeam == matchzyTeam1 ? "team2_ban" : "team1_ban")); + } + break; + + case 2: + if (matchConfig.MapsPool.Count < 5) + { + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team1_pick" + : "team2_pick"); + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team2_pick" + : "team1_pick"); + } + else + { + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team1_ban" + : "team2_ban"); + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team2_ban" + : "team1_ban"); + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team1_pick" + : "team2_pick"); + matchConfig.MapBanOrder.Add(startingVetoTeam == matchzyTeam1 ? "team2_pick" + : "team1_pick"); + } + break; + + default: + // Bo3 with 7 maps as an example. + // For this to work, a Bo3 requires a map pool of at least 5. + if (matchConfig.MapsPool.Count >= matchConfig.NumMaps + 2) + { // 7 >= 3 + 2 + int numberOfPicks = matchConfig.NumMaps - 1; // 2 picks in a Bo3 + // Determine how many bans before we start picking (may be 0): + int numberOfStartBans = matchConfig.MapsPool.Count - (matchConfig.NumMaps + 2); // 7 - (3 + 2) = 2 + if (numberOfStartBans > 0) + { // == 2 + for (int i = 0; i < numberOfStartBans; i++) + { + matchConfig.MapBanOrder.Add( + matchConfig.MapBanOrder.Count % 2 == 0 + ? (startingVetoTeam == matchzyTeam1 ? "team1_ban" : "team2_ban") + : (startingVetoTeam == matchzyTeam1 ? "team2_ban" : "team1_ban")); + } + } + + // After the initial bans, add the picks: + for (int i = 0; i < numberOfPicks; i++) + { + matchConfig.MapBanOrder.Add( + matchConfig.MapBanOrder.Count % 2 == 0 + ? (startingVetoTeam == matchzyTeam1 ? "team1_pick" : "team2_pick") + : (startingVetoTeam == matchzyTeam1 ? "team2_pick" : "team1_pick")); + } + + // Determine how many bans to append to the end (may be 0): + int numberOfEndBans = matchConfig.MapsPool.Count - 1 - numberOfPicks - numberOfStartBans; // 7 - 2 - 2 - 1 = 2 + if (numberOfEndBans > 0) + { // == 2 + for (int i = 0; i < numberOfEndBans; i++) + { + matchConfig.MapBanOrder.Add( + matchConfig.MapBanOrder.Count % 2 == 0 + ? (startingVetoTeam == matchzyTeam1 ? "team1_ban" : "team2_ban") + : (startingVetoTeam == matchzyTeam1 ? "team2_ban" : "team1_ban")); + } + } + } + else + { + // else we just alternate picks and ignore the last map. + for (int i = 0; i < matchConfig.NumMaps; i++) + { + matchConfig.MapBanOrder.Add( + i % 2 == 0 + ? (startingVetoTeam == matchzyTeam1 ? "team1_pick" : "team2_pick") + : (startingVetoTeam == matchzyTeam1 ? "team2_pick" : "team1_pick")); + } + } + break; + } + } + + } +} + diff --git a/MatchConfig.cs b/MatchConfig.cs index b736af1..7c80c5c 100644 --- a/MatchConfig.cs +++ b/MatchConfig.cs @@ -7,19 +7,31 @@ namespace MatchZy public class MatchConfig { public List Maplist { get; set; } = new List(); + public List MapsPool { get; set; } = new List(); + public List MapsLeftInVetoPool { get; set; } = new List(); + public List MapBanOrder { get; set; } = new List(); + public bool SkipVeto = true; public long MatchId { get; set; } public int NumMaps { get; set; } = 1; public int PlayersPerTeam { get; set; } = 5; public int MinPlayersToReady { get; set; } = 12; + public int MinSpectatorsToReady { get; set; } = 0; public int CurrentMapNumber = 0; public List MapSides { get; set; } = new List(); public bool SeriesCanClinch { get; set; } = true; public bool Scrim { get; set; } = false; + public bool Wingman { get; set; } = false; + + public string MatchSideType { get; set; } = "standard"; + public Dictionary ChangedCvars = new(); public Dictionary OriginalCvars = new(); public JToken? Spectators; + public string RemoteLogURL = ""; + public string RemoteLogHeaderKey = ""; + public string RemoteLogHeaderValue = ""; } } diff --git a/MatchData.cs b/MatchData.cs new file mode 100644 index 0000000..3f267b6 --- /dev/null +++ b/MatchData.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Serialization; + +namespace MatchZy; +public class Winner +{ + [JsonPropertyName("side")] + public string Side { get; set; } + + [JsonPropertyName("team")] + public string Team { get; set; } + + public Winner(string side, string team) + { + Side = side; + Team = team; + } +} + +public class StatsPlayer +{ + [JsonPropertyName("steamid")] + public required string SteamId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("stats")] + public required PlayerStats Stats { get; init; } +} + +// Referred from PugSharp +public class PlayerStats +{ + [JsonPropertyName("kills")] + public int Kills { get; set; } + + [JsonPropertyName("deaths")] + public int Deaths { get; set; } + + [JsonPropertyName("assists")] + public int Assists { get; set; } + + [JsonPropertyName("flash_assists")] + public int FlashAssists { get; set; } + + [JsonPropertyName("team_kills")] + public int TeamKills { get; set; } + + [JsonPropertyName("suicides")] + public int Suicides { get; set; } + + [JsonPropertyName("damage")] + public int Damage { get; set; } + + [JsonPropertyName("utility_damage")] + public int UtilityDamage { get; set; } + + [JsonPropertyName("enemies_flashed")] + public int EnemiesFlashed { get; set; } + + [JsonPropertyName("friendlies_flashed")] + public int FriendliesFlashed { get; set; } + + [JsonPropertyName("knife_kills")] + public int KnifeKills { get; set; } + + [JsonPropertyName("headshot_kills")] + public int HeadshotKills { get; set; } + + [JsonPropertyName("rounds_played")] + public int RoundsPlayed { get; set; } + + [JsonPropertyName("bomb_defuses")] + public int BombDefuses { get; set; } + + [JsonPropertyName("bomb_plants")] + public int BombPlants { get; set; } + + [JsonPropertyName("1k")] + public int Kills1 { get; set; } + + [JsonPropertyName("2k")] + public int Kills2 { get; set; } + + [JsonPropertyName("3k")] + public int Kills3 { get; set; } + + [JsonPropertyName("4k")] + public int Kills4 { get; set; } + + [JsonPropertyName("5k")] + public int Kills5 { get; set; } + + [JsonPropertyName("1v1")] + public int OneV1s { get; set; } + + [JsonPropertyName("1v2")] + public int OneV2s { get; set; } + + [JsonPropertyName("1v3")] + public int OneV3s { get; set; } + + [JsonPropertyName("1v4")] + public int OneV4s { get; set; } + + [JsonPropertyName("1v5")] + public int OneV5s { get; set; } + + [JsonPropertyName("first_kills_t")] + public int FirstKillsT { get; set; } + + [JsonPropertyName("first_kills_ct")] + public int FirstKillsCT { get; set; } + + [JsonPropertyName("first_deaths_t")] + public int FirstDeathsT { get; set; } + + [JsonPropertyName("first_deaths_ct")] + public int FirstDeathsCT { get; set; } + + [JsonPropertyName("trade_kills")] + public int TradeKills { get; set; } + + [JsonPropertyName("kast")] + public int Kast { get; set; } + + [JsonPropertyName("score")] + public int Score { get; set; } + + [JsonPropertyName("mvp")] + public int Mvps { get; set; } +} + +public class MatchZyTeamWrapper +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + public MatchZyTeamWrapper(string id, string name) + { + Id = id; + Name = name; + } +} + +public class MatchZyStatsTeam : MatchZyTeamWrapper +{ + [JsonPropertyName("series_score")] + public int SeriesScore { get; set; } + + [JsonPropertyName("score")] + public int Score { get; set; } + + [JsonPropertyName("score_ct")] + public int ScoreCT { get; set; } + + [JsonPropertyName("score_t")] + public int ScoreT { get; set; } + + [JsonPropertyName("players")] + public List Players { get; set; } + + public MatchZyStatsTeam(string id, string name, int seriesScore, int score, int scoreCt, int scoreT, List players) : base(id, name) + { + SeriesScore = seriesScore; + Score = score; + ScoreCT = scoreCt; + ScoreT = scoreT; + Players = players; + } +} diff --git a/MatchManagement.cs b/MatchManagement.cs index b183497..ba671a2 100644 --- a/MatchManagement.cs +++ b/MatchManagement.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; @@ -30,6 +31,16 @@ public partial class MatchZy public Dictionary teamSides = new(); public Dictionary reverseTeamSides = new(); + [ConsoleCommand("css_team1", "Sets team name for team1")] + public void OnTeam1Command(CCSPlayerController? player, CommandInfo command) { + HandleTeamNameChangeCommand(player, command.ArgString, 1); + } + + [ConsoleCommand("css_team2", "Sets team name for team1")] + public void OnTeam2Command(CCSPlayerController? player, CommandInfo command) { + HandleTeamNameChangeCommand(player, command.ArgString, 2); + } + [ConsoleCommand("matchzy_loadmatch", "Loads a match from the given JSON file path (relative to the csgo/ directory)")] public void LoadMatch(CCSPlayerController? player, CommandInfo command) { @@ -49,7 +60,12 @@ public void LoadMatch(CCSPlayerController? player, CommandInfo command) return; } string jsonData = File.ReadAllText(filePath); - LoadMatchFromJSON(jsonData); + bool success = LoadMatchFromJSON(jsonData); + if (!success) + { + command.ReplyToCommand("Match load failed! Resetting current match"); + ResetMatch(); + } } catch (Exception e) { @@ -58,6 +74,7 @@ public void LoadMatch(CCSPlayerController? player, CommandInfo command) } } + [ConsoleCommand("get5_loadmatch_url", "Loads a match from the given URL")] [ConsoleCommand("matchzy_loadmatch_url", "Loads a match from the given URL")] public void LoadMatchFromURL(CCSPlayerController? player, CommandInfo command) { @@ -68,7 +85,12 @@ public void LoadMatchFromURL(CCSPlayerController? player, CommandInfo command) return; } string url = command.ArgByIndex(1); - Log($"[LoadMatchDataCommand] Match setup request received with URL: {url}"); + + string headerName = command.ArgCount > 3 ? command.ArgByIndex(2) : ""; + string headerValue = command.ArgCount > 3 ? command.ArgByIndex(3) : ""; + + Log($"[LoadMatchDataCommand] Match setup request received with URL: {url} headerName: {headerName} and headerValue: {headerValue}"); + if (!IsValidUrl(url)) { Log($"[LoadMatchDataCommand] Invalid URL: {url}. Please provide a valid URL to load the match!"); @@ -77,6 +99,10 @@ public void LoadMatchFromURL(CCSPlayerController? player, CommandInfo command) try { HttpClient httpClient = new(); + if (headerName != "") + { + httpClient.DefaultRequestHeaders.Add(headerName, headerValue); + } HttpResponseMessage response = httpClient.GetAsync(url).Result; if (response.IsSuccessStatusCode) @@ -84,7 +110,12 @@ public void LoadMatchFromURL(CCSPlayerController? player, CommandInfo command) string jsonData = response.Content.ReadAsStringAsync().Result; Log($"[LoadMatchFromURL] Received following data: {jsonData}"); - LoadMatchFromJSON(jsonData); + bool success = LoadMatchFromJSON(jsonData); + if (!success) + { + command.ReplyToCommand("Match load failed! Resetting current match"); + ResetMatch(); + } } else { @@ -100,7 +131,7 @@ public void LoadMatchFromURL(CCSPlayerController? player, CommandInfo command) static string ValidateMatchJsonStructure(JObject jsonData) { - string[] requiredFields = { "maplist", "team1", "team2", "num_maps", "map_sides" }; + string[] requiredFields = { "maplist", "team1", "team2", "num_maps" }; // Check if any required field is missing foreach (string field in requiredFields) @@ -118,7 +149,9 @@ static string ValidateMatchJsonStructure(JObject jsonData) switch (field) { case "matchid": + case "players_per_team": case "min_players_to_ready": + case "min_spectators_to_ready": case "num_maps": int numMaps; if (!int.TryParse(jsonData[field].ToString(), out numMaps)) @@ -147,12 +180,19 @@ static string ValidateMatchJsonStructure(JObject jsonData) { return $"{field} should be a JSON structure!"; } - if (jsonData[field]["players"] == null || jsonData[field]["players"].Type != JTokenType.Object) + if ((field != "spectators") && (jsonData[field]["players"] == null || jsonData[field]["players"].Type != JTokenType.Object)) { return $"{field} should have 'players' JSON!"; } break; + case "veto_mode": + if (jsonData[field].Type != JTokenType.Array) + { + return $"{field} should be an Array!"; + } + break; + case "maplist": if (jsonData[field].Type != JTokenType.Array) { @@ -181,8 +221,9 @@ static string ValidateMatchJsonStructure(JObject jsonData) } break; + case "skip_veto": case "clinch_series": - if (!Convert.ToBoolean(jsonData[field].ToString())) + if (!bool.TryParse(jsonData[field].ToString(), out bool result)) { return $"{field} should be a boolean!"; } @@ -193,7 +234,7 @@ static string ValidateMatchJsonStructure(JObject jsonData) return ""; } - public void LoadMatchFromJSON(string jsonData) + public bool LoadMatchFromJSON(string jsonData) { JObject jsonDataObject = JObject.Parse(jsonData); @@ -203,7 +244,7 @@ public void LoadMatchFromJSON(string jsonData) if (validationError != "") { Log($"[LoadMatchDataCommand] {validationError}"); - return; + return false; } if(jsonDataObject["matchid"] != null) @@ -214,58 +255,107 @@ public void LoadMatchFromJSON(string jsonData) JToken team2 = jsonDataObject["team2"]!; JToken maplist = jsonDataObject["maplist"]!; + if (team1["id"] != null) matchzyTeam1.id = team1["id"].ToString(); + if (team2["id"] != null) matchzyTeam2.id = team2["id"].ToString(); + matchzyTeam1.teamName = RemoveSpecialCharacters(team1["name"].ToString()); matchzyTeam2.teamName = RemoveSpecialCharacters(team2["name"].ToString()); matchzyTeam1.teamPlayers = team1["players"]; matchzyTeam2.teamPlayers = team2["players"]; - if(jsonDataObject["min_players_to_ready"] != null) - { - minimumReadyRequired = jsonDataObject["min_players_to_ready"]!.Value(); - } matchConfig = new() { MatchId = liveMatchId, - Maplist = maplist.ToObject>()!, + MapsPool = maplist.ToObject>()!, + MapsLeftInVetoPool = maplist.ToObject>()!, NumMaps = jsonDataObject["num_maps"]!.Value(), - MapSides = jsonDataObject["map_sides"]!.ToObject>()!, MinPlayersToReady = minimumReadyRequired }; - if (jsonDataObject["spectators"] != null && jsonDataObject["spectators"]!["players"] != null) + GetOptionalMatchValues(jsonDataObject); + + if (matchConfig.MapsPool.Count == matchConfig.NumMaps) { - matchConfig.Spectators = jsonDataObject["spectators"]!["players"]; + matchConfig.SkipVeto = true; + isPreVeto = false; } - if (jsonDataObject["clinch_series"] != null) + else if (matchConfig.MapsPool.Count < matchConfig.NumMaps) + { + Log($"[LOADMATCH] The map pool {matchConfig.MapsPool.Count} is not large enough to play a series of {matchConfig.NumMaps} maps."); + return false; + } + + if (!matchConfig.SkipVeto) { - matchConfig.SeriesCanClinch = Convert.ToBoolean(jsonDataObject["clinch_series"]!.ToString()); + if (matchConfig.MapBanOrder.Count != 0) + { + if (!ValidateMapBanLogic()) return false; + } + else + { + GenerateDefaultVetoSetup(); + } } GetCvarValues(jsonDataObject); Log($"[LOADMATCH] MinPlayersToReady: {matchConfig.MinPlayersToReady} SeriesClinch: {matchConfig.SeriesCanClinch}"); + Log($"[LOADMATCH] MapsPool: {string.Join(", ", matchConfig.MapsPool)} MapsLeftInVetoPool: {string.Join(", ", matchConfig.MapsLeftInVetoPool)}"); LoadClientNames(); - string mapName = matchConfig.Maplist[0].ToString(); - - if (long.TryParse(mapName, out _)) { - Server.ExecuteCommand($"host_workshop_map \"{mapName}\""); - } else if (Server.IsMapValid(mapName)) { - Server.ExecuteCommand($"changelevel \"{mapName}\""); - } else { - Log($"[LoadMatchFromJSON] Invalid map name: {mapName}, cannot setup match!"); - ResetMatch(false); - return; + if (matchConfig.SkipVeto) + { + // Copy the first k maps from the maplist to the final match maps. + for (int i = 0; i < matchConfig.NumMaps; i++) + { + matchConfig.Maplist.Add(matchConfig.MapsPool[i]); + + // Push a map side if one hasn't been set yet. + if (matchConfig.MapSides.Count < matchConfig.Maplist.Count) { + if (matchConfig.MatchSideType == "standard" || matchConfig.MatchSideType == "always_knife") { + matchConfig.MapSides.Add("knife"); + } else if (matchConfig.MatchSideType == "random") { + matchConfig.MapSides.Add(new Random().Next(0, 2) == 0 ? "team1_ct" : "team1_t"); + } else { + matchConfig.MapSides.Add("team1_ct"); + } + } + } + string mapName = matchConfig.Maplist[0].ToString(); + + if (long.TryParse(mapName, out _)) { + Server.ExecuteCommand($"host_workshop_map \"{mapName}\""); + } else if (Server.IsMapValid(mapName)) { + Server.ExecuteCommand($"changelevel \"{mapName}\""); + } else { + Log($"[LoadMatchFromJSON] Invalid map name: {mapName}, cannot setup match!"); + ResetMatch(false); + return false; + } } + else + { + isPreVeto = true; + } + + readyAvailable = true; + + // This is done before starting warmup so that cvars like get5_remote_log_url are set properly to send the events + ExecuteChangedConvars(); StartWarmup(); isMatchSetup = true; - SetMapSides(); + if(matchConfig.SkipVeto) SetMapSides(); + + SetTeamNames(); + + UpdatePlayersMap(); Log($"[LoadMatchFromJSON] Success with matchid: {liveMatchId}!"); + return true; } public void SetMapSides() { @@ -290,6 +380,12 @@ public void SetMapSides() { { isKnifeRequired = true; } + + SetTeamNames(); + } + + public void SetTeamNames() + { Server.ExecuteCommand($"mp_teamname_1 {reverseTeamSides["CT"].teamName}"); Server.ExecuteCommand($"mp_teamname_2 {reverseTeamSides["TERRORIST"].teamName}"); } @@ -320,15 +416,46 @@ public void GetCvarValues(JObject jsonDataObject) } } - - [ConsoleCommand("css_team1", "Sets team name for team1")] - public void OnTeam1Command(CCSPlayerController? player, CommandInfo command) { - HandleTeamNameChangeCommand(player, command.ArgString, 1); - } - - [ConsoleCommand("css_team2", "Sets team name for team1")] - public void OnTeam2Command(CCSPlayerController? player, CommandInfo command) { - HandleTeamNameChangeCommand(player, command.ArgString, 2); + public void GetOptionalMatchValues(JObject jsonDataObject) + { + if(jsonDataObject["map_sides"] != null) + { + matchConfig.MapSides = jsonDataObject["map_sides"]!.ToObject>()!; + } + if(jsonDataObject["players_per_team"] != null) + { + matchConfig.PlayersPerTeam = jsonDataObject["players_per_team"]!.Value(); + } + if(jsonDataObject["min_players_to_ready"] != null) + { + matchConfig.MinPlayersToReady = jsonDataObject["min_players_to_ready"]!.Value(); + } + if(jsonDataObject["min_spectators_to_ready"] != null) + { + matchConfig.MinSpectatorsToReady = jsonDataObject["min_spectators_to_ready"]!.Value(); + } + if (jsonDataObject["spectators"] != null && jsonDataObject["spectators"]!["players"] != null) + { + matchConfig.Spectators = jsonDataObject["spectators"]!["players"]; + if (matchConfig.Spectators is JArray spectatorsArray && spectatorsArray.Count == 0) + { + // Convert the empty JArray to an empty JObject + matchConfig.Spectators = new JObject(); + } + } + if (jsonDataObject["clinch_series"] != null) + { + matchConfig.SeriesCanClinch = bool.Parse(jsonDataObject["clinch_series"]!.ToString()); + } + if (jsonDataObject["skip_veto"] != null) + { + matchConfig.SkipVeto = bool.Parse(jsonDataObject["skip_veto"]!.ToString()); + } + if (jsonDataObject["veto_mode"] != null) + { + matchConfig.MapBanOrder = jsonDataObject["veto_mode"]!.ToObject>()!; + } + } public void HandleTeamNameChangeCommand(CCSPlayerController? player, string teamName, int teamNum) { @@ -413,6 +540,21 @@ private CsTeam GetPlayerTeam(CCSPlayerController player) public void EndSeries(string winnerName, int restartDelay) { Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}{winnerName}{ChatColors.Default} has won the match"); + + (int t1score, int t2score) = GetTeamsScore(); + var seriesResultEvent = new MatchZySeriesResultEvent() + { + MatchId = liveMatchId.ToString(), + Winner = new Winner(t1score > t2score && reverseTeamSides["CT"] == matchzyTeam1 ? "3" : "2", matchzyTeam1.seriesScore > matchzyTeam2.seriesScore ? "team1" : "team2"), + Team1SeriesScore = matchzyTeam1.seriesScore, + Team2SeriesScore = matchzyTeam2.seriesScore, + TimeUntilRestore = 10, + }; + Task.Run(async () => { + // Making sure that map end event is fired first + await Task.Delay(2000); + await SendEventAsync(seriesResultEvent); + }); database.SetMatchEndData(liveMatchId, winnerName, matchzyTeam1.seriesScore, matchzyTeam2.seriesScore); if (resetCvarsOnSeriesEnd) ResetChangedConvars(); isMatchLive = false; diff --git a/MatchZy.cs b/MatchZy.cs index 9269e2e..2e803dd 100644 --- a/MatchZy.cs +++ b/MatchZy.cs @@ -3,6 +3,7 @@ using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.Core.Attributes; +using CounterStrikeSharp.API.Modules.Timers; namespace MatchZy @@ -12,7 +13,7 @@ public partial class MatchZy : BasePlugin { public override string ModuleName => "MatchZy"; - public override string ModuleVersion => "0.5.1-alpha"; + public override string ModuleVersion => "0.6.0-alpha"; public override string ModuleAuthor => "WD- (https://github.com/shobhit-pathak/)"; @@ -33,6 +34,8 @@ public partial class MatchZy : BasePlugin public long liveMatchId = -1; public int autoStartMode = 1; + public bool mapReloadRequired = false; + // Pause Data public bool isPaused = false; public Dictionary unpauseData = new Dictionary { @@ -596,6 +599,18 @@ public override void Load(bool hotReload) { HandleCoachCommand(player, coachSide); } + if (message.StartsWith(".ban")) { + string command = ".ban"; + string mapArg = message.Substring(command.Length).Trim(); + + HandeMapBanCommand(player, mapArg); + } + if (message.StartsWith(".pick")) { + string command = ".pick"; + string mapArg = message.Substring(command.Length).Trim(); + + HandeMapPickCommand(player, mapArg); + } return HookResult.Continue; }); diff --git a/PracticeMode.cs b/PracticeMode.cs index a17e84f..05e9c4d 100644 --- a/PracticeMode.cs +++ b/PracticeMode.cs @@ -926,6 +926,11 @@ public void OnClearCommand(CCSPlayerController? player, CommandInfo? command) [ConsoleCommand("css_t", "Switches team to Terrorist")] public void OnTCommand(CCSPlayerController? player, CommandInfo? command) { + if (player == null || player.UserId == null) return; + if (isVeto) { + HandleSideChoice(CsTeam.Terrorist, player.UserId.Value); + return; + } if (!isPractice || player == null) return; SideSwitchCommand(player, CsTeam.Terrorist); @@ -933,7 +938,12 @@ public void OnTCommand(CCSPlayerController? player, CommandInfo? command) { [ConsoleCommand("css_ct", "Switches team to Counter-Terrorist")] public void OnCTCommand(CCSPlayerController? player, CommandInfo? command) { - if (!isPractice || player == null) return; + if (player == null || player.UserId == null) return; + if (isVeto) { + HandleSideChoice(CsTeam.CounterTerrorist, player.UserId.Value); + return; + } + if (!isPractice) return; SideSwitchCommand(player, CsTeam.CounterTerrorist); } @@ -946,6 +956,7 @@ public void OnSpecCommand(CCSPlayerController? player, CommandInfo? command) { } [ConsoleCommand("css_fas", "Switches all other players to spectator")] + [ConsoleCommand("css_watchme", "Switches all other players to spectator")] public void OnFASCommand(CCSPlayerController? player, CommandInfo? command) { if (!isPractice || player == null) return; diff --git a/PublishEvents.cs b/PublishEvents.cs new file mode 100644 index 0000000..06c7921 --- /dev/null +++ b/PublishEvents.cs @@ -0,0 +1,46 @@ +using System.Text; +using System.Text.Json; + + +namespace MatchZy +{ + public partial class MatchZy + { + public async Task SendEventAsync(MatchZyEvent @event) + { + try + { + if (string.IsNullOrEmpty(matchConfig.RemoteLogURL)) return; + + Log($"[SendEventAsync] Sending Event: {@event.EventName} for matchId: {liveMatchId} mapNumber: {matchConfig.CurrentMapNumber} on {matchConfig.RemoteLogURL}"); + + using var httpClient = new HttpClient(); + using var jsonContent = new StringContent(JsonSerializer.Serialize(@event, @event.GetType()), Encoding.UTF8, "application/json"); + + string jsonString = await jsonContent.ReadAsStringAsync(); + + Log($"[SendEventAsync] SENDING DATA: {jsonString}"); + + if (!string.IsNullOrEmpty(matchConfig.RemoteLogHeaderKey)) + { + httpClient.DefaultRequestHeaders.Add(matchConfig.RemoteLogHeaderKey, matchConfig.RemoteLogHeaderValue); + } + + var httpResponseMessage = await httpClient.PostAsync(matchConfig.RemoteLogURL, jsonContent); + + if (httpResponseMessage.IsSuccessStatusCode) + { + Log($"[SendEventAsync] Sending {@event.EventName} for matchId: {liveMatchId} mapNumber: {matchConfig.CurrentMapNumber} successful with status code: {httpResponseMessage.StatusCode}"); + } + else + { + Log($"[SendEventAsync] Sending {@event.EventName} for matchId: {liveMatchId} mapNumber: {matchConfig.CurrentMapNumber} failed with status code: {httpResponseMessage.StatusCode}, ResponseContent: {await httpResponseMessage.Content.ReadAsStringAsync()}"); + } + } + catch (Exception e) + { + Log($"[SendEventAsync FATAL] An error occurred: {e.Message}"); + } + } + } +} diff --git a/README.md b/README.md index 30e5f3b..dd8c42d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,26 @@ MatchZy is a plugin for CS2 (Counter Strike 2) for running and managing practice [![Discord](https://discordapp.com/api/guilds/1169549878490304574/widget.png?style=banner2)](https://discord.gg/2zvhy9m7qg) +## Feature Highligts: + +* Pug mode with simple commands to manage! +* Support of [Get5 Panel!](https://shobhit-pathak.github.io/MatchZy/get5/) +* Support BO1/BO3/BO5 and Veto when using Match configuration or Get5 Panel! +* [Setting up matches](https://shobhit-pathak.github.io/MatchZy/match_setup/) and locking players into their team +* Practice Mode with `.bot`, `.spawn`, `.ctspawn`, `.tspawn`, `.nobots`, `.clear`, `.exitprac` and many more commands! +* Knife round (With expected logic, i.e., team with most players win. If same number of players, then team with HP advantage wins. If same HP, winner is decided randomly) +* Automatically starts demo recording and stop recording when match is ended (Make sure you have tv_enable 1) +* Automatically uploads demo on map end on the given URL. +* Players whitelisting (Thanks to [DEAFPS](https://github.com/DEAFPS)!) +* Coaching system +* Damage report after every round +* Support for round restore (Currently using the vanilla valve's backup system) +* Ability to create admin and allowing them access to admin commands +* Database Stats and CSV Stats! MatchZy stores data and stats of all the matches in a local SQLite database (MySQL Database is also supported!) and also creates a CSV file for detailed stats of every player in that match! +* Provides easy configuration +* And much more!! + + ## Documentation ## [shobhit-pathak.github.io/MatchZy/](https://shobhit-pathak.github.io/MatchZy/) @@ -14,6 +34,7 @@ MIT ## Credits and thanks! * [Get5](https://github.com/splewis/get5) - A lot of functionalities and workings have been referred from Get5 and they did an amazing job for managing matches in CS:GO. Huge thanks to them! +* [G5V](https://github.com/PhlexPlexico/G5V) and [G5API](https://github.com/PhlexPlexico/G5API) - Amazing work with the web panel for managing the servers! * [eBot](https://github.com/deStrO/eBot-CSGO) - Amazing job in CS:GO and then provided this great panel again in CS2 which is helping a lot of organizers now. Some logics have been referred from eBot as well! * [CounterStrikeSharp](https://github.com/roflmuffin/CounterStrikeSharp/) - Amazing job with development of CSSharp which gave us a platform to build our own plugins and also sparked my interest in plugin development! * [AlliedModders and community](https://alliedmods.net/) - They are the reason this whole plugin was possible! They are very helpful and inspire a lot! diff --git a/ReadySystem.cs b/ReadySystem.cs new file mode 100644 index 0000000..50ffc80 --- /dev/null +++ b/ReadySystem.cs @@ -0,0 +1,82 @@ +using CounterStrikeSharp.API.Modules.Utils; + +namespace MatchZy; + +public partial class MatchZy +{ + + public bool IsTeamsReady() + { + return IsTeamReady((int)CsTeam.CounterTerrorist) && IsTeamReady((int)CsTeam.Terrorist); + } + + public bool IsSpectatorsReady() + { + return IsTeamReady((int)CsTeam.Spectator); + } + + public bool IsTeamReady(int team) + { + // if (matchStarted) return true; + + int minPlayers = GetPlayersPerTeam(team); + int minReady = GetTeamMinReady(team); + (int playerCount, int readyCount) = GetTeamPlayerCount(team, false); + + Log($"[IsTeamReady] team: {team} minPlayers:{minPlayers} minReady:{minReady} playerCount:{playerCount} readyCount:{readyCount}"); + + if (team == (int)CsTeam.Spectator && minReady == 0) + { + return true; + } + + if (readyAvailable && playerCount == 0) + { + // We cannot ready for veto with no players, regardless of force status or min_players_to_ready. + return false; + } + + if (playerCount == readyCount && playerCount >= minPlayers) + { + return true; + } + + // Todo: Implement Force ready system + + // if (IsTeamForcedReady(team) && readyCount >= minReady) + // { + // return true; + // } + + return false; + } + + public int GetPlayersPerTeam(int team) + { + if (team == (int)CsTeam.CounterTerrorist || team == (int)CsTeam.Terrorist) return matchConfig.PlayersPerTeam; + if (team == (int)CsTeam.Spectator) return matchConfig.MinSpectatorsToReady; + return 0; + } + + public int GetTeamMinReady(int team) + { + if (team == (int)CsTeam.CounterTerrorist || team == (int)CsTeam.Terrorist) return matchConfig.MinPlayersToReady; + if (team == (int)CsTeam.Spectator) return matchConfig.MinSpectatorsToReady; + return 0; + } + + public (int, int) GetTeamPlayerCount(int team, bool includeCoaches = false) + { + int playerCount = 0; + int readyCount = 0; + foreach (var key in playerData.Keys) + { + if (!playerData[key].IsValid) continue; + if (playerData[key].TeamNum == team) { + playerCount++; + if (playerReadyStatus[key] == true) readyCount++; + } + } + return (playerCount, readyCount); + } +} diff --git a/RemoteLogConfig.cs b/RemoteLogConfig.cs new file mode 100644 index 0000000..5f24d28 --- /dev/null +++ b/RemoteLogConfig.cs @@ -0,0 +1,45 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; + +namespace MatchZy +{ + public partial class MatchZy + { + [ConsoleCommand("get5_remote_log_url", "If defined, all events are sent to this URL over HTTP. If no protocol is provided")] + [ConsoleCommand("matchzy_remote_log_url", "If defined, all events are sent to this URL over HTTP. If no protocol is provided")] + public void RemoteLogURLCommand(CCSPlayerController? player, CommandInfo command) + { + if (player != null) return; + string url = command.ArgByIndex(1); + + if (!IsValidUrl(url)) + { + Log($"[RemoteLogURLCommand] Invalid URL: {url}. Please provide a valid URL!"); + return; + } + + matchConfig.RemoteLogURL = url; + } + + [ConsoleCommand("get5_remote_log_header_key", "If defined, a custom HTTP header with this name is added to the HTTP requests for events")] + [ConsoleCommand("matchzy_remote_log_header_key", "If defined, a custom HTTP header with this name is added to the HTTP requests for events")] + public void RemoteLogHeaderKeyCommand(CCSPlayerController? player, CommandInfo command) + { + if (player != null) return; + string header = command.ArgByIndex(1).Trim(); + + if (header != "") matchConfig.RemoteLogHeaderKey = header; + } + + [ConsoleCommand("get5_remote_log_header_value", "If defined, the value of the custom header added to the events sent over HTTP")] + [ConsoleCommand("matchzy_remote_log_header_value", "If defined, the value of the custom header added to the events sent over HTTP")] + public void RemoteLogHeaderValueCommand(CCSPlayerController? player, CommandInfo command) + { + if (player != null) return; + string headerValue = command.ArgByIndex(1).Trim(); + + if (headerValue != "") matchConfig.RemoteLogHeaderValue = headerValue; + } + } +} diff --git a/SleepMode.cs b/SleepMode.cs index 3ebf9be..f964948 100644 --- a/SleepMode.cs +++ b/SleepMode.cs @@ -2,17 +2,6 @@ using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; -using CounterStrikeSharp.API.Modules.Utils; -using CounterStrikeSharp.API.Modules.Timers; -using CounterStrikeSharp.API.Modules.Memory; - -using System; -using System.IO; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; -using System.Net.Mime; - namespace MatchZy diff --git a/Teams.cs b/Teams.cs index 5d0d1d6..25a9a7c 100644 --- a/Teams.cs +++ b/Teams.cs @@ -11,6 +11,7 @@ namespace MatchZy public class Team { + public string id = ""; public required string teamName; public string teamFlag = ""; public string teamTag = ""; @@ -58,6 +59,52 @@ public void OnUnCoachCommand(CCSPlayerController? player, CommandInfo? command) ReplyToUserCommand(player, "You are now not coaching any team!"); } + [ConsoleCommand("matchzy_addplayer", "Adds player to the provided team")] + [ConsoleCommand("get5_addplayer", "Adds player to the provided team")] + public void OnAddPlayerCommand(CCSPlayerController? player, CommandInfo? command) + { + if (player != null || command == null) return; + if (!isMatchSetup) { + command.ReplyToCommand("No match is setup!"); + return; + } + if (IsHalfTimePhase()) + { + command.ReplyToCommand("Cannot add players during halftime. Please wait until the next round starts."); + return; + } + if (command.ArgCount < 3) + { + command.ReplyToCommand("Usage: matchzy_addplayertoteam \"\""); + return; + } + + string playerSteamId = command.ArgByIndex(1); + string playerTeam = command.ArgByIndex(2); + string playerName = command.ArgByIndex(3); + bool success; + if (playerTeam == "team1") + { + success = AddPlayerToTeam(playerSteamId, playerName, matchzyTeam1.teamPlayers); + } else if (playerTeam == "team2") + { + success = AddPlayerToTeam(playerSteamId, playerName, matchzyTeam2.teamPlayers); + } else if (playerTeam == "spec") + { + success = AddPlayerToTeam(playerSteamId, playerName, matchConfig.Spectators); + } else + { + command.ReplyToCommand("Unknown team: must be one of team1, team2, spec"); + return; + } + if (!success) + { + command.ReplyToCommand($"Failed to add player {playerName} to {playerTeam}. They may already be on a team or you provided an invalid Steam ID."); + return; + } + command.ReplyToCommand($"Player {playerName} added to {playerTeam} successfully!"); + } + public void HandleCoachCommand(CCSPlayerController? player, string side) { if (player == null || !player.PlayerPawn.IsValid) return; if (isPractice) { @@ -125,5 +172,26 @@ public void HandleCoaches() coach.ActionTrackingServices!.MatchStats.Damage = 0; } } + + public bool AddPlayerToTeam(string steamId, string name, JToken? team) + { + if (matchzyTeam1.teamPlayers != null && matchzyTeam1.teamPlayers[steamId] != null) return false; + if (matchzyTeam2.teamPlayers != null && matchzyTeam2.teamPlayers[steamId] != null) return false; + if (matchConfig.Spectators != null && matchConfig.Spectators[steamId] != null) return false; + + if (team is JObject jObjectTeam) + { + jObjectTeam.Add(steamId, name); + LoadClientNames(); + return true; + } + else if (team is JArray jArrayTeam) + { + jArrayTeam.Add(name); + LoadClientNames(); + return true; + } + return false; + } } } diff --git a/Utility.cs b/Utility.cs index cf2dd03..7ee5e83 100644 --- a/Utility.cs +++ b/Utility.cs @@ -106,10 +106,19 @@ private void SendUnreadyPlayersMessage() { } if (unreadyPlayers.Count > 0) { string unreadyPlayerList = string.Join(", ", unreadyPlayers); - Server.PrintToChatAll($"{chatPrefix} Unready players: {unreadyPlayerList}. Please type .ready to ready up! [Minimum ready players required: {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}]"); + string minimumReadyRequiredMessage = isMatchSetup ? "" : $"[Minimum ready players required: {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}]"; + + Server.PrintToChatAll($"{chatPrefix} Unready players: {unreadyPlayerList}. Please type .ready to ready up! {minimumReadyRequiredMessage}"); } else { int countOfReadyPlayers = playerReadyStatus.Count(kv => kv.Value == true); - Server.PrintToChatAll($"{chatPrefix} Minimum ready players required {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}, current ready players: {ChatColors.Green}{countOfReadyPlayers}{ChatColors.Default}"); + if (isMatchSetup) + { + Server.PrintToChatAll($"{chatPrefix} Current ready players: {ChatColors.Green}{countOfReadyPlayers}{ChatColors.Default}"); + } + else + { + Server.PrintToChatAll($"{chatPrefix} Minimum ready players required {ChatColors.Green}{minimumReadyRequired}{ChatColors.Default}, current ready players: {ChatColors.Green}{countOfReadyPlayers}{ChatColors.Default}"); + } } } } @@ -144,6 +153,8 @@ private void ExecWarmupCfg() { } private void StartWarmup() { + unreadyPlayerMessageTimer?.Kill(); + unreadyPlayerMessageTimer = null; if (unreadyPlayerMessageTimer == null) { unreadyPlayerMessageTimer = AddTimer(chatTimerDelay, SendUnreadyPlayersMessage, TimerFlags.REPEAT); } @@ -241,6 +252,16 @@ private void StartLive() { } ExecuteChangedConvars(); }); + + var goingLiveEvent = new GoingLiveEvent + { + MatchId = liveMatchId.ToString(), + MapNumber = matchConfig.CurrentMapNumber, + }; + + Task.Run(async () => { + await SendEventAsync(goingLiveEvent); + }); } private void KillPhaseTimers() { @@ -295,6 +316,8 @@ private void ResetMatch(bool warmupCfgRequired = true) isMatchLive = false; liveMatchId = -1; isPractice = false; + isVeto = false; + isPreVeto = false; lastBackupFileName = ""; @@ -314,15 +337,12 @@ private void ResetMatch(bool warmupCfgRequired = true) }; // Reset stop data - Dictionary stopData = new() - { - { "ct", false }, - { "t", false } - }; + stopData["ct"] = false; + stopData["t"] = false; // Reset owned bots data pracUsedBots = new Dictionary>(); - Server.ExecuteCommand("mp_unpause_match"); + UnpauseMatch(); matchzyTeam1.teamName = "COUNTER-TERRORISTS"; matchzyTeam2.teamName = "TERRORISTS"; @@ -355,6 +375,8 @@ private void ResetMatch(bool warmupCfgRequired = true) StartWarmup(); } else { // Since we should be already in warmup phase by this point, we are juts setting up the SendUnreadyPlayersMessage timer + unreadyPlayerMessageTimer?.Kill(); + unreadyPlayerMessageTimer = null; if (unreadyPlayerMessageTimer == null) { unreadyPlayerMessageTimer = AddTimer(chatTimerDelay, SendUnreadyPlayersMessage, TimerFlags.REPEAT); } @@ -506,9 +528,15 @@ private void HandleReadyRequiredCommand(CCSPlayerController? player, string comm private void CheckLiveRequired() { if (!readyAvailable || matchStarted) return; + // Todo: Implement a same ready system for both pug and match int countOfReadyPlayers = playerReadyStatus.Count(kv => kv.Value == true); bool liveRequired = false; - if (minimumReadyRequired == 0) { + if (isMatchSetup) { + if (IsTeamsReady() && IsSpectatorsReady()) { + liveRequired = true; + } + } + else if (minimumReadyRequired == 0) { if (countOfReadyPlayers >= connectedPlayers && connectedPlayers > 0) { liveRequired = true; } @@ -561,9 +589,17 @@ private void HandleMatchStart() { liveMatchId = database.InitMatch(matchzyTeam1.teamName, matchzyTeam2.teamName, "-" , isMatchSetup, liveMatchId, matchConfig.CurrentMapNumber, seriesType); SetupRoundBackupFile(); StartDemoRecording(); - if (isKnifeRequired) { + + if (isPreVeto) + { + CreateVeto(); + } + else if (isKnifeRequired) + { StartKnifeRound(); - } else { + } + else + { StartLive(); } Server.PrintToChatAll($"{chatPrefix} {ChatColors.Green}MatchZy{ChatColors.Default} Plugin by {ChatColors.Green}WD-{ChatColors.Default}"); @@ -624,7 +660,17 @@ private void HandleMatchEnd() { string statsPath = Server.GameDirectory + "/csgo/MatchZy_Stats/" + liveMatchId.ToString(); + var mapResultEvent = new MapResultEvent + { + MatchId = liveMatchId.ToString(), + MapNumber = currentMapNumber, + Winner = new Winner(t1score > t2score && reverseTeamSides["CT"] == matchzyTeam1 ? "3" : "2", team1SeriesScore > team2SeriesScore ? "team1" : "team2"), + StatsTeam1 = new MatchZyStatsTeam(matchzyTeam1.id, matchzyTeam1.teamName, team1SeriesScore, t1score, 0, 0, new List()), + StatsTeam2 = new MatchZyStatsTeam(matchzyTeam2.id, matchzyTeam2.teamName, team2SeriesScore, t2score, 0, 0, new List()) + }; + Task.Run(async () => { + await SendEventAsync(mapResultEvent); await database.SetMapEndData(liveMatchId, currentMapNumber, winnerName, t1score, t2score, team1SeriesScore, team2SeriesScore); await database.WritePlayerStatsToCsv(statsPath, liveMatchId, currentMapNumber); }); @@ -798,12 +844,28 @@ private void HandlePostRoundEndEvent(EventRoundEnd @event) { ShowDamageInfo(); - Dictionary> playerStatsDictionary = GetPlayerStatsDict(); + (Dictionary> playerStatsDictionary, List playerStatsListTeam1, List playerStatsListTeam2) = GetPlayerStatsDict(); int currentMapNumber = matchConfig.CurrentMapNumber; long matchId = liveMatchId; + int ctTeamNum = reverseTeamSides["CT"] == matchzyTeam1 ? 1 : 2; + int tTeamNum = reverseTeamSides["TERRORIST"] == matchzyTeam1 ? 1 : 2; + Winner winner = new(@event.Winner == 3 ? ctTeamNum.ToString() : tTeamNum.ToString(), t1score > t2score ? "team1" : "team2" ); + + var roundEndEvent = new MatchZyRoundEndedEvent + { + MatchId = liveMatchId.ToString(), + MapNumber = matchConfig.CurrentMapNumber, + RoundNumber = t1score + t2score, + Reason = @event.Reason, + RoundTime = 0, + Winner = winner, + StatsTeam1 = new MatchZyStatsTeam(matchzyTeam1.id, matchzyTeam1.teamName, 0, t1score, 0, 0, playerStatsListTeam1), + StatsTeam2 = new MatchZyStatsTeam(matchzyTeam2.id, matchzyTeam2.teamName, 0, t2score, 0, 0, playerStatsListTeam2), + }; Task.Run(async () => { + await SendEventAsync(roundEndEvent); await database.UpdatePlayerStatsAsync(matchId, currentMapNumber, playerStatsDictionary); await database.UpdateMapStatsAsync(matchId, currentMapNumber, t1score, t2score); }); @@ -920,6 +982,7 @@ private void PauseMatch(CCSPlayerController? player, CommandInfo? command) { private void ForcePauseMatch(CCSPlayerController? player, CommandInfo? command) { + if (!matchStarted) return; if (!IsPlayerAdmin(player, "css_forcepause", "@css/config")) { SendPlayerNotAdminMessage(player); return; @@ -1079,7 +1142,9 @@ public void WriteClientNamesInFile(StringBuilder sb, JToken? players) foreach (JProperty player in players) { string steamId = player.Name; - string escapedName = player.Value.ToString().Replace("\"", "\\\""); + string escapedName = player.Value.ToString().Replace("\"", "\\\"").Trim(); + + if (string.IsNullOrEmpty(escapedName)) continue; sb.AppendLine($"\t\"{steamId}\"\t\t\"{escapedName}\""); } @@ -1157,8 +1222,8 @@ public void ExecuteChangedConvars() foreach (string key in matchConfig.ChangedCvars.Keys) { string value = matchConfig.ChangedCvars[key]; - Log($"[ExecuteChangedConvars] Execing: {key} {value}"); - Server.ExecuteCommand($"{key} {value}"); + Log($"[ExecuteChangedConvars] Execing: {key} \"{value}\""); + Server.ExecuteCommand($"{key} \"{value}\""); } } @@ -1167,7 +1232,7 @@ public void ResetChangedConvars() foreach (string key in matchConfig.OriginalCvars.Keys) { string value = matchConfig.OriginalCvars[key]; - Log($"[ResetChangedConvars] Execing: {key} {value}"); + Log($"[ResetChangedConvars] Execing: {key} \"{value}\""); Server.ExecuteCommand($"{key} {value}"); } } @@ -1212,13 +1277,15 @@ public bool IsTacticalTimeoutActive() return (gameRules.CTTimeOutActive || gameRules.TerroristTimeOutActive) && gameRules.FreezePeriod; } - public Dictionary> GetPlayerStatsDict() + public (Dictionary>, List, List) GetPlayerStatsDict() { Dictionary> playerStatsDictionary = new Dictionary>(); + List playerStatsListTeam1 = new(); + List playerStatsListTeam2 = new(); + var gameRules = Utilities.FindAllEntitiesByDesignerName("cs_gamerules").First().GameRules!; + int roundsPlayed = gameRules.TotalRoundsPlayed; try { - - foreach (int key in playerData.Keys) { CCSPlayerController player = playerData[key]; @@ -1274,6 +1341,63 @@ public Dictionary> GetPlayerStatsDict() stats["TeamName"] = teamName; playerStatsDictionary.Add(steamid64, stats); + + // Populate PlayerStats instance + // Todo: Implement stats which are marked as 0 for now + PlayerStats playerStatsInstance = new() + { + Kills = playerStats.Kills, + Deaths = playerStats.Deaths, + Assists = playerStats.Assists, + FlashAssists = 0, + TeamKills = 0, + Suicides = 0, + Damage = playerStats.Damage, + UtilityDamage = playerStats.UtilityDamage, + EnemiesFlashed = playerStats.EnemiesFlashed, + FriendliesFlashed = 0, + KnifeKills = 0, + HeadshotKills = playerStats.HeadShotKills, + RoundsPlayed = roundsPlayed, + BombDefuses = 0, + BombPlants = 0, + Kills1 = 0, + Kills2 = playerStats.Enemy2Ks, + Kills3 = playerStats.Enemy3Ks, + Kills4 = playerStats.Enemy4Ks, + Kills5 = playerStats.Enemy5Ks, + OneV1s = playerStats.I1v1Wins, + OneV2s = playerStats.I1v2Wins, + OneV3s = 0, + OneV4s = 0, + OneV5s = 0, + FirstKillsT = 0, + FirstKillsCT = 0, + FirstDeathsT = 0, + FirstDeathsCT = 0, + TradeKills = 0, + Kast = 0, + Score = player.Score, + Mvps = player.MVPs, + }; + + StatsPlayer statsPlayer = new() + { + SteamId = steamid64.ToString(), + Name = player.PlayerName, + Stats = playerStatsInstance + }; + + int ctTeamNum = reverseTeamSides["CT"] == matchzyTeam1 ? 1 : 2; + int tTeamNum = reverseTeamSides["TERRORIST"] == matchzyTeam1 ? 1 : 2; + + if (player.TeamNum == 3){ + if (ctTeamNum == 1) playerStatsListTeam1.Add(statsPlayer); + if (ctTeamNum == 2) playerStatsListTeam2.Add(statsPlayer); + } else if (player.TeamNum == 2 ) { + if (tTeamNum == 1) playerStatsListTeam1.Add(statsPlayer); + if (tTeamNum == 2) playerStatsListTeam2.Add(statsPlayer); + } } } catch (Exception e) @@ -1281,7 +1405,7 @@ public Dictionary> GetPlayerStatsDict() Log($"[GetPlayerStatsDict FATAL] An error occurred: {e.Message}"); } - return playerStatsDictionary; + return (playerStatsDictionary, playerStatsListTeam1, playerStatsListTeam2); } static string RemoveSpecialCharacters(string input) @@ -1311,5 +1435,36 @@ private void AutoStart() StartPracticeMode(); } } + + public int GetGameMode() { + var convar = ConVar.Find("game_mode"); + if (convar != null) { + return convar.GetPrimitiveValue(); + } + return -1; + } + + public int GetGameType() { + var convar = ConVar.Find("game_type"); + if (convar != null) { + return convar.GetPrimitiveValue(); + } + return -1; + } + + public void SetCorrectGameMode() { + ConVar.Find("game_mode")!.SetValue(matchConfig.Wingman ? 2 : 1); + ConVar.Find("game_type")!.SetValue(0); // Classic GameType + } + + public bool IsMapReloadRequiredForGameMode(bool wingman) + { + int expectedMode = wingman ? 2 : 1; + if (GetGameMode() != expectedMode || GetGameType() != 0) + { + return true; + } + return false; + } } } diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index abac24b..15ccb47 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -72,7 +72,8 @@ Again, inside `csgo/cfg/MatchZy`, a file named `config.cfg` should be present. T : Path of folder in which demos will be saved. If defined, it must not start with a slash and must end with a slash. Set to empty string to use the csgo root. Example: `matchzy_demo_path MatchZy/`
**`Default: MatchZy/`** ####`matchzy_demo_upload_url` -: If defined, recorded demo will be [uploaded](../gotv#automatic-upload) to this URL once the map ends.
**`Default: ""`** +: If defined, recorded demo will be [uploaded](../gotv#automatic-upload) to this URL once the map ends. Make sure that the URL is wrapped in double quotes (""). +Example: `matchzy_demo_upload_url "https://your-website.com/upload-endpoint"`
**`Default: ""`** ####`matchzy_kick_when_no_match_loaded` : Whether to kick all clients and prevent anyone from joining the server if no match is loaded. This means if server is in match mode, a match needs to be set-up using `matchzy_loadmatch`/`matchzy_loadmatch_url` to load and configure a match.
**`Default: false`** @@ -143,6 +144,6 @@ MySQL Database is useful for those who wants to use a common database across mul ### CSV Stats Once a match is over, data is pulled from the database and a CSV file is written in the folder: -`csgo/MatchZy_Stats`. This folder will contain CSV file for each match (file name pattern: `match_data_{matchid}.csv`) and it will have the same data which is present in `matchzy_stats_players`. +`csgo/MatchZy_Stats`. This folder will contain CSV file for each match (file name pattern: `match_data_map{mapNumber}_{matchId}.csv`) and it will have the same data which is present in `matchzy_stats_players`. There is a scope of improvement here, like having the match score in the CSV file or atleast in the file name patter. I'll make this change soon! diff --git a/documentation/docs/credits.md b/documentation/docs/credits.md index 2e28140..31ae99a 100644 --- a/documentation/docs/credits.md +++ b/documentation/docs/credits.md @@ -1,4 +1,5 @@ * [Get5](https://github.com/splewis/get5) - A lot of functionalities and workings have been referred from Get5 and they did an amazing job for managing matches in CS:GO. Huge thanks to them! +* [G5V](https://github.com/PhlexPlexico/G5V) and [G5API](https://github.com/PhlexPlexico/G5API) - Amazing work with the web panel for managing the servers! * [eBot](https://github.com/deStrO/eBot-CSGO) - Amazing job in CS:GO and then provided this great panel again in CS2 which is helping a lot of organizers now. Some logics have been referred from eBot as well! * [CounterStrikeSharp](https://github.com/roflmuffin/CounterStrikeSharp/) - Amazing job with development of CSSharp which gave us a platform to build our own plugins and also sparked my interest in plugin development! * [AlliedModders and community](https://alliedmods.net/) - They are the reason this whole plugin was possible! They are very helpful and inspire a lot! diff --git a/documentation/docs/database_stats.md b/documentation/docs/database_stats.md index 1aedbaf..f68ff8f 100644 --- a/documentation/docs/database_stats.md +++ b/documentation/docs/database_stats.md @@ -25,6 +25,6 @@ MySQL Database is useful for those who wants to use a common database across mul ### CSV Stats Once a match is over, data is pulled from the database and a CSV file is written in the folder: -`csgo/MatchZy_Stats`. This folder will contain CSV file for each match (file name pattern: `match_data_{matchid}.csv`) and it will have the same data which is present in `matchzy_stats_players`. +`csgo/MatchZy_Stats`. This folder will contain CSV file for each match (file name pattern: `match_data_map{mapNumber}_{matchId}.csv`) and it will have the same data which is present in `matchzy_stats_players`. There is a scope of improvement here, like having the match score in the CSV file or atleast in the file name patter. I'll make this change soon! diff --git a/documentation/docs/get5.md b/documentation/docs/get5.md new file mode 100644 index 0000000..5e2b9d6 --- /dev/null +++ b/documentation/docs/get5.md @@ -0,0 +1,154 @@ +## Get5 Panel + +MatchZy can work with Get5 Web panel ([G5V](https://github.com/PhlexPlexico/G5V) and [G5API](https://github.com/PhlexPlexico/G5API)) to setup and manage matches! + +### Features + +1. Create teams and setup matches from web panel +2. Support for BO1, BO3, BO5, etc with Veto and Knife Round +2. Get veto, scores and player stats live on the panel +3. Get demo uploaded automatically on the panel (which can be downloaded from its match page) +4. Pause and unpause game from the panel +5. Add players in a live game +6. And much more!!! + +### How to use Get5 Panel with MatchZy? + +It's pretty simple, just install Get5 panel, add your server in it and you will be able to create and manage matches just like Get5 CSGO :D + +### Installing Get5 Panel + +To use Get5 panel, [G5V](https://github.com/PhlexPlexico/G5V) and [G5API](https://github.com/PhlexPlexico/G5API) are required + +## Without Docker + +### Install G5V + +You can refer to the installation steps given here: https://github.com/PhlexPlexico/G5V/wiki/Installation + +### Install G5API + +You can refer to the installation steps given here: https://github.com/PhlexPlexico/G5API/wiki + + +## Using Docker + +docker-compose.yml file: + +```yml title="docker-compose.yml example" +version: "3.7" + +services: + redis: + image: redis:6 + command: redis-server --requirepass Z3fZeK9W6jBfMJY + container_name: redis + networks: + - get5 + restart: always + + get5db: + image: yobasystems/alpine-mariadb + container_name: get5db + restart: always + networks: + - get5 + environment: + - MYSQL_ROOT_PASSWORD=FJqXv2dd3TeFAn3 + - MYSQL_DATABASE=get5 + - MYSQL_USER=get5 + - MYSQL_PASSWORD=FJqXv2dd3TeFAn3 + - MYSQL_CHARSET=utf8mb4 + - MYSQL_COLLATION=utf8mb4_general_ci + ports: + - 3306:3306 + + caddy: + image: lucaslorentz/caddy-docker-proxy:ci-alpine + container_name: caddy-reverse-proxy + restart: unless-stopped + networks: + - get5 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 80:80 + - 443:443 + environment: + - CADDY_INGRESS_NETWORKS=get5 + + g5api: + image: ghcr.io/phlexplexico/g5api:latest + depends_on: + - get5db + container_name: G5API + networks: + - get5 + labels: + caddy: your-domain.com + caddy.handle_path: /api/* + caddy.handle_path.0_reverse_proxy: "{{upstreams 3301}}" + volumes: + - ./public:/Get5API/public + environment: + - NODE_ENV=production + - PORT=3301 + - DBKEY=0fc9c89ce985fa8066398b1be5c730f7 #CHANGME https://www.random.org/cgi-bin/randbyte?nbytes=16&format=h + - STEAMAPIKEY=FE315E4DAA500737EC827E9A77018971 + - HOSTNAME=https://your-domain.com + - SHAREDSECRET= Z3TLmUEVpvXdE5H7UdnEbNSySak9gj + - CLIENTHOME=https://your-domain.com + - APIURL=https://your-domain.com/api + - SQLUSER=get5 + - SQLPASSWORD=FJqXv2dd3TeFAn3 + - SQLPORT=3306 + - DATABASE=get5 + - SQLHOST=get5db + - ADMINS=76561198154367261 + - SUPERADMINS=76561198154367261 + - REDISURL=redis://:Z3fZeK9W6jBfMJY@redis:6379 + - REDISTTL=86400 + - USEREDIS=true + - UPLOADDEMOS=true + - LOCALLOGINS=false + restart: always + + g5v: + image: ghcr.io/phlexplexico/g5v:latest + depends_on: + - g5api + container_name: G5V-Front-End + networks: + - get5 + restart: always + labels: + caddy: your-domain.com + caddy.reverse_proxy: "{{upstreams}}" + +networks: + get5: + external: true +``` + +In this file, following changes will be needed: + +1. Change `your-domain.com` to your DNS or Domain +2. Change MySQL and Redis password if needed +3. Add `ADMINS` and `SUPERADMINS` as per your need (Steam64ID, comma sepearated if you want to add multiple admins) + +Commands to run to download and install this yml file: + +``` +sudo apt-get update +apt install docker.io +apt install docker-compose + +docker network create -d bridge get5 +docker-compose -f /path/to/your/docker-compose-file.yml up -d +``` + +## Current Limitations with Get5 Integration + +1. Stats like KAST, Teammates Flashed, Flashbang Assists, Knife Kills, Bomb plants and defuses are missing and will be shown as 0 +2. Coaches cannot be added from the panel (player can type `.coach ` to start coaching) +3. Backups cannot be listed and restored from the panel (ingame commands for restoring like `.stop` and `.restore ` will work as expected) diff --git a/documentation/docs/gotv.md b/documentation/docs/gotv.md index 2a5bda3..0e029a2 100644 --- a/documentation/docs/gotv.md +++ b/documentation/docs/gotv.md @@ -28,6 +28,8 @@ In addition to recording demos, MatchZy can also upload them to a URL when the r `matchzy_demo_upload_url `. The HTTP body will be the zipped demo file, and you can read the [headers](#headers) for file metadata. +Example: `matchzy_demo_upload_url "https://your-website.com/upload-endpoint"` + ### Headers MatchZy will add these HTTP headers to its demo upload request: diff --git a/documentation/docs/index.md b/documentation/docs/index.md index e157798..aa7f3e2 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -12,6 +12,8 @@ MatchZy can solve a lot of match management requirements. It provides basic comm **Feature Highligts:** * Pug mode with simple commands to manage! +* Support of [Get5 Panel!](./get5.md) +* Support BO1/BO3/BO5 and Veto when using Match configuration or Get5 Panel! * [Setting up matches](./match_setup) and locking players into their team * Practice Mode with `.bot`, `.spawn`, `.ctspawn`, `.tspawn`, `.nobots`, `.clear`, `.exitprac` and many more commands! * Knife round (With expected logic, i.e., team with most players win. If same number of players, then team with HP advantage wins. If same HP, winner is decided randomly) diff --git a/documentation/docs/match_setup.md b/documentation/docs/match_setup.md index 7e82e7b..2ba4803 100644 --- a/documentation/docs/match_setup.md +++ b/documentation/docs/match_setup.md @@ -9,13 +9,13 @@ In this documentation, we'll see how we can setup a match in MatchZy using a JSO There are 2 commands available which can be used to load a match: 1. `matchzy_loadmatch `: Loads a JSON match configuration file relative to the `csgo` directory. -2. `matchzy_loadmatch_url `: Loads a remote (JSON-formatted) match configuration by sending an HTTP(S) `GET` to the given URL. Make sure to put the url argument inside quotation marks (`""`). +2. `matchzy_loadmatch_url [header name] [header value]`: Loads a remote (JSON-formatted) match configuration by sending an HTTP(S) `GET` to the given URL. You may optionally provide an HTTP header and value pair using the `header name` and `header value` arguments. You should put all arguments inside quotation marks (`""`). (`""`). ## Example !!! tip "Example only" - Required fields: `"maplist"`, `"team1"`, `"team2"`, `"num_maps"`, and `"map_sides"`. If `"matchid"` is left empty, it will be auto-generated by the server. + Required fields: `"maplist"`, `"team1"`, `"team2"` and `"num_maps"``. If `"matchid"` is left empty, it will be auto-generated by the server. ```json title="csgo/astralis_vs_navi_27.json" { @@ -73,8 +73,7 @@ This file can be loaded using : ## Current Limitations? -1. Veto is currently not available in MatchZy hence maps cannot be chosen using veto. -2. Coaches cannot be added directly via this match configuration, hence to add a coach, add them to `"players"` key of the team and then use `.coach ` command in the server to start coaching. -3. Only Steam64id is supported currently. +1. Coaches cannot be added directly via this match configuration, hence to add a coach, add them to `"players"` key of the team and then use `.coach ` command in the server to start coaching. +2. Only Steam64id is supported currently. These limitations will be resolved ASAP in the next updates! :D diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 3fabd87..9dc2492 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -1,4 +1,7 @@ site_name: MatchZy +site_url: https://shobhit-pathak.github.io/MatchZy/ +repo_url: https://github.com/shobhit-pathak/MatchZy +repo_name: shobhit-pathak/MatchZy theme: name: material features: @@ -27,6 +30,7 @@ nav: - Setup: - Installation: installation.md - Configuration: configuration.md + - Get5 Panel: get5.md - Basics: - Getting Started: getting_started.md - Match Setup: match_setup.md