From 13890b8018f30cc3230a24abffc00f1528ff6e0d Mon Sep 17 00:00:00 2001 From: crashzk Date: Wed, 6 Dec 2023 16:13:59 -0300 Subject: [PATCH 01/14] update: Change !knife command to !roundknife & !rk --- ConsoleCommands.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ConsoleCommands.cs b/ConsoleCommands.cs index dd1cb5a..fa7cf23 100644 --- a/ConsoleCommands.cs +++ b/ConsoleCommands.cs @@ -185,7 +185,8 @@ public void OnTacCommand(CCSPlayerController? player, CommandInfo? command) { } } - [ConsoleCommand("css_knife", "Toggles knife round for the match")] + [ConsoleCommand("css_roundknife", "Toggles knife round for the match")] + [ConsoleCommand("css_rk", "Toggles knife round for the match")] public void OnKifeCommand(CCSPlayerController? player, CommandInfo? command) { if (IsPlayerAdmin(player, "css_knife", "@css/config")) { isKnifeRequired = !isKnifeRequired; From 6ae653b0ea1e15cb8e61d8d97dcf8e07ddea9d36 Mon Sep 17 00:00:00 2001 From: crashzk Date: Wed, 6 Dec 2023 16:19:38 -0300 Subject: [PATCH 02/14] docs: Updating Knife command --- documentation/docs/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index aa163c3..37ed79a 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -35,7 +35,7 @@ Most of the commands can also be used using ! prefix instead of . (like !ready) - `.forcepause` Pauses the match as an admin (Players cannot unpause the admin-paused match). (`.fp` for shorter command) - `.forceunpause` Force unpauses the match. (`.fup` for shorter command) - `.restore ` Restores the backup of provided round number. -- `.knife` Toggles the knife round. If disabled, match will directly go from Warmup phase to Live phase. +- `.roundknife` Toggles the knife round. If disabled, match will directly go from Warmup phase to Live phase. (`.rk` for shorter command) - `.playout` Toggles playout (If playout is enabled, all rounds would be played irrespective of winner. Useful in scrims!) - `.whitelist` Toggles whitelisting of players. To whitelist a player, add the steam64id in `cfg/MatchZy/whitelist.cfg` - `.readyrequired ` Sets the number of ready players required to start the match. If set to 0, all connected players will have to ready-up to start the match. From 38dc5b85bd07b204b8010879647827d9ac92b034 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak <140690706+shobhit-pathak@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:44:45 +0530 Subject: [PATCH 03/14] Revert "update: Change !knife command to !roundknife & !rk" --- ConsoleCommands.cs | 3 +-- documentation/docs/commands.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ConsoleCommands.cs b/ConsoleCommands.cs index fa7cf23..dd1cb5a 100644 --- a/ConsoleCommands.cs +++ b/ConsoleCommands.cs @@ -185,8 +185,7 @@ public void OnTacCommand(CCSPlayerController? player, CommandInfo? command) { } } - [ConsoleCommand("css_roundknife", "Toggles knife round for the match")] - [ConsoleCommand("css_rk", "Toggles knife round for the match")] + [ConsoleCommand("css_knife", "Toggles knife round for the match")] public void OnKifeCommand(CCSPlayerController? player, CommandInfo? command) { if (IsPlayerAdmin(player, "css_knife", "@css/config")) { isKnifeRequired = !isKnifeRequired; diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index 37ed79a..aa163c3 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -35,7 +35,7 @@ Most of the commands can also be used using ! prefix instead of . (like !ready) - `.forcepause` Pauses the match as an admin (Players cannot unpause the admin-paused match). (`.fp` for shorter command) - `.forceunpause` Force unpauses the match. (`.fup` for shorter command) - `.restore ` Restores the backup of provided round number. -- `.roundknife` Toggles the knife round. If disabled, match will directly go from Warmup phase to Live phase. (`.rk` for shorter command) +- `.knife` Toggles the knife round. If disabled, match will directly go from Warmup phase to Live phase. - `.playout` Toggles playout (If playout is enabled, all rounds would be played irrespective of winner. Useful in scrims!) - `.whitelist` Toggles whitelisting of players. To whitelist a player, add the steam64id in `cfg/MatchZy/whitelist.cfg` - `.readyrequired ` Sets the number of ready players required to start the match. If set to 0, all connected players will have to ready-up to start the match. From d6f7d47998d01a739e22618f7016b1d73ada870f Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Fri, 8 Dec 2023 23:22:19 +0530 Subject: [PATCH 04/14] 0.5.1 | feat: prac improvements and timeout fixes --- PracticeMode.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/PracticeMode.cs b/PracticeMode.cs index a17e84f..1f84564 100644 --- a/PracticeMode.cs +++ b/PracticeMode.cs @@ -946,6 +946,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; From 94a5d2ff83195394b0f530d1a1e1605cb3f2320e Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Sat, 9 Dec 2023 18:25:39 +0530 Subject: [PATCH 05/14] fix: docs updated --- documentation/docs/configuration.md | 5 +++-- documentation/docs/database_stats.md | 2 +- documentation/docs/gotv.md | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) 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/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/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: From 696b29eee87aebb42dcfaa5f0f96755bbd652e01 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 17:16:17 +0530 Subject: [PATCH 06/14] v0.6.0-alpha | feat: Get5 support --- ConfigConvars.cs | 1 + ConsoleCommands.cs | 58 +++- DemoManagement.cs | 96 ++++--- Events.cs | 206 +++++++++++++++ G5API.cs | 43 +++ MapVeto.cs | 642 +++++++++++++++++++++++++++++++++++++++++++++ MatchConfig.cs | 12 + MatchData.cs | 174 ++++++++++++ MatchManagement.cs | 216 ++++++++++++--- MatchZy.cs | 17 +- PracticeMode.cs | 12 +- PublishEvents.cs | 46 ++++ ReadySystem.cs | 82 ++++++ RemoteLogConfig.cs | 45 ++++ SleepMode.cs | 11 - Teams.cs | 68 +++++ Utility.cs | 195 ++++++++++++-- 17 files changed, 1810 insertions(+), 114 deletions(-) create mode 100644 Events.cs create mode 100644 G5API.cs create mode 100644 MapVeto.cs create mode 100644 MatchData.cs create mode 100644 PublishEvents.cs create mode 100644 ReadySystem.cs create mode 100644 RemoteLogConfig.cs 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..b5fdcc0 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,19 @@ 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 () => { + 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 1f84564..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); } 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/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; + } } } From b6da568013ee6e259e805fb1d0d1790f7ffdaac7 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 21:36:47 +0530 Subject: [PATCH 07/14] v0.6.0-alpha | docs update --- documentation/docs/get5.md | 154 ++++++++++++++++++++++++++++++ documentation/docs/index.md | 1 + documentation/docs/match_setup.md | 2 +- documentation/mkdocs.yml | 1 + 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 documentation/docs/get5.md diff --git a/documentation/docs/get5.md b/documentation/docs/get5.md new file mode 100644 index 0000000..dce46d5 --- /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 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/index.md b/documentation/docs/index.md index e157798..0651809 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -12,6 +12,7 @@ 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) * [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..7aacab4 100644 --- a/documentation/docs/match_setup.md +++ b/documentation/docs/match_setup.md @@ -9,7 +9,7 @@ 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 diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 3fabd87..c510e91 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -27,6 +27,7 @@ nav: - Setup: - Installation: installation.md - Configuration: configuration.md + - Get5 Panel: get5.md - Basics: - Getting Started: getting_started.md - Match Setup: match_setup.md From 647ddc8bca258028abc07d8fb2a35d2762e67f07 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 21:37:20 +0530 Subject: [PATCH 08/14] v0.6.0-alpha | docs update --- documentation/docs/get5.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/get5.md b/documentation/docs/get5.md index dce46d5..5e2b9d6 100644 --- a/documentation/docs/get5.md +++ b/documentation/docs/get5.md @@ -6,7 +6,7 @@ MatchZy can work with Get5 Web panel ([G5V](https://github.com/PhlexPlexico/G5V) 1. Create teams and setup matches from web panel 2. Support for BO1, BO3, BO5, etc with Veto and Knife Round -2. Get scores and player stats live on the panel +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 From dd05f7e5df0d846c30024d651ff44057f89ffc4c Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 21:46:02 +0530 Subject: [PATCH 09/14] v0.6.0-alpha | docs update --- README.md | 1 + documentation/docs/credits.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 30e5f3b..a081430 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,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/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! From eda3853254775415263a4851f03f95e64b0f8676 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 22:33:03 +0530 Subject: [PATCH 10/14] v0.6.0-alpha | docs update --- documentation/mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index c510e91..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: From edbd1ea7cc039df6d3fffe54071630b7233ca482 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Thu, 14 Dec 2023 23:42:54 +0530 Subject: [PATCH 11/14] v0.6.0-alpha | fix: delay on seriesResultEvent --- MatchManagement.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MatchManagement.cs b/MatchManagement.cs index b5fdcc0..ba671a2 100644 --- a/MatchManagement.cs +++ b/MatchManagement.cs @@ -551,6 +551,8 @@ public void EndSeries(string winnerName, int restartDelay) 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); From 2ca66dbdcbce9c50d7ebf5d94155f7b360c0e06e Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Sat, 16 Dec 2023 12:46:32 +0530 Subject: [PATCH 12/14] v0.6.0-alpha | docs update --- README.md | 20 ++++++++++++++++++++ documentation/docs/index.md | 1 + documentation/docs/match_setup.md | 5 ++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a081430..30a4275 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!](./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) +* 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/) diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 0651809..aa7f3e2 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -13,6 +13,7 @@ MatchZy can solve a lot of match management requirements. It provides basic comm * 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 7aacab4..4f276bf 100644 --- a/documentation/docs/match_setup.md +++ b/documentation/docs/match_setup.md @@ -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 From 8e0e1a853bf890ee97ffd65e6c0fb7f4dcbdfc25 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Sat, 16 Dec 2023 12:48:56 +0530 Subject: [PATCH 13/14] Readme update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 30a4275..dd8c42d 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ MatchZy is a plugin for CS2 (Counter Strike 2) for running and managing practice ## Feature Highligts: * Pug mode with simple commands to manage! -* Support of [Get5 Panel!](./get5.md) +* 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](./match_setup) and locking players into their team +* [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) From 9cb97c6fc59ac74b7b990864c182fe0aa43376a8 Mon Sep 17 00:00:00 2001 From: Shobhit Pathak Date: Sat, 16 Dec 2023 12:51:30 +0530 Subject: [PATCH 14/14] v0.6.0-alpha | docs update --- documentation/docs/match_setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/match_setup.md b/documentation/docs/match_setup.md index 4f276bf..2ba4803 100644 --- a/documentation/docs/match_setup.md +++ b/documentation/docs/match_setup.md @@ -15,7 +15,7 @@ There are 2 commands available which can be used to load a match: !!! 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" {