From 15b85f067e0be7a1be1ba71af4e02bdfd09880ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Nov 2022 11:16:48 +0000 Subject: [PATCH 01/27] Bump ncipollo/release-action from 1.11.1 to 1.11.2 Bumps [ncipollo/release-action](https://github.com/ncipollo/release-action) from 1.11.1 to 1.11.2. - [Release notes](https://github.com/ncipollo/release-action/releases) - [Commits](https://github.com/ncipollo/release-action/compare/v1.11.1...v1.11.2) --- updated-dependencies: - dependency-name: ncipollo/release-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 200cc19f6..a660c402f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: myToken: ${{ secrets.GITHUB_TOKEN }} - name: Tag And Attach Nightly Build - uses: ncipollo/release-action@v1.11.1 + uses: ncipollo/release-action@v1.11.2 with: token: "${{ secrets.GITHUB_TOKEN }}" artifacts: "artifacts/${{ needs.build.outputs.filename }}.zip,artifacts/${{ needs.build.outputs.filename }}.tar.gz" @@ -124,7 +124,7 @@ jobs: path: artifacts - name: Tag And Draft Release - uses: ncipollo/release-action@v1.11.1 + uses: ncipollo/release-action@v1.11.2 with: token: "${{ secrets.GITHUB_TOKEN }}" artifacts: "artifacts/${{ needs.build.outputs.filename }}.zip,artifacts/${{ needs.build.outputs.filename }}.tar.gz" From 0f4d6480e01c94ec5998970628fa938903cc1e3b Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 16 Nov 2022 03:06:28 +0100 Subject: [PATCH 02/27] Fix hostname restore/mapchange problems Add TEAM_SCORE substitute tags Fix mp_teamscore_max not being reset if going from boX to bo1 Remove redundant calls in Team cvar logic Also reset mp_teamscore_1/2 cvars bump version --- documentation/docs/configuration.md | 20 ++++++++++++++++---- scripting/get5.sp | 22 +++++++++++++++++----- scripting/get5/matchconfig.sp | 29 +++++++++++++++++++---------- scripting/get5/util.sp | 5 +---- scripting/get5/version.sp | 2 +- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index d47710fb3..2f660e20a 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -313,9 +313,9 @@ exist.
**`Default: ""`** disable.
**`Default: "get5_matchstats_{MATCHID}.cfg"`** ####`get5_hostname_format` -: The hostname to apply to the server when a match configuration is loaded. -[State substitutes](#state-substitutes) can be used. Leave blank to disable changing the hostname.
-**`Default: "Get5: {TEAM1} vs {TEAM2}"`** +: The hostname to apply to the server. [State substitutes](#state-substitutes) can be used. Set to an empty string to +disable changing the hostname. This is updated on every round start to allow for the use of team score +substitutes.
**`Default: "Get5: {TEAM1} vs {TEAM2}"`** ####`get5_message_prefix` : The tag applied before plugin messages. Note that at least one character must come before @@ -384,9 +384,15 @@ must **end with a slash**. Required folders will be created if they do not exist ####`get5_demo_name_format` : Format to use for demo files when [recording matches](../gotv#demos). Do not include a file extension (`.dem` is added automatically). If you do not include the [`{TIME}`](#tag-time) tag, you will have problems with duplicate files -if restoring a game from a backup. Note that the [`{MAPNUMBER}`](#tag-mapnumber)variable is not zero-indexed. Set to +if restoring a game from a backup. Note that the [`{MAPNUMBER}`](#tag-mapnumber) variable is not zero-indexed. Set to empty string to disable recording demos.
**`Default: "{TIME}_{MATCHID}_map{MAPNUMBER}_{MAPNAME}"`** +!!! info "Team score is always zero" + + While it may be tempting to use the [`{TEAM1_SCORE}`](#tag-team1-score) and [`{TEAM2_SCORE}`](#tag-team2-score) + variables in the demo name; note that this file is created as the match begins, so the score will always be zero at + that stage. + ## Events ####`get5_remote_log_url` @@ -433,6 +439,12 @@ placeholder strings that will be replaced by meaningful values when printed. Not ####`{TEAM2}` {: #tag-team2} : The name of `team2`. +####`{TEAM1_SCORE}` {: #tag-team1-score } +: The score of `team1` on the current map. + +####`{TEAM2_SCORE}` {: #tag-team2-score } +: The score of `team2` on the current map. + ####`{MATCHTITLE}` {: #tag-matchtitle} : The title of the current match. diff --git a/scripting/get5.sp b/scripting/get5.sp index aefcd24ba..87f842e51 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -149,6 +149,7 @@ char g_TeamMatchTexts[MATCHTEAM_COUNT][MAX_CVAR_LENGTH]; char g_MatchTitle[MAX_CVAR_LENGTH]; int g_FavoredTeamPercentage = 0; char g_FavoredTeamText[MAX_CVAR_LENGTH]; +char g_HostnamePreGet5[MAX_CVAR_LENGTH]; int g_PlayersPerTeam = 5; int g_CoachesPerTeam = 2; int g_MinPlayersToReady = 1; @@ -1339,6 +1340,7 @@ void EndSeries(Get5Team winningTeam, bool printWinnerMessage, float restoreDelay if (restoreDelay < 0.1) { // When force-ending the match there is no delay. RestoreCvars(g_MatchConfigChangedCvars); + ResetHostname(); } else { // If we restore cvars immediately, it might change the tv_ params or set the // mp_match_restart_delay to something lower, which is noticed by the game and may trigger a map @@ -1400,6 +1402,7 @@ static Action Timer_RestoreMatchCvars(Handle timer) { // Only reset if no game is running, otherwise a game started before the restart delay for // another ends will mess this up. RestoreCvars(g_MatchConfigChangedCvars); + ResetHostname(); } return Plugin_Handled; } @@ -1473,6 +1476,9 @@ static Action Event_RoundStart(Event event, const char[] name, bool dontBroadcas return; } + // Update server hostname as it may contain team score variables. + UpdateHostname(); + // We cannot do this during warmup, as sending users into warmup post-knife triggers a round start // event. if (!InWarmup()) { @@ -1807,7 +1813,7 @@ static Get5StatusTeam GetTeamInfo(Get5Team team) { view_as(side), GetNumHumansOnTeam(side)); } -bool FormatCvarString(ConVar cvar, char[] buffer, int len) { +bool FormatCvarString(ConVar cvar, char[] buffer, int len, bool safeTeamNames = true) { cvar.GetString(buffer, len); if (StrEqual(buffer, "")) { return false; @@ -1827,14 +1833,15 @@ bool FormatCvarString(ConVar cvar, char[] buffer, int len) { FormatTime(formattedTime, sizeof(formattedTime), timeFormat, timeStamp); FormatTime(formattedDate, sizeof(formattedDate), dateFormat, timeStamp); - // Get team names with spaces removed. char team1Str[MAX_CVAR_LENGTH]; strcopy(team1Str, sizeof(team1Str), g_TeamNames[Get5Team_1]); - ReplaceString(team1Str, sizeof(team1Str), " ", "_"); - char team2Str[MAX_CVAR_LENGTH]; strcopy(team2Str, sizeof(team2Str), g_TeamNames[Get5Team_2]); - ReplaceString(team2Str, sizeof(team2Str), " ", "_"); + if (safeTeamNames) { + // Get team names with spaces removed. + ReplaceString(team1Str, sizeof(team1Str), " ", "_"); + ReplaceString(team2Str, sizeof(team2Str), " ", "_"); + } // MATCHTITLE must go first as it can contain other placeholders ReplaceString(buffer, len, "{MATCHTITLE}", g_MatchTitle); @@ -1848,6 +1855,11 @@ bool FormatCvarString(ConVar cvar, char[] buffer, int len) { ReplaceString(buffer, len, "{TEAM1}", team1Str); ReplaceString(buffer, len, "{TEAM2}", team2Str); + Get5Side team1Side = view_as(Get5_Get5TeamToCSTeam(Get5Team_1)); + Get5Side team2Side = team1Side == Get5Side_CT ? Get5Side_T : Get5Side_CT; + ReplaceStringWithInt(buffer, len, "{TEAM1_SCORE}", team1Side != Get5Side_None ? CS_GetTeamScore(view_as(team1Side)) : 0); + ReplaceStringWithInt(buffer, len, "{TEAM2_SCORE}", team2Side != Get5Side_None ? CS_GetTeamScore(view_as(team2Side)) : 0); + return true; } diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 91939de36..42f7b4a0b 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -62,6 +62,11 @@ bool LoadMatchConfig(const char[] config, bool restoreBackup = false) { CloseCvarStorage(g_KnifeChangedCvars); CloseCvarStorage(g_MatchConfigChangedCvars); + if (!restoreBackup) { + // Loading a backup should not override this, as the original hostname pre-Get5 will then be lost. + GetConVarStringSafe("hostname", g_HostnamePreGet5, sizeof(g_HostnamePreGet5)); + } + if (!LoadMatchFile(config)) { return false; } @@ -129,6 +134,7 @@ bool LoadMatchConfig(const char[] config, bool restoreBackup = false) { LoadPlayerNames(); AddTeamLogosToDownloadTable(); SetStartingTeams(); + UpdateHostname(); // Set mp_backup_round_file to prevent backup file collisions ServerCommand("mp_backup_round_file backup_%d", Get5_GetServerID()); @@ -794,16 +800,7 @@ void SetMatchTeamCvars() { } else { SetConVarIntSafe("mp_teamprediction_pct", 100 - g_FavoredTeamPercentage); } - - if (g_MapsToWin > 1) { - SetConVarIntSafe("mp_teamscore_max", g_MapsToWin); - } - - char formattedHostname[128]; - - if (FormatCvarString(g_SetHostnameCvar, formattedHostname, sizeof(formattedHostname))) { - SetConVarStringSafe("hostname", formattedHostname); - } + SetConVarIntSafe("mp_teamscore_max", g_MapsToWin > 1 ? g_MapsToWin : 0); } static void ExecuteMatchConfigCvars() { @@ -1369,3 +1366,15 @@ static Action Timer_ExecMatchConfig(Handle timer) { SetMatchTeamCvars(); return Plugin_Handled; } + +void ResetHostname() { + SetConVarStringSafe("hostname", g_HostnamePreGet5); + g_HostnamePreGet5 = ""; +} + +void UpdateHostname() { + char formattedHostname[128]; + if (FormatCvarString(g_SetHostnameCvar, formattedHostname, sizeof(formattedHostname), false)) { + SetConVarStringSafe("hostname", formattedHostname); + } +} diff --git a/scripting/get5/util.sp b/scripting/get5/util.sp index 038e99a62..d6c6a8e36 100644 --- a/scripting/get5/util.sp +++ b/scripting/get5/util.sp @@ -235,10 +235,7 @@ stock void SetTeamInfo(int csTeam, const char[] name, const char[] flag = "", co SetConVarStringSafe(flagCvarName, flag); SetConVarStringSafe(logoCvarName, logo); SetConVarStringSafe(textCvarName, matchstat); - - if (g_MapsToWin > 1) { - SetConVarIntSafe(scoreCvarName, series_score); - } + SetConVarIntSafe(scoreCvarName, g_MapsToWin > 1 ? series_score : 0); } stock void SetConVarIntSafe(const char[] name, int value) { diff --git a/scripting/get5/version.sp b/scripting/get5/version.sp index 4b24d5e36..621ef9107 100644 --- a/scripting/get5/version.sp +++ b/scripting/get5/version.sp @@ -1,6 +1,6 @@ #tryinclude "manual_version.sp" #if !defined PLUGIN_VERSION -#define PLUGIN_VERSION "0.11.0-dev" +#define PLUGIN_VERSION "0.12.0-dev" #endif // This MUST be the latest version in x.y.z semver format followed by -dev. From 523328835a11ca8e5b3a01a2ab75c45238ce8a5b Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 16 Nov 2022 20:30:26 +0100 Subject: [PATCH 03/27] Prevent cvar reset from resetting demo upload parameters --- scripting/get5/recording.sp | 89 +++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 29 deletions(-) diff --git a/scripting/get5/recording.sp b/scripting/get5/recording.sp index 8d9e6b5bb..46913daea 100644 --- a/scripting/get5/recording.sp +++ b/scripting/get5/recording.sp @@ -37,50 +37,69 @@ bool StartRecording() { } void StopRecording(float delay = 0.0) { + char uploadUrl[1024]; + g_DemoUploadURLCvar.GetString(uploadUrl, sizeof(uploadUrl)); + char uploadUrlHeaderKey[1024]; + g_DemoUploadHeaderKeyCvar.GetString(uploadUrlHeaderKey, sizeof(uploadUrlHeaderKey)); + char uploadUrlHeaderValue[1024]; + g_DemoUploadHeaderValueCvar.GetString(uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue)); + DataPack pack = GetDemoInfoDataPack(g_MatchID, g_MapNumber, g_DemoFileName, uploadUrl, uploadUrlHeaderKey, + uploadUrlHeaderValue, g_DemoUploadDeleteAfterCvar.BoolValue); if (delay < 0.1) { LogDebug("Stopping GOTV recording immediately."); - StopRecordingCallback(g_MatchID, g_MapNumber, g_DemoFileName); + StopRecordingCallback(pack); } else { LogDebug("Starting timer that will end GOTV recording in %f seconds.", delay); - CreateTimer(delay, Timer_StopGoTVRecording, GetDemoInfoDataPack(g_MatchID, g_MapNumber, g_DemoFileName)); + CreateTimer(delay, Timer_StopGoTVRecording, pack); } g_DemoFileName = ""; } -static void StopRecordingCallback(const char[] matchId, const int mapNumber, const char[] demoFileName) { +static void StopRecordingCallback(DataPack pack) { ServerCommand("tv_stoprecord"); + char demoFileName[PLATFORM_MAX_PATH]; + pack.Position = view_as(2); // demo file name is at index 2, see GetDemoInfoDataPack(). + pack.ReadString(demoFileName, sizeof(demoFileName)); if (StrEqual("", demoFileName)) { LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished()"); + delete pack; return; } // We delay this by 15 seconds to allow the server to flush to the file before firing the event. // For some servers, this take a pretty long time (up to 8-9 seconds, so 15 for grace). - CreateTimer(15.0, Timer_FireStopRecordingEvent, GetDemoInfoDataPack(matchId, mapNumber, demoFileName)); + CreateTimer(15.0, Timer_FireStopRecordingEvent, pack); } -static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, const char[] demoFileName) { +static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, const char[] demoFileName, + const char[] uploadUrl, const char[] uploadHeaderKey, + const char[] uploadHeaderValue, const bool deleteAfterUpload) { DataPack pack = CreateDataPack(); pack.WriteString(matchId); pack.WriteCell(mapNumber); pack.WriteString(demoFileName); + pack.WriteString(uploadUrl); + pack.WriteString(uploadHeaderKey); + pack.WriteString(uploadHeaderValue); + pack.WriteCell(deleteAfterUpload); return pack; } -static void ReadDemoDataPack(DataPack pack, char[] matchId, const int matchIdLength, int &mapNumber, - char[] demoFileName, const int demoFileNameLength) { +static void ReadDemoDataPack(DataPack pack, char[] matchId, const int matchIdLength, int &mapNumber, char[] uploadUrl, + const int uploadUrlLength, char[] uploadHeaderKey, const int uploadHeaderKeyLength, + char[] uploadeHeaderValue, const int uploadHeaderValueLength, char[] demoFileName, + const int demoFileNameLength, bool &deleteAfterUpload) { pack.Reset(); pack.ReadString(matchId, matchIdLength); mapNumber = pack.ReadCell(); pack.ReadString(demoFileName, demoFileNameLength); - delete pack; + pack.ReadString(uploadUrl, uploadUrlLength); + pack.ReadString(uploadHeaderKey, uploadHeaderKeyLength); + pack.ReadString(uploadeHeaderValue, uploadHeaderValueLength); + deleteAfterUpload = pack.ReadCell(); } static Action Timer_StopGoTVRecording(Handle timer, DataPack pack) { - char matchId[MATCH_ID_LENGTH]; - char demoFileName[PLATFORM_MAX_PATH]; - int mapNumber; - ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, demoFileName, sizeof(demoFileName)); - StopRecordingCallback(matchId, mapNumber, demoFileName); + StopRecordingCallback(pack); return Plugin_Handled; } @@ -88,7 +107,14 @@ static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { char matchId[MATCH_ID_LENGTH]; char demoFileName[PLATFORM_MAX_PATH]; int mapNumber; - ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, demoFileName, sizeof(demoFileName)); + char uploadUrl[1024]; + char uploadUrlHeaderKey[1024]; + char uploadUrlHeaderValue[1024]; + bool deleteAfterUpload; + ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, + sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, + sizeof(demoFileName), deleteAfterUpload); + delete pack; Get5DemoFinishedEvent event = new Get5DemoFinishedEvent(matchId, mapNumber, demoFileName); LogDebug("Calling Get5_OnDemoFinished()"); @@ -97,18 +123,13 @@ static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { Call_Finish(); EventLogger_LogAndDeleteEvent(event); - UploadDemoToServer(demoFileName, matchId, mapNumber); + UploadDemoToServer(demoFileName, matchId, mapNumber, uploadUrl, uploadUrlHeaderKey, uploadUrlHeaderValue, + deleteAfterUpload); return Plugin_Handled; } -static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, int mapNumber) { - char demoUrl[1024]; - char demoHeaderKey[1024]; - char demoHeaderValue[1024]; - - g_DemoUploadURLCvar.GetString(demoUrl, sizeof(demoUrl)); - g_DemoUploadHeaderKeyCvar.GetString(demoHeaderKey, sizeof(demoHeaderKey)); - g_DemoUploadHeaderValueCvar.GetString(demoHeaderValue, sizeof(demoHeaderValue)); +static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, int mapNumber, const char[] demoUrl, + const char[] demoHeaderKey, const char[] demoHeaderValue, const bool deleteAfterUpload) { if (StrEqual(demoUrl, "")) { LogDebug("Will not upload any demos as upload URL is not set."); @@ -181,7 +202,9 @@ static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, return; } - SteamWorks_SetHTTPRequestContextValue(demoRequest, GetDemoInfoDataPack(matchId, mapNumber, demoFileName)); + SteamWorks_SetHTTPRequestContextValue( + demoRequest, + GetDemoInfoDataPack(matchId, mapNumber, demoFileName, demoUrl, demoHeaderKey, demoHeaderValue, deleteAfterUpload)); SteamWorks_SetHTTPCallbacks(demoRequest, DemoRequestCallback); SteamWorks_SendHTTPRequest(demoRequest); } @@ -238,13 +261,20 @@ void SetCurrentMatchRestartDelay(float delay) { static int DemoRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, DataPack pack) { - int mapNumber; char matchId[MATCH_ID_LENGTH]; char demoFileName[PLATFORM_MAX_PATH]; - ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, demoFileName, sizeof(demoFileName)); + int mapNumber; + char uploadUrl[1024]; + char uploadUrlHeaderKey[1024]; + char uploadUrlHeaderValue[1024]; + bool deleteAfterUpload; + ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, + sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, + sizeof(demoFileName), deleteAfterUpload); + delete pack; if (failure || !requestSuccessful) { - LogError("Failed to upload demo %s.", demoFileName); + LogError("Failed to upload demo '%s' to '%s'.", demoFileName, uploadUrl); delete request; CallUploadEvent(matchId, mapNumber, demoFileName, false); return; @@ -267,8 +297,9 @@ static int DemoRequestCallback(Handle request, bool failure, bool requestSuccess } LogDebug("Demo request succeeded. HTTP status code: %d.", statusCode); - if (g_DemoUploadDeleteAfterCvar.BoolValue) { - LogDebug("get5_demo_delete_after_upload set to true; deleting the file from the game server."); + if (deleteAfterUpload) { + LogDebug( + "get5_demo_delete_after_upload set to true when demo request started; deleting the file from the game server."); if (FileExists(demoFileName)) { if (!DeleteFile(demoFileName)) { LogError("Unable to delete demo file %s.", demoFileName); From 4c83ac786a19627f1d2b17d765d61243da1fff80 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 18 Nov 2022 00:02:02 +0100 Subject: [PATCH 04/27] Simplify recording stop logic Move code for legibility Adjust callback return type to match interface --- scripting/get5/recording.sp | 80 +++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/scripting/get5/recording.sp b/scripting/get5/recording.sp index 46913daea..304b3386b 100644 --- a/scripting/get5/recording.sp +++ b/scripting/get5/recording.sp @@ -37,6 +37,10 @@ bool StartRecording() { } void StopRecording(float delay = 0.0) { + if (StrEqual("", g_DemoFileName)) { + LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished() or stopping recording."); + return; + } char uploadUrl[1024]; g_DemoUploadURLCvar.GetString(uploadUrl, sizeof(uploadUrl)); char uploadUrlHeaderKey[1024]; @@ -55,21 +59,43 @@ void StopRecording(float delay = 0.0) { g_DemoFileName = ""; } +static Action Timer_StopGoTVRecording(Handle timer, DataPack pack) { + StopRecordingCallback(pack); + return Plugin_Handled; +} + static void StopRecordingCallback(DataPack pack) { ServerCommand("tv_stoprecord"); - char demoFileName[PLATFORM_MAX_PATH]; - pack.Position = view_as(2); // demo file name is at index 2, see GetDemoInfoDataPack(). - pack.ReadString(demoFileName, sizeof(demoFileName)); - if (StrEqual("", demoFileName)) { - LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished()"); - delete pack; - return; - } // We delay this by 15 seconds to allow the server to flush to the file before firing the event. // For some servers, this take a pretty long time (up to 8-9 seconds, so 15 for grace). CreateTimer(15.0, Timer_FireStopRecordingEvent, pack); } +static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { + char matchId[MATCH_ID_LENGTH]; + char demoFileName[PLATFORM_MAX_PATH]; + int mapNumber; + char uploadUrl[1024]; + char uploadUrlHeaderKey[1024]; + char uploadUrlHeaderValue[1024]; + bool deleteAfterUpload; + ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, + sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, + sizeof(demoFileName), deleteAfterUpload); + delete pack; + + Get5DemoFinishedEvent event = new Get5DemoFinishedEvent(matchId, mapNumber, demoFileName); + LogDebug("Calling Get5_OnDemoFinished()"); + Call_StartForward(g_OnDemoFinished); + Call_PushCell(event); + Call_Finish(); + EventLogger_LogAndDeleteEvent(event); + + UploadDemoToServer(demoFileName, matchId, mapNumber, uploadUrl, uploadUrlHeaderKey, uploadUrlHeaderValue, + deleteAfterUpload); + return Plugin_Handled; +} + static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, const char[] demoFileName, const char[] uploadUrl, const char[] uploadHeaderKey, const char[] uploadHeaderValue, const bool deleteAfterUpload) { @@ -98,52 +124,20 @@ static void ReadDemoDataPack(DataPack pack, char[] matchId, const int matchIdLen deleteAfterUpload = pack.ReadCell(); } -static Action Timer_StopGoTVRecording(Handle timer, DataPack pack) { - StopRecordingCallback(pack); - return Plugin_Handled; -} - -static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { - char matchId[MATCH_ID_LENGTH]; - char demoFileName[PLATFORM_MAX_PATH]; - int mapNumber; - char uploadUrl[1024]; - char uploadUrlHeaderKey[1024]; - char uploadUrlHeaderValue[1024]; - bool deleteAfterUpload; - ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, - sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, - sizeof(demoFileName), deleteAfterUpload); - delete pack; - - Get5DemoFinishedEvent event = new Get5DemoFinishedEvent(matchId, mapNumber, demoFileName); - LogDebug("Calling Get5_OnDemoFinished()"); - Call_StartForward(g_OnDemoFinished); - Call_PushCell(event); - Call_Finish(); - EventLogger_LogAndDeleteEvent(event); - - UploadDemoToServer(demoFileName, matchId, mapNumber, uploadUrl, uploadUrlHeaderKey, uploadUrlHeaderValue, - deleteAfterUpload); - return Plugin_Handled; -} - static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, int mapNumber, const char[] demoUrl, const char[] demoHeaderKey, const char[] demoHeaderValue, const bool deleteAfterUpload) { if (StrEqual(demoUrl, "")) { - LogDebug("Will not upload any demos as upload URL is not set."); + LogDebug("Skipping demo upload as upload URL is not set."); return; } if (!LibraryExists("SteamWorks")) { - LogDebug("Cannot upload demos to a web server without the SteamWorks extension running."); + LogError("Get5 cannot upload demos to a web server without the SteamWorks extension. Set get5_demo_upload_url to an empty string to remove this message."); return; } - LogDebug("UploadDemoToServer: demoUrl (SteamWorks) = %s", demoUrl); Handle demoRequest = CreateGet5HTTPRequest(k_EHTTPMethodPOST, demoUrl); - if (demoRequest == INVALID_HANDLE) { CallUploadEvent(matchId, mapNumber, demoFileName, false); return; @@ -259,7 +253,7 @@ void SetCurrentMatchRestartDelay(float delay) { } } -static int DemoRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, +static void DemoRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, DataPack pack) { char matchId[MATCH_ID_LENGTH]; char demoFileName[PLATFORM_MAX_PATH]; From b00a625800d0650bf31f0412ae82450cd07afe2a Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 18 Nov 2022 03:31:36 +0100 Subject: [PATCH 05/27] Doc typo --- 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 205000882..467a7fc45 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -45,7 +45,7 @@ if possible. Can only be used during warmup. : Asks to reload the last match backup file, i.e. restart the current round. The opposing team must confirm before the round ends. Only works if the [backup system is enabled](../configuration#get5_backup_system_enabled) -and [get5_stop_command_enabled](../configuration#get5_stop_command_enabled) is set to `1`. +and [`get5_stop_command_enabled`](../configuration#get5_stop_command_enabled) is set to `1`. ####`!forceready` From 9725b44cfefba1b88c925be500b218f50eba3b4e Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Sat, 19 Nov 2022 18:38:27 +0100 Subject: [PATCH 06/27] Fix problem with use of CS_GetTeamScore causing gamerules lookup error --- scripting/get5.sp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripting/get5.sp b/scripting/get5.sp index 87f842e51..f7c06e8b5 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -1855,10 +1855,17 @@ bool FormatCvarString(ConVar cvar, char[] buffer, int len, bool safeTeamNames = ReplaceString(buffer, len, "{TEAM1}", team1Str); ReplaceString(buffer, len, "{TEAM2}", team2Str); - Get5Side team1Side = view_as(Get5_Get5TeamToCSTeam(Get5Team_1)); - Get5Side team2Side = team1Side == Get5Side_CT ? Get5Side_T : Get5Side_CT; - ReplaceStringWithInt(buffer, len, "{TEAM1_SCORE}", team1Side != Get5Side_None ? CS_GetTeamScore(view_as(team1Side)) : 0); - ReplaceStringWithInt(buffer, len, "{TEAM2_SCORE}", team2Side != Get5Side_None ? CS_GetTeamScore(view_as(team2Side)) : 0); + int team1Score = 0; + int team2Score = 0; + if (g_GameState == Get5State_Live) { + Get5Side team1Side = view_as(Get5_Get5TeamToCSTeam(Get5Team_1)); + if (team1Side != Get5Side_None) { + team1Score = CS_GetTeamScore(view_as(team1Side)); + team2Score = CS_GetTeamScore(view_as(team1Side == Get5Side_CT ? Get5Side_T : Get5Side_CT)); + } + } + ReplaceStringWithInt(buffer, len, "{TEAM1_SCORE}", team1Score); + ReplaceStringWithInt(buffer, len, "{TEAM2_SCORE}", team2Score); return true; } From a028e948b3b013a2a03a66571f07ce5d47f9d72d Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Mon, 21 Nov 2022 19:22:34 +0100 Subject: [PATCH 07/27] Clean up cvar list/flags Bump max cvar length to 512 Minor cvar buffer optimizations --- documentation/docs/configuration.md | 6 ++ scripting/get5.sp | 139 ++++++++++++++++------------ scripting/get5/matchconfig.sp | 10 +- scripting/get5/readysystem.sp | 2 +- 4 files changed, 93 insertions(+), 64 deletions(-) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 2f660e20a..6a05f7517 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -14,6 +14,12 @@ cfg/sourcemod/get5.cfg You can either set the below parameters in that file, or in the `cvars` section of a match config. As mentioned in the explanation of the [match schema](../match_schema), that section will override all other settings. +!!! warning "512 and no more" + + Note that the maximum length of any config parameter is *less than* 512 characters. Depending on where these + parameters are set, being close to this limit may cause problems. This applies to things like URLs or HTTP headers, + so beware of long strings in these cases. + ### Phase Configuration Files You should also have three config files. These can be edited, but we recommend not diff --git a/scripting/get5.sp b/scripting/get5.sp index f7c06e8b5..29cd79595 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -37,7 +37,7 @@ #define DEBUG_CVAR "get5_debug" #define MATCH_ID_LENGTH 64 -#define MAX_CVAR_LENGTH 128 +#define MAX_CVAR_LENGTH 513 // 512 + 1 for buffers #define TEAM1_STARTING_SIDE CS_TEAM_CT #define TEAM2_STARTING_SIDE CS_TEAM_T @@ -356,69 +356,92 @@ public void OnPluginStart() { /** ConVars **/ // clang-format off - g_AllowTechPauseCvar = CreateConVar("get5_allow_technical_pause", "1", "Whether or not technical pauses are allowed"); - g_MaxTechPauseDurationCvar = CreateConVar("get5_tech_pause_time", "0", "Number of seconds before anyone can call unpause on a technical timeout, 0=unlimited"); - g_MaxTechPausesCvar = CreateConVar("get5_max_tech_pauses", "0", "Number of technical pauses a team is allowed to have, 0=unlimited"); + + // Pauses + g_AllowTechPauseCvar = CreateConVar("get5_allow_technical_pause", "1", "Whether technical pauses are allowed."); g_AutoTechPauseMissingPlayersCvar = CreateConVar("get5_auto_tech_pause_missing_players", "0", "The number of players that must leave a team to trigger an automatic technical pause. Set to 0 to disable."); - g_AutoLoadConfigCvar = CreateConVar("get5_autoload_config", "", "Name of a match config file to automatically load when the server loads"); - g_AutoReadyActivePlayersCvar = CreateConVar("get5_auto_ready_active_players", "0", "Whether to automatically mark players as ready if they kill anyone in the warmup or veto phase."); - g_BackupSystemEnabledCvar = CreateConVar("get5_backup_system_enabled", "1", "Whether the get5 backup system is enabled"); - g_DamagePrintCvar = CreateConVar("get5_print_damage", "0", "Whether damage reports are printed on round end."); - g_DamagePrintFormatCvar = CreateConVar("get5_damageprint_format", "- [{KILL_TO}] ({DMG_TO} in {HITS_TO}) to [{KILL_FROM}] ({DMG_FROM} in {HITS_FROM}) from {NAME} ({HEALTH} HP)", "Format of the damage output string. Available tags are in the default, color tags such as {LIGHT_RED} and {GREEN} also work. {KILL_TO} and {KILL_FROM} indicate kills, assists and flash assists as booleans, all of which are mutually exclusive."); - g_DamagePrintExcessCvar = CreateConVar("get5_print_damage_excess", "0", "Prints full damage given in the damage report on round end. With this disabled (default), a player cannot take more than 100 damage."); - g_DateFormatCvar = CreateConVar("get5_date_format", "%Y-%m-%d", "Date format to use when creating file names. Don't tweak this unless you know what you're doing! Avoid using spaces or colons."); - g_CheckAuthsCvar = CreateConVar("get5_check_auths", "1", "If set to 0, get5 will not force players to the correct team based on steamid"); - g_DemoNameFormatCvar = CreateConVar("get5_demo_name_format", "{TIME}_{MATCHID}_map{MAPNUMBER}_{MAPNAME}", "Format for demo file names, use \"\" to disable. Do not remove the {TIME} placeholder if you use the backup system."); - g_DemoPathCvar = CreateConVar("get5_demo_path", "", "The folder to save demo files in, relative to the csgo directory. If defined, it must not start with a slash and must end with a slash."); - g_DemoUploadHeaderValueCvar = CreateConVar("get5_demo_upload_header_value", "", "If defined, it is the authorization value that is appended to the HTTP request. Requires SteamWorks."); - g_DemoUploadHeaderKeyCvar = CreateConVar("get5_demo_upload_header_key", "Authorization", "If defined, it is the authorization key that is appended to the HTTP request. Requires SteamWorks."); + g_FixedPauseTimeCvar = CreateConVar("get5_fixed_pause_time", "0", "The fixed duration of tactical pauses in seconds. 0 = unlimited."); + g_MaxTacticalPausesCvar = CreateConVar("get5_max_pauses", "0", "Number of tactical pauses a team can use. 0 = unlimited."); + g_MaxPauseTimeCvar = CreateConVar("get5_max_pause_time", "300", "Maximum number of seconds a game can spend under tactical pause for each team. 0 = unlimited."); + g_MaxTechPausesCvar = CreateConVar("get5_max_tech_pauses", "0", "Number of technical pauses a team can use. 0 = unlimited."); + g_PausingEnabledCvar = CreateConVar("get5_pausing_enabled", "1", "Whether pausing (both kinds) is allowed by players."); + g_PauseOnVetoCvar = CreateConVar("get5_pause_on_veto", "0", "Whether the game pauses during the veto phase."); + g_ResetPausesEachHalfCvar = CreateConVar("get5_reset_pauses_each_half", "1", "Whether tactical pause limits will be reset on halftime."); + g_MaxTechPauseDurationCvar = CreateConVar("get5_tech_pause_time", "0", "Number of seconds before anyone can call !unpause during a technical timeout. 0 = unlimited."); + + // Backups + g_RoundBackupPathCvar = CreateConVar("get5_backup_path", "", "The folder to save backup files in, relative to the csgo directory. If defined, it must not start with a slash and must end with a slash. Set to empty string to use the csgo root."); + g_BackupSystemEnabledCvar = CreateConVar("get5_backup_system_enabled", "1", "Whether the Get5 backup system is enabled."); + g_MaxBackupAgeCvar = CreateConVar("get5_max_backup_age", "172800", "Number of seconds before a backup file is automatically deleted. Set to 0 to disable. Default is 2 days."); + g_StopCommandEnabledCvar = CreateConVar("get5_stop_command_enabled", "1", "Whether clients can use the !stop command to restore to the beginning of the current round."); + + // Demos g_DemoUploadDeleteAfterCvar = CreateConVar("get5_demo_delete_after_upload", "0", "Whether to delete the demo from the game server after a successful upload."); - g_DemoUploadURLCvar = CreateConVar("get5_demo_upload_url", "", "If defined, it is the URL at which a server resides to accept connections to upload demos. Requires SteamWorks extension. If no protocol is provided, the plugin will prepend http:// to this value."); - g_DisplayGotvVetoCvar = CreateConVar("get5_display_gotv_veto", "0", "Whether to wait for map vetos to be printed to GOTV before changing map"); - g_EventLogFormatCvar = CreateConVar("get5_event_log_format", "", "Path to use when writing match event logs, use \"\" to disable"); - g_EventLogRemoteURLCvar = CreateConVar("get5_remote_log_url", "", "URL to send event logs as JSON objects to. http:// will be prepended if no protocol is provided."); - g_EventLogRemoteHeaderKeyCvar = CreateConVar("get5_remote_log_header_key", "Authorization", "Custom header key to add to event log HTTP requests."); - g_EventLogRemoteHeaderValueCvar = CreateConVar("get5_remote_log_header_value", "", "Value to assign to get5_remote_log_header_key."); - g_FixedPauseTimeCvar = CreateConVar("get5_fixed_pause_time", "0", "If set to non-zero, this will be the fixed length of any pause"); - g_KickClientImmunityCvar = CreateConVar("get5_kick_immunity", "1", "Whether or not admins with the changemap flag will be immune to kicks from \"get5_kick_when_no_match_loaded\". Set to \"0\" to disable"); - g_KickClientsWithNoMatchCvar = CreateConVar("get5_kick_when_no_match_loaded", "0", "Whether the plugin kicks new clients when no match is loaded"); - g_LiveCfgCvar = CreateConVar("get5_live_cfg", "get5/live.cfg", "Config file to exec when the game goes live."); - g_WarmupCfgCvar = CreateConVar("get5_warmup_cfg", "get5/warmup.cfg", "Config file to exec in warmup periods."); - g_KnifeCfgCvar = CreateConVar("get5_knife_cfg", "get5/knife.cfg", "Config file to exec in knife periods."); - g_LiveCountdownTimeCvar = CreateConVar("get5_live_countdown_time", "10", "Number of seconds used to count down when a match is going live", 0, true, 5.0, true, 60.0); - g_MaxBackupAgeCvar = CreateConVar("get5_max_backup_age", "160000", "Number of seconds before a backup file is automatically deleted, 0 to disable"); - g_MaxTacticalPausesCvar = CreateConVar("get5_max_pauses", "0", "Maximum number of pauses a team can use, 0=unlimited"); - g_MaxPauseTimeCvar = CreateConVar("get5_max_pause_time", "300", "Maximum number of time the game can spend paused by a team, 0=unlimited"); - g_MessagePrefixCvar = CreateConVar("get5_message_prefix", DEFAULT_TAG, "The tag applied before plugin messages."); - g_ResetPausesEachHalfCvar = CreateConVar("get5_reset_pauses_each_half", "1", "Whether pause limits will be reset each halftime period"); - g_PauseOnVetoCvar = CreateConVar("get5_pause_on_veto", "0", "Set 1 to Pause Match during Veto time"); - g_PausingEnabledCvar = CreateConVar("get5_pausing_enabled", "1", "Whether pausing is allowed."); - g_PrettyPrintJsonCvar = CreateConVar("get5_pretty_print_json", "1", "Whether all JSON output is in pretty-print format."); - g_ReadyTeamTagCvar = CreateConVar("get5_ready_team_tag", "1", "Adds [READY] [NOT READY] Tags before Team Names. 0 to disable it."); - g_AllowForceReadyCvar = CreateConVar("get5_allow_force_ready", "1", "Allows players to use the !forceready command. Turning this off does not disable get5_forceready."); - g_ServerIdCvar = CreateConVar("get5_server_id", "0", "Integer that identifies your server. This is used in temp files to prevent collisions."); - g_SetClientClanTagCvar = CreateConVar("get5_set_client_clan_tags", "1", "Whether to set client clan tags to player ready status."); - g_SetHostnameCvar = CreateConVar("get5_hostname_format", "Get5: {TEAM1} vs {TEAM2}", "Template that the server hostname will follow when a match is live. Leave field blank to disable."); - g_StatsPathFormatCvar = CreateConVar("get5_stats_path_format", "get5_matchstats_{MATCHID}.cfg", "Where match stats are saved (updated each map end), set to \"\" to disable"); - g_StopCommandEnabledCvar = CreateConVar("get5_stop_command_enabled", "1", "Whether clients can use the !stop command to restore to the last round"); - g_TeamTimeToStartCvar = CreateConVar("get5_time_to_start", "0", "Time (in seconds) teams have to ready up before forfeiting the match, 0=unlimited"); - g_TeamTimeToKnifeDecisionCvar = CreateConVar("get5_time_to_make_knife_decision", "60", "Time (in seconds) a team has to make a !stay/!swap decision after winning knife round, 0=unlimited"); - g_TimeFormatCvar = CreateConVar("get5_time_format", "%Y-%m-%d_%H-%M-%S", "Time format to use when creating file names. Don't tweak this unless you know what you're doing! Avoid using spaces or colons."); - g_VetoConfirmationTimeCvar = CreateConVar("get5_veto_confirmation_time", "2.0", "Time (in seconds) from presenting a veto menu to a selection being made, during which a confirmation will be required, 0 to disable"); - g_VetoCountdownCvar = CreateConVar("get5_veto_countdown", "5", "Seconds to countdown before veto process commences. Set to \"0\" to disable."); - g_PrintUpdateNoticeCvar = CreateConVar("get5_print_update_notice", "1", "Whether to print to chat when the game goes live if a new version of Get5 is available."); - g_RoundBackupPathCvar = CreateConVar("get5_backup_path", "", "The folder to save backup files in, relative to the csgo directory. If defined, it must not start with a slash and must end with a slash."); - g_PhaseAnnouncementCountCvar = CreateConVar("get5_phase_announcement_count", "5", "The number of times Get5 will print 'Knife' or 'Match is LIVE' when the game starts. Set to 0 to disable."); - g_Team1NameColorCvar = CreateConVar("get5_team1_color", "{LIGHT_GREEN}", "The color used for the name of team 1 in chat messages."); - g_Team2NameColorCvar = CreateConVar("get5_team2_color", "{PINK}", "The color used for the name of team 2 in chat messages."); - g_SpecNameColorCvar = CreateConVar("get5_spec_color", "{NORMAL}", "The color used for the name of spectators in chat messages."); + g_DemoNameFormatCvar = CreateConVar("get5_demo_name_format", "{TIME}_{MATCHID}_map{MAPNUMBER}_{MAPNAME}", "The format to use for demo files. Do not remove the {TIME} placeholder if you use the backup system. Set to empty string to disable automatic demo recording."); + g_DemoPathCvar = CreateConVar("get5_demo_path", "", "The folder to save demo files in, relative to the csgo directory. If defined, it must not start with a slash and must end with a slash. Set to empty string to use the csgo root."); + g_DemoUploadHeaderKeyCvar = CreateConVar("get5_demo_upload_header_key", "Authorization", "If defined, a custom HTTP header with this name is added to the demo upload HTTP request.", FCVAR_DONTRECORD); + g_DemoUploadHeaderValueCvar = CreateConVar("get5_demo_upload_header_value", "", "If defined, the value of the custom header added to the demo upload HTTP request.", FCVAR_DONTRECORD | FCVAR_PROTECTED); + g_DemoUploadURLCvar = CreateConVar("get5_demo_upload_url", "", "If defined, recorded demos will be uploaded to this URL over HTTP. If no protocol is provided, 'http://' is prepended to this value.", FCVAR_DONTRECORD); + + // Surrender/Forfeit + g_ForfeitCountdownTimeCvar = CreateConVar("get5_forfeit_countdown", "180", "The grace-period (in seconds) for rejoining the server to avoid a loss by forfeit.", 0, true, 30.0); + g_ForfeitEnabledCvar = CreateConVar("get5_forfeit_enabled", "1", "Whether the forfeit feature is enabled."); + g_SurrenderCooldownCvar = CreateConVar("get5_surrender_cooldown", "60", "The number of seconds before a vote to surrender can be retried if it fails."); g_SurrenderEnabledCvar = CreateConVar("get5_surrender_enabled", "0", "Whether the surrender command is enabled."); g_MinimumRoundDeficitForSurrenderCvar = CreateConVar("get5_surrender_minimum_round_deficit", "8", "The minimum number of rounds a team must be behind in order to surrender.", 0, true, 0.0); g_VotesRequiredForSurrenderCvar = CreateConVar("get5_surrender_required_votes", "3", "The number of votes required for a team to surrender.", 0, true, 1.0); g_SurrenderVoteTimeLimitCvar = CreateConVar("get5_surrender_time_limit", "15", "The number of seconds before a vote to surrender fails.", 0, true, 10.0); - g_SurrenderCooldownCvar = CreateConVar("get5_surrender_cooldown", "60", "The number of seconds before a vote to surrender can be retried if it fails."); - g_ForfeitEnabledCvar = CreateConVar("get5_forfeit_enabled", "1", "Whether the forfeit feature is enabled. Not to be confused with the surrender feature."); - g_ForfeitCountdownTimeCvar = CreateConVar("get5_forfeit_countdown", "180", "This determines the grace-period for rejoining the server to avoid a loss by forfeit. Cannot be set lower than 30 seconds.", 0, true, 30.0); + + // Events + g_EventLogFormatCvar = CreateConVar("get5_event_log_format", "", "Path to use when writing match event logs to disk. Use \"\" to disable."); + g_EventLogRemoteHeaderKeyCvar = CreateConVar("get5_remote_log_header_key", "Authorization", "If defined, a custom HTTP header with this name is added to the HTTP requests for events.", FCVAR_DONTRECORD); + g_EventLogRemoteHeaderValueCvar = CreateConVar("get5_remote_log_header_value", "", "If defined, the value of the custom header added to the events sent over HTTP.", FCVAR_DONTRECORD | FCVAR_PROTECTED); + g_EventLogRemoteURLCvar = CreateConVar("get5_remote_log_url", "", "If defined, all events are sent to this URL over HTTP. If no protocol is provided, 'http://' is prepended to this value.", FCVAR_DONTRECORD); + + // Damage info + g_DamagePrintCvar = CreateConVar("get5_print_damage", "1", "Whether damage reports are printed to chat on round end."); + g_DamagePrintExcessCvar = CreateConVar("get5_print_damage_excess", "0", "Prints full damage given in the damage report on round end. With this disabled, a player cannot take more than 100 damage."); + g_DamagePrintFormatCvar = CreateConVar("get5_damageprint_format", "- [{KILL_TO}] ({DMG_TO} in {HITS_TO}) to [{KILL_FROM}] ({DMG_FROM} in {HITS_FROM}) from {NAME} ({HEALTH} HP)", "Format of the damage output string. Available tags are in the default, color tags such as {LIGHT_RED} and {GREEN} also work. {KILL_TO} and {KILL_FROM} indicate kills, assists and flash assists as booleans, all of which are mutually exclusive."); + + // Date/time formats + g_DateFormatCvar = CreateConVar("get5_date_format", "%Y-%m-%d", "Date format to use when creating file names. Don't tweak this unless you know what you're doing! Avoid using spaces or colons."); + g_TimeFormatCvar = CreateConVar("get5_time_format", "%Y-%m-%d_%H-%M-%S", "Time format to use when creating file names. Don't tweak this unless you know what you're doing! Avoid using spaces or colons."); + + // Ready system + g_AllowForceReadyCvar = CreateConVar("get5_allow_force_ready", "1", "Allows players to use the !forceready command."); + g_AutoReadyActivePlayersCvar = CreateConVar("get5_auto_ready_active_players", "0", "Whether to automatically mark players as ready if they kill anyone in the warmup or veto phase."); + g_ReadyTeamTagCvar = CreateConVar("get5_ready_team_tag", "1", "Adds [READY]/[NOT READY] tags to team names."); + g_SetClientClanTagCvar = CreateConVar("get5_set_client_clan_tags", "1", "Whether to set client clan tags to player ready status."); + + // Chat/color + g_MessagePrefixCvar = CreateConVar("get5_message_prefix", DEFAULT_TAG, "The tag printed before each chat message."); + g_PhaseAnnouncementCountCvar = CreateConVar("get5_phase_announcement_count", "5", "The number of times 'Knife' or 'Match is LIVE' is printed to chat when the game starts."); + g_SpecNameColorCvar = CreateConVar("get5_spec_color", "{NORMAL}", "The color used for the name of spectators in chat messages."); + g_Team1NameColorCvar = CreateConVar("get5_team1_color", "{LIGHT_GREEN}", "The color used for the name of team 1 in chat messages."); + g_Team2NameColorCvar = CreateConVar("get5_team2_color", "{PINK}", "The color used for the name of team 2 in chat messages."); + + // Countdown/timers + g_LiveCountdownTimeCvar = CreateConVar("get5_live_countdown_time", "10", "Number of seconds used to count down when a match is going live.", 0, true, 5.0, true, 60.0); + g_TeamTimeToStartCvar = CreateConVar("get5_time_to_start", "0", "Time (in seconds) teams have to ready up before forfeiting the match. 0 = unlimited."); + g_TeamTimeToKnifeDecisionCvar = CreateConVar("get5_time_to_make_knife_decision", "60", "Time (in seconds) a team has to make a !stay/!swap decision after winning knife round. 0 = unlimited."); + g_VetoConfirmationTimeCvar = CreateConVar("get5_veto_confirmation_time", "2.0", "Time (in seconds) from presenting a veto menu to a selection being made, during which a confirmation will be required. 0 to disable."); + g_VetoCountdownCvar = CreateConVar("get5_veto_countdown", "5", "Seconds to countdown before veto process commences. 0 to skip countdown."); + + // Server config + g_AutoLoadConfigCvar = CreateConVar("get5_autoload_config", "", "The path/name of a match config file to automatically load when the server loads or when the first player joins."); + g_CheckAuthsCvar = CreateConVar("get5_check_auths", "1", "Whether players are forced onto the correct teams based on their Steam IDs."); + g_DisplayGotvVetoCvar = CreateConVar("get5_display_gotv_veto", "0", "Whether to wait for map vetos to be printed to GOTV before changing map."); + g_SetHostnameCvar = CreateConVar("get5_hostname_format", "Get5: {TEAM1} vs {TEAM2}", "The server hostname to use when a match is loaded. Set to \"\" to disable/use existing."); + g_KickClientImmunityCvar = CreateConVar("get5_kick_immunity", "1", "Whether admins with the 'changemap' flag will be immune to kicks from \"get5_kick_when_no_match_loaded\"."); + g_KickClientsWithNoMatchCvar = CreateConVar("get5_kick_when_no_match_loaded", "0", "Whether the plugin kicks players when no match is loaded and when a match ends."); + g_KnifeCfgCvar = CreateConVar("get5_knife_cfg", "get5/knife.cfg", "Config file to execute for the knife round."); + g_LiveCfgCvar = CreateConVar("get5_live_cfg", "get5/live.cfg", "Config file to execute when the game goes live."); + g_PrettyPrintJsonCvar = CreateConVar("get5_pretty_print_json", "1", "Whether all JSON output is in pretty-print format."); + g_PrintUpdateNoticeCvar = CreateConVar("get5_print_update_notice", "1", "Whether to print to chat when the game goes live if a new version of Get5 is available."); + g_ServerIdCvar = CreateConVar("get5_server_id", "0", "Integer that identifies your server. This is used in temporary files to prevent collisions."); + g_StatsPathFormatCvar = CreateConVar("get5_stats_path_format", "get5_matchstats_{MATCHID}.cfg", "Where match stats are saved (updated each map end). Set to \"\" to disable."); + g_WarmupCfgCvar = CreateConVar("get5_warmup_cfg", "get5/warmup.cfg", "Config file to execute during warmup periods."); + // clang-format on /** Create and exec plugin's configuration file **/ AutoExecConfig(true, "get5"); diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 42f7b4a0b..300b133b0 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -416,9 +416,9 @@ void WriteMatchToKv(KeyValues kv) { kv.GoBack(); kv.JumpToKey("cvars", true); + char cvarName[MAX_CVAR_LENGTH]; + char cvarValue[MAX_CVAR_LENGTH]; for (int i = 0; i < g_CvarNames.Length; i++) { - char cvarName[MAX_CVAR_LENGTH]; - char cvarValue[MAX_CVAR_LENGTH]; g_CvarNames.GetString(i, cvarName, sizeof(cvarName)); g_CvarValues.GetString(i, cvarValue, sizeof(cvarValue)); kv.SetString(cvarName, strlen(cvarValue) == 0 ? KEYVALUE_STRING_PLACEHOLDER : cvarValue); @@ -531,9 +531,9 @@ static bool LoadMatchFromKv(KeyValues kv) { } if (kv.JumpToKey("cvars")) { + char name[MAX_CVAR_LENGTH]; + char value[MAX_CVAR_LENGTH]; if (kv.GotoFirstSubKey(false)) { - char name[MAX_CVAR_LENGTH]; - char value[MAX_CVAR_LENGTH]; do { kv.GetSectionName(name, sizeof(name)); ReadEmptyStringInsteadOfPlaceholder(kv, value, sizeof(value)); @@ -1219,8 +1219,8 @@ Action Command_CreateScrim(int client, int args) { // Also ensure empty string values in cvars get printed to the match config. if (kv.JumpToKey("cvars")) { + char cVarValue[MAX_CVAR_LENGTH]; if (kv.GotoFirstSubKey(false)) { - char cVarValue[MAX_CVAR_LENGTH]; do { WritePlaceholderInsteadOfEmptyString(kv, cVarValue, sizeof(cVarValue)); } while (kv.GotoNextKey(false)); diff --git a/scripting/get5/readysystem.sp b/scripting/get5/readysystem.sp index 9b433fda4..29f5b53c1 100644 --- a/scripting/get5/readysystem.sp +++ b/scripting/get5/readysystem.sp @@ -226,7 +226,7 @@ Action Command_ForceReadyClient(int client, int args) { char cVarName[MAX_CVAR_LENGTH]; g_AllowForceReadyCvar.GetName(cVarName, sizeof(cVarName)); FormatCvarName(cVarName, sizeof(cVarName), cVarName); - char forceReadyCommand[MAX_CVAR_LENGTH]; + char forceReadyCommand[64]; FormatChatCommand(forceReadyCommand, sizeof(forceReadyCommand), "!forceready"); Get5_Message(client, "%t", "ForceReadyDisabled", forceReadyCommand, cVarName); return; From 39941dd25ba325db05046a486599aa000c8d5ee2 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 18 Nov 2022 00:14:02 +0100 Subject: [PATCH 08/27] Add logic to have separate time to veto and time to live/knife --- documentation/docs/configuration.md | 8 ++++++-- scripting/get5.sp | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 6a05f7517..4c52c93a9 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -129,8 +129,12 @@ affect the availability of [`get5_forceready`](../commands#get5_forceready) to a : Whether to set client clan tags to player ready status.
**`Default: 1`** ####`get5_time_to_start` -: Time (in seconds) teams have to ready up before forfeiting the match. Set to zero for no limit. If neither team -becomes ready in time, the series is ended in a tie.
**`Default: 0`** +: Time (in seconds) teams have to ready up for knife/live before forfeiting the match. Set to zero for no limit. If +neither team becomes ready in time, the series is ended in a tie.
**`Default: 0`** + +####`get5_time_to_start_veto` +: Time (in seconds) teams have to ready up for vetoing before forfeiting the match. Set to zero for no limit. If +neither team becomes ready in time, the series is ended in a tie.
**`Default: 0`** ####`get5_time_to_make_knife_decision` : Time (in seconds) a team has to make a [`!stay`](../commands#stay) or [`!swap`](../commands#swap) diff --git a/scripting/get5.sp b/scripting/get5.sp index 29cd79595..4e7a6e11b 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -96,7 +96,8 @@ ConVar g_SetHostnameCvar; ConVar g_StatsPathFormatCvar; ConVar g_StopCommandEnabledCvar; ConVar g_TeamTimeToKnifeDecisionCvar; -ConVar g_TeamTimeToStartCvar; +ConVar g_TimeToStartCvar; +ConVar g_TimeToStartVetoCvar; ConVar g_TimeFormatCvar; ConVar g_VetoConfirmationTimeCvar; ConVar g_VetoCountdownCvar; @@ -422,7 +423,8 @@ public void OnPluginStart() { // Countdown/timers g_LiveCountdownTimeCvar = CreateConVar("get5_live_countdown_time", "10", "Number of seconds used to count down when a match is going live.", 0, true, 5.0, true, 60.0); - g_TeamTimeToStartCvar = CreateConVar("get5_time_to_start", "0", "Time (in seconds) teams have to ready up before forfeiting the match. 0 = unlimited."); + g_TimeToStartCvar = CreateConVar("get5_time_to_start", "0", "Time (in seconds) teams have to ready up for live/knife before forfeiting the match. 0 = unlimited."); + g_TimeToStartVetoCvar = CreateConVar("get5_time_to_start_veto", "0", "Time (in seconds) teams have to ready up for vetoing before forfeiting the match. 0 = unlimited."); g_TeamTimeToKnifeDecisionCvar = CreateConVar("get5_time_to_make_knife_decision", "60", "Time (in seconds) a team has to make a !stay/!swap decision after winning knife round. 0 = unlimited."); g_VetoConfirmationTimeCvar = CreateConVar("get5_veto_confirmation_time", "2.0", "Time (in seconds) from presenting a veto menu to a selection being made, during which a confirmation will be required. 0 to disable."); g_VetoCountdownCvar = CreateConVar("get5_veto_countdown", "5", "Seconds to countdown before veto process commences. 0 to skip countdown."); @@ -908,11 +910,12 @@ static bool CheckReadyWaitingTimes() { return true; } - if (g_TeamTimeToStartCvar.IntValue <= 0) { + int readyTime = g_GameState == Get5State_PreVeto ? g_TimeToStartVetoCvar.IntValue : g_TimeToStartCvar.IntValue; + if (readyTime <= 0) { return false; } - int timeLeft = g_TeamTimeToStartCvar.IntValue - g_ReadyTimeWaitingUsed; + int timeLeft = readyTime - g_ReadyTimeWaitingUsed; if (timeLeft > 0) { if ((timeLeft >= 300 && timeLeft % 60 == 0) || (timeLeft < 300 && timeLeft % 30 == 0) || (timeLeft == 10)) { From 3301d6749f19febb9494a57fbcd8d532a58ff901 Mon Sep 17 00:00:00 2001 From: Jonas Gustafson Date: Fri, 25 Nov 2022 01:15:57 +0100 Subject: [PATCH 09/27] Increased heap from 4kB to 128kB (#944) Increased max head size from 4KB to 128KB --- scripting/get5.sp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripting/get5.sp b/scripting/get5.sp index 4e7a6e11b..9f06a5a0f 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -53,6 +53,12 @@ #pragma semicolon 1 #pragma newdecls required +/** + * Increases stack space to 32000 cells (or 128KB, a cell is 4 bytes) + * This is to prevent "Not enough space on the heap" error when dumping match stats + * Default heap size is 4KB + */ +#pragma dynamic 32000 /** ConVar handles **/ ConVar g_AllowTechPauseCvar; From f7fa0a1753daef258d24852ee98fd9a7b76bd8e5 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Mon, 28 Nov 2022 16:25:34 +0100 Subject: [PATCH 10/27] Clean up loadteam and prevent fromfile recursion (#945) Adjust docs as cfg example was wrong Prevent suicide/team swap in warmup from triggering auto-ready Use MatchConfigFail when failing fromfile via match config Allow spectators to be loaded fromfile Prevent spectators from loading coaches using loadteam Allow mixing fromfile file types; refactor to reuse code Run formatter --- documentation/docs/match_schema.md | 10 +- scripting/get5.sp | 2 +- scripting/get5/matchconfig.sp | 200 +++++++++++++++++------------ scripting/get5/recording.sp | 5 +- scripting/get5/stats.sp | 4 +- 5 files changed, 128 insertions(+), 93 deletions(-) diff --git a/documentation/docs/match_schema.md b/documentation/docs/match_schema.md index da3cbf5a2..591d62361 100644 --- a/documentation/docs/match_schema.md +++ b/documentation/docs/match_schema.md @@ -51,8 +51,9 @@ interface Get5Match { "spectators": { // (10) "name": string | undefined // (29) "players": Get5PlayerSet | undefined // (30) + "fromfile": string | undefined // (34) } | undefined, - "maplist": [string] // (13) + "maplist": string[] // (13) "favored_percentage_team1": number | undefined // (14) "favored_percentage_text": string | undefined // (15) "team1": Get5MatchTeam | Get5MatchTeamFromFile // (20) @@ -128,9 +129,8 @@ interface Get5Match { cvars.

**`Default: ""`** 28. Match teams can also be loaded from a separate file, allowing you to easily re-use a match configuration for different sets of teams. A `fromfile` value could be `"addons/sourcemod/configs/get5/team_nip.json"`, and is always - relative to the `csgo` directory. The file should contain a valid `Get5MatchTeam` object. Note that the file you - point to must be in the same format as the main file, so pointing to a `.cfg` file when the main file is `.json` - will **not** work. + relative to the `csgo` directory. The file should contain a valid `Get5MatchTeam` object. You **are** allowed to mix + filetypes, so a JSON file can point to a `fromfile` that's a KeyValue file and vice-versa. 29. _Optional_
The name of the spectator team.

**`Default: "casters"`** 30. _Optional_
The spectator/caster Steam IDs and names. Setting a Steam ID as spectator takes precedence over being set as a player or coach. @@ -140,6 +140,7 @@ interface Get5Match { 32. _Optional_
If `false`, the entire map list will be played, regardless of score. If `true`, a series will be won when the series score for a team exceeds the number of maps divided by two.

**`Default: true`** 33. _Optional_
Determines if coaches must also [`!ready`](../commands#ready).

**`Default: false`** +34. _Optional_
Similarly to teams, spectators may also be loaded from another file. !!! info "Team assignment priority" @@ -382,6 +383,7 @@ These examples are identical in the way they would work if loaded. ``` `fromfile` example: ```cfg title="addons/sourcemod/get5/team_navi.cfg" + Team { "name" "Natus Vincere" "tag" "NaVi" diff --git a/scripting/get5.sp b/scripting/get5.sp index 9f06a5a0f..c43e75706 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -37,7 +37,7 @@ #define DEBUG_CVAR "get5_debug" #define MATCH_ID_LENGTH 64 -#define MAX_CVAR_LENGTH 513 // 512 + 1 for buffers +#define MAX_CVAR_LENGTH 513 // 512 + 1 for buffers #define TEAM1_STARTING_SIDE CS_TEAM_CT #define TEAM2_STARTING_SIDE CS_TEAM_T diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 300b133b0..2828e7856 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -489,25 +489,29 @@ static bool LoadMatchFromKv(KeyValues kv) { GetTeamPlayers(Get5Team_Spec).Clear(); if (kv.JumpToKey("spectators")) { - AddSubsectionAuthsToList(kv, "players", GetTeamPlayers(Get5Team_Spec)); - kv.GetString("name", g_TeamNames[Get5Team_Spec], MAX_CVAR_LENGTH, CONFIG_SPECTATORSNAME_DEFAULT); + if (!LoadTeamData(kv, Get5Team_Spec, true)) { + return false; + } kv.GoBack(); - FormatTeamName(Get5Team_Spec); } if (kv.JumpToKey("team1")) { - LoadTeamData(kv, Get5Team_1); + if (!LoadTeamData(kv, Get5Team_1, true)) { + return false; + } kv.GoBack(); } else { - MatchConfigFail("Missing \"team1\" section in match kv"); + MatchConfigFail("Missing \"team1\" section in match config KeyValues."); return false; } if (kv.JumpToKey("team2")) { - LoadTeamData(kv, Get5Team_2); + if (!LoadTeamData(kv, Get5Team_2, true)) { + return false; + } kv.GoBack(); } else { - MatchConfigFail("Missing \"team2\" section in match kv"); + MatchConfigFail("Missing \"team2\" section in match config KeyValues."); return false; } @@ -582,26 +586,27 @@ static bool LoadMatchFromJson(JSON_Object json) { GetTeamPlayers(Get5Team_Spec).Clear(); JSON_Object spec = json.GetObject("spectators"); - if (spec != null) { - json_object_get_string_safe(spec, "name", g_TeamNames[Get5Team_Spec], MAX_CVAR_LENGTH, - CONFIG_SPECTATORSNAME_DEFAULT); - AddJsonAuthsToList(spec, "players", GetTeamPlayers(Get5Team_Spec), AUTH_LENGTH); - FormatTeamName(Get5Team_Spec); + if (spec != null && !LoadTeamDataJson(spec, Get5Team_Spec, true)) { + return false; } JSON_Object team1 = json.GetObject("team1"); if (team1 != null) { - LoadTeamDataJson(team1, Get5Team_1); + if (!LoadTeamDataJson(team1, Get5Team_1, true)) { + return false; + } } else { - MatchConfigFail("Missing \"team1\" section in match json"); + MatchConfigFail("Missing \"team1\" section in match config JSON."); return false; } JSON_Object team2 = json.GetObject("team2"); if (team2 != null) { - LoadTeamDataJson(team2, Get5Team_2); + if (!LoadTeamDataJson(team2, Get5Team_2, true)) { + return false; + } } else { - MatchConfigFail("Missing \"team2\" section in match json"); + MatchConfigFail("Missing \"team2\" section in match config JSON."); return false; } @@ -658,66 +663,86 @@ static bool LoadMatchFromJson(JSON_Object json) { return true; } -static void LoadTeamDataJson(JSON_Object json, Get5Team matchTeam) { - GetTeamPlayers(matchTeam).Clear(); - GetTeamCoaches(matchTeam).Clear(); - +static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, const bool loadFromMatchConfig, + const bool allowFromFile = true) { char fromfile[PLATFORM_MAX_PATH]; - json_object_get_string_safe(json, "fromfile", fromfile, sizeof(fromfile)); - + if (allowFromFile) { + json_object_get_string_safe(json, "fromfile", fromfile, sizeof(fromfile)); + } if (StrEqual(fromfile, "")) { + GetTeamPlayers(matchTeam).Clear(); + GetTeamCoaches(matchTeam).Clear(); // TODO: this needs to support both an array and a dictionary // For now, it only supports an array + json_object_get_string_safe(json, "name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, + matchTeam == Get5Team_Spec ? CONFIG_SPECTATORSNAME_DEFAULT : ""); + FormatTeamName(matchTeam); AddJsonAuthsToList(json, "players", GetTeamPlayers(matchTeam), AUTH_LENGTH); - JSON_Object coaches = json.GetObject("coaches"); - if (coaches != null) { - AddJsonAuthsToList(json, "coaches", GetTeamCoaches(matchTeam), AUTH_LENGTH); + if (matchTeam != Get5Team_Spec) { + JSON_Object coaches = json.GetObject("coaches"); + if (coaches != null) { + AddJsonAuthsToList(json, "coaches", GetTeamCoaches(matchTeam), AUTH_LENGTH); + } + json_object_get_string_safe(json, "tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH); + json_object_get_string_safe(json, "flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH); + json_object_get_string_safe(json, "logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH); + json_object_get_string_safe(json, "matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH); + g_TeamSeriesScores[matchTeam] = json_object_get_int_safe(json, "series_score", 0); } - json_object_get_string_safe(json, "name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH); - json_object_get_string_safe(json, "tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH); - json_object_get_string_safe(json, "flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH); - json_object_get_string_safe(json, "logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH); - json_object_get_string_safe(json, "matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH); + return true; } else { - JSON_Object fromfileJson = json_read_from_file(fromfile); - if (fromfileJson == null) { - LogError("Cannot load team config from file \"%s\", fromfile"); - } else { - LoadTeamDataJson(fromfileJson, matchTeam); - json_cleanup_and_delete(fromfileJson); - } + return LoadTeamDataFromFile(fromfile, matchTeam, loadFromMatchConfig); } - - g_TeamSeriesScores[matchTeam] = json_object_get_int_safe(json, "series_score", 0); - FormatTeamName(matchTeam); } -static void LoadTeamData(KeyValues kv, Get5Team matchTeam) { - GetTeamPlayers(matchTeam).Clear(); - GetTeamCoaches(matchTeam).Clear(); +static bool LoadTeamData(const KeyValues kv, const Get5Team matchTeam, const bool loadFromMatchConfig, + const bool allowFromFile = true) { char fromfile[PLATFORM_MAX_PATH]; - kv.GetString("fromfile", fromfile, sizeof(fromfile)); - + if (allowFromFile) { + kv.GetString("fromfile", fromfile, sizeof(fromfile)); + } if (StrEqual(fromfile, "")) { + // TODO: Probably add some validation here and use loadFromMatchConfig to determine error and return false? + GetTeamPlayers(matchTeam).Clear(); + GetTeamCoaches(matchTeam).Clear(); + kv.GetString("name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, + matchTeam == Get5Team_Spec ? CONFIG_SPECTATORSNAME_DEFAULT : ""); + FormatTeamName(matchTeam); AddSubsectionAuthsToList(kv, "players", GetTeamPlayers(matchTeam)); - AddSubsectionAuthsToList(kv, "coaches", GetTeamCoaches(matchTeam)); - kv.GetString("name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH, ""); - } else { - KeyValues fromfilekv = new KeyValues("team"); - if (fromfilekv.ImportFromFile(fromfile)) { - LoadTeamData(fromfilekv, matchTeam); - } else { - LogError("Cannot load team config from file \"%s\"", fromfile); + if (matchTeam != Get5Team_Spec) { + AddSubsectionAuthsToList(kv, "coaches", GetTeamCoaches(matchTeam)); + kv.GetString("tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH, ""); + g_TeamSeriesScores[matchTeam] = kv.GetNum("series_score", 0); } - delete fromfilekv; + return true; + } else { + return LoadTeamDataFromFile(fromfile, matchTeam, loadFromMatchConfig); } +} - g_TeamSeriesScores[matchTeam] = kv.GetNum("series_score", 0); - FormatTeamName(matchTeam); +static bool LoadTeamDataFromFile(const char[] fromFile, const Get5Team team, const bool loadFromMatchConfig) { + LogDebug("Loading team data for team %d using fromfile.", team); + bool success = false; + if (IsJSONPath(fromFile)) { + JSON_Object fromFileJson = json_read_from_file(fromFile); + if (fromFileJson != null) { + success = LoadTeamDataJson(fromFileJson, team, loadFromMatchConfig, false); + json_cleanup_and_delete(fromFileJson); + } + } else { + KeyValues kvFromFile = new KeyValues("Team"); + if (kvFromFile.ImportFromFile(fromFile)) { + success = LoadTeamData(kvFromFile, team, loadFromMatchConfig, false); + } + delete kvFromFile; + } + if (!success && loadFromMatchConfig) { + MatchConfigFail("Cannot load team config from file: \"%s\".", fromFile); + } + return success; } static void FormatTeamName(const Get5Team team) { @@ -824,40 +849,47 @@ static void ExecuteMatchConfigCvars() { } Action Command_LoadTeam(int client, int args) { + char arg1[PLATFORM_MAX_PATH]; + char arg2[PLATFORM_MAX_PATH]; + if (args < 2 || !GetCmdArg(1, arg1, sizeof(arg1)) || !GetCmdArg(2, arg2, sizeof(arg2))) { + ReplyToCommand(client, "Usage: get_loadteam "); + return Plugin_Handled; + } + if (g_GameState == Get5State_None) { - ReplyToCommand(client, "Cannot change player lists when there is no match to modify"); + ReplyToCommand(client, "A match configuration must be loaded before you can load a team file."); return Plugin_Handled; } - char arg1[PLATFORM_MAX_PATH]; - char arg2[PLATFORM_MAX_PATH]; - if (args >= 2 && GetCmdArg(1, arg1, sizeof(arg1)) && GetCmdArg(2, arg2, sizeof(arg2))) { - Get5Team team = Get5Team_None; - if (StrEqual(arg1, "team1")) { - team = Get5Team_1; - } else if (StrEqual(arg1, "team2")) { - team = Get5Team_2; - } else if (StrEqual(arg1, "spec")) { - team = Get5Team_Spec; - } else { - ReplyToCommand(client, "Unknown team: must be one of team1, team2, spec"); + Get5Team team = Get5Team_None; + if (StrEqual(arg1, "team1")) { + team = Get5Team_1; + } else if (StrEqual(arg1, "team2")) { + team = Get5Team_2; + if (g_InScrimMode) { + ReplyToCommand(client, "In scrim mode only team1 or spec can be loaded."); return Plugin_Handled; } + } else if (StrEqual(arg1, "spec")) { + team = Get5Team_Spec; + } else { + ReplyToCommand(client, "Unknown team argument. Must be one of: team1, team2, spec."); + return Plugin_Handled; + } - KeyValues kv = new KeyValues("team"); - if (kv.ImportFromFile(arg2)) { - LoadTeamData(kv, team); - ReplyToCommand(client, "Loaded team data for %s", arg1); - SetMatchTeamCvars(); - } else { - ReplyToCommand(client, "Failed to read keyvalues from file \"%s\"", arg2); + if (LoadTeamDataFromFile(arg2, team, false)) { + ReplyToCommand(client, "Loaded team data for %s.", arg1); + SetMatchTeamCvars(); + if (g_CheckAuthsCvar.BoolValue) { + LOOP_CLIENTS(i) { + if (IsPlayer(i)) { + CheckClientTeam(i); + } + } } - delete kv; - } else { - ReplyToCommand(client, "Usage: get_loadteam "); + ReplyToCommand(client, "Failed to load data for %s from file: \"%s\".", arg1, arg2); } - return Plugin_Handled; } diff --git a/scripting/get5/recording.sp b/scripting/get5/recording.sp index 304b3386b..5cc2d56ff 100644 --- a/scripting/get5/recording.sp +++ b/scripting/get5/recording.sp @@ -133,7 +133,8 @@ static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, } if (!LibraryExists("SteamWorks")) { - LogError("Get5 cannot upload demos to a web server without the SteamWorks extension. Set get5_demo_upload_url to an empty string to remove this message."); + LogError( + "Get5 cannot upload demos to a web server without the SteamWorks extension. Set get5_demo_upload_url to an empty string to remove this message."); return; } @@ -254,7 +255,7 @@ void SetCurrentMatchRestartDelay(float delay) { } static void DemoRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, - DataPack pack) { + DataPack pack) { char matchId[MATCH_ID_LENGTH]; char demoFileName[PLATFORM_MAX_PATH]; int mapNumber; diff --git a/scripting/get5/stats.sp b/scripting/get5/stats.sp index 8c4a8e6b1..d90c4db5c 100644 --- a/scripting/get5/stats.sp +++ b/scripting/get5/stats.sp @@ -620,16 +620,16 @@ static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBr return; } int attacker = GetClientOfUserId(event.GetInt("attacker")); + int victim = GetClientOfUserId(event.GetInt("userid")); if (g_GameState != Get5State_Live) { - if (g_AutoReadyActivePlayersCvar.BoolValue && IsAuthedPlayer(attacker)) { + if (attacker != victim && g_AutoReadyActivePlayersCvar.BoolValue && IsAuthedPlayer(attacker)) { // HandleReadyCommand checks for game state, so we don't need to do that here as well. HandleReadyCommand(attacker, true); } return; } - int victim = GetClientOfUserId(event.GetInt("userid")); int assister = GetClientOfUserId(event.GetInt("assister")); bool validAttacker = IsValidClient(attacker); From ac7b12199228e75804a27ff444005a53838aa26f Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Mon, 28 Nov 2022 21:12:21 +0100 Subject: [PATCH 11/27] Add match ID to player connect/disconnect events and only fire if match is loaded (#947) Remove unused Get5PlayerMapEvent object --- documentation/docs/event_schema.yml | 14 +++--- scripting/get5.sp | 11 +++-- scripting/include/get5.inc | 73 ++++++++++++----------------- 3 files changed, 47 insertions(+), 51 deletions(-) diff --git a/documentation/docs/event_schema.yml b/documentation/docs/event_schema.yml index 0a12c1a5c..64c8040c4 100644 --- a/documentation/docs/event_schema.yml +++ b/documentation/docs/event_schema.yml @@ -59,13 +59,11 @@ paths: schema: title: Get5PlayerConnectedEvent allOf: - - $ref: "#/components/schemas/Get5Event" + - $ref: "#/components/schemas/Get5PlayerDisconnectedEvent" properties: event: enum: - player_connect - player: - $ref: "#/components/schemas/Get5Player" ip_address: type: string example: '34.132.182.66' @@ -83,13 +81,11 @@ paths: schema: title: Get5PlayerDisconnectedEvent allOf: - - $ref: "#/components/schemas/Get5Event" + - $ref: "#/components/schemas/Get5PlayerDisconnectedEvent" properties: event: enum: - player_disconnect - player: - $ref: "#/components/schemas/Get5Player" responses: { } "/Get5_OnPreLoadMatchConfig": post: @@ -1142,6 +1138,12 @@ components: - a - b description: The site at which the bomb was planted/defused or exploded. `Site` in SourceMod. + Get5PlayerDisconnectedEvent: + allOf: + - "$ref": "#/components/schemas/Get5MatchEvent" + properties: + player: + $ref: "#/components/schemas/Get5Player" tags: - name: All Events diff --git a/scripting/get5.sp b/scripting/get5.sp index c43e75706..7a24a982f 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -757,12 +757,15 @@ public void OnClientSayCommand_Post(int client, const char[] command, const char * put on that team and spawned, so we can't allow that. */ static Action Event_PlayerConnectFull(Event event, const char[] name, bool dontBroadcast) { + if (g_GameState == Get5State_None) { + return Plugin_Continue; + } int client = GetClientOfUserId(event.GetInt("userid")); if (IsPlayer(client)) { char ipAddress[32]; GetClientIP(client, ipAddress, sizeof(ipAddress)); - Get5PlayerConnectedEvent connectEvent = new Get5PlayerConnectedEvent(GetPlayerObject(client), ipAddress); + Get5PlayerConnectedEvent connectEvent = new Get5PlayerConnectedEvent(g_MatchID, GetPlayerObject(client), ipAddress); LogDebug("Calling Get5_OnPlayerConnected()"); Call_StartForward(g_OnPlayerConnected); @@ -772,13 +775,14 @@ static Action Event_PlayerConnectFull(Event event, const char[] name, bool dontB SetEntPropFloat(client, Prop_Send, "m_fForceTeam", 3600.0); } + return Plugin_Continue; } static Action Event_PlayerDisconnect(Event event, const char[] name, bool dontBroadcast) { int client = GetClientOfUserId(event.GetInt("userid")); g_ClientPendingTeamCheck[client] = false; - if (IsPlayer(client)) { - Get5PlayerDisconnectedEvent disconnectEvent = new Get5PlayerDisconnectedEvent(GetPlayerObject(client)); + if (g_GameState != Get5State_None && IsPlayer(client)) { + Get5PlayerDisconnectedEvent disconnectEvent = new Get5PlayerDisconnectedEvent(g_MatchID, GetPlayerObject(client)); LogDebug("Calling Get5_OnPlayerDisconnected()"); Call_StartForward(g_OnPlayerDisconnected); @@ -790,6 +794,7 @@ static Action Event_PlayerDisconnect(Event event, const char[] name, bool dontBr // to get the right "number of players per team" in CheckForForfeitOnDisconnect(). CreateTimer(0.1, Timer_DisconnectCheck, _, TIMER_FLAG_NO_MAPCHANGE); } + return Plugin_Continue; } // This runs every time a map starts *or* when the plugin is reloaded. diff --git a/scripting/include/get5.inc b/scripting/include/get5.inc index d9ae276ee..62ff3a463 100644 --- a/scripting/include/get5.inc +++ b/scripting/include/get5.inc @@ -385,44 +385,6 @@ methodmap Get5Event < JSON_Object { } } -methodmap Get5PlayerConnectedEvent < Get5Event { - - property Get5Player Player { - public get() { - return view_as(this.GetObject("player")); - } - public set(Get5Player player) { - this.SetObject("player", player); - } - } - - public bool SetIPAddress(const char[] address) { - return this.SetString("ip_address", address); - } - - public bool GetIPAddress(char[] buffer, const int maxSize) { - return this.GetString("ip_address", buffer, maxSize); - } - - public Get5PlayerConnectedEvent(const Get5Player player, const char[] ipAddress) { - Get5PlayerConnectedEvent self = view_as(new JSON_Object()); - self.SetEvent("player_connect"); - self.Player = player; - self.SetIPAddress(ipAddress); - return self; - } -} - -methodmap Get5PlayerDisconnectedEvent < Get5PlayerConnectedEvent { - - public Get5PlayerDisconnectedEvent(const Get5Player player) { - Get5PlayerDisconnectedEvent self = view_as(new JSON_Object()); - self.SetEvent("player_disconnect"); - self.Player = player; - return self; - } -} - methodmap Get5MatchEvent < Get5Event { public bool SetMatchId(const char[] matchId) { @@ -504,7 +466,7 @@ methodmap Get5TimedRoundEvent < Get5RoundEvent { } } -methodmap Get5PlayerMapEvent < Get5MapEvent { +methodmap Get5PlayerRoundEvent < Get5RoundEvent { property Get5Player Player { public get() { @@ -516,7 +478,7 @@ methodmap Get5PlayerMapEvent < Get5MapEvent { } } -methodmap Get5PlayerRoundEvent < Get5RoundEvent { +methodmap Get5PlayerTimedRoundEvent < Get5TimedRoundEvent { property Get5Player Player { public get() { @@ -528,7 +490,8 @@ methodmap Get5PlayerRoundEvent < Get5RoundEvent { } } -methodmap Get5PlayerTimedRoundEvent < Get5TimedRoundEvent { +// MATCH CONFIG +methodmap Get5PlayerDisconnectedEvent < Get5MatchEvent { property Get5Player Player { public get() { @@ -538,9 +501,35 @@ methodmap Get5PlayerTimedRoundEvent < Get5TimedRoundEvent { this.SetObject("player", player); } } + + public Get5PlayerDisconnectedEvent(const char[] matchId, const Get5Player player) { + Get5PlayerDisconnectedEvent self = view_as(new JSON_Object()); + self.SetMatchId(matchId); + self.SetEvent("player_disconnect"); + self.Player = player; + return self; + } } -// MATCH CONFIG +methodmap Get5PlayerConnectedEvent < Get5PlayerDisconnectedEvent { + + public bool SetIPAddress(const char[] address) { + return this.SetString("ip_address", address); + } + + public bool GetIPAddress(char[] buffer, const int maxSize) { + return this.GetString("ip_address", buffer, maxSize); + } + + public Get5PlayerConnectedEvent(const char[] matchId, const Get5Player player, const char[] ipAddress) { + Get5PlayerConnectedEvent self = view_as(new JSON_Object()); + self.SetMatchId(matchId); + self.SetEvent("player_connect"); + self.Player = player; + self.SetIPAddress(ipAddress); + return self; + } +} methodmap Get5SeriesResultEvent < Get5MatchEvent { From 4d83ce55c8d7907c08989621558689ecd3ea9306 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 2 Dec 2022 01:56:49 +0100 Subject: [PATCH 12/27] Let bots count as clutchers and correctly trigger 1vX (#949) Let bots win knife round Deduplicate clutch detection code on death --- scripting/get5.sp | 8 ++-- scripting/get5/stats.sp | 100 ++++++++++++++++------------------------ scripting/get5/util.sp | 8 ++-- 3 files changed, 49 insertions(+), 67 deletions(-) diff --git a/scripting/get5.sp b/scripting/get5.sp index 7a24a982f..9df320959 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -1590,16 +1590,16 @@ static Action Event_RoundStart(Event event, const char[] name, bool dontBroadcas static Action Event_RoundWinPanel(Event event, const char[] name, bool dontBroadcast) { LogDebug("Event_RoundWinPanel"); if (g_GameState == Get5State_KnifeRound && g_HasKnifeRoundStarted) { - int ctAlive = CountAlivePlayersOnTeam(CS_TEAM_CT); - int tAlive = CountAlivePlayersOnTeam(CS_TEAM_T); + int ctAlive = CountAlivePlayersOnTeam(Get5Side_CT); + int tAlive = CountAlivePlayersOnTeam(Get5Side_T); int winningCSTeam; if (ctAlive > tAlive) { winningCSTeam = CS_TEAM_CT; } else if (tAlive > ctAlive) { winningCSTeam = CS_TEAM_T; } else { - int ctHealth = SumHealthOfTeam(CS_TEAM_CT); - int tHealth = SumHealthOfTeam(CS_TEAM_T); + int ctHealth = SumHealthOfTeam(Get5Side_CT); + int tHealth = SumHealthOfTeam(Get5Side_T); if (ctHealth > tHealth) { winningCSTeam = CS_TEAM_CT; } else if (tHealth > ctHealth) { diff --git a/scripting/get5/stats.sp b/scripting/get5/stats.sp index d90c4db5c..c370ac709 100644 --- a/scripting/get5/stats.sp +++ b/scripting/get5/stats.sp @@ -616,56 +616,42 @@ static Action Stats_GrenadeThrownEvent(Event event, const char[] name, bool dont } static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBroadcast) { - if (IsDoingRestoreOrMapChange()) { - return; + if (g_GameState == Get5State_None || IsDoingRestoreOrMapChange()) { + return Plugin_Continue; } - int attacker = GetClientOfUserId(event.GetInt("attacker")); + int victim = GetClientOfUserId(event.GetInt("userid")); + if (!IsValidClient(victim)) { + return Plugin_Continue; // Not sure how this would happen, but it's not something we care about. + } + Get5Player victimPlayer = GetPlayerObject(victim); + int attacker = GetClientOfUserId(event.GetInt("attacker")); + Get5Player attackerPlayer = IsValidClient(attacker) ? GetPlayerObject(attacker) : null; if (g_GameState != Get5State_Live) { - if (attacker != victim && g_AutoReadyActivePlayersCvar.BoolValue && IsAuthedPlayer(attacker)) { + if (attacker != victim && g_AutoReadyActivePlayersCvar.BoolValue && attackerPlayer != null) { // HandleReadyCommand checks for game state, so we don't need to do that here as well. HandleReadyCommand(attacker, true); } - return; + return Plugin_Continue; } - int assister = GetClientOfUserId(event.GetInt("assister")); - - bool validAttacker = IsValidClient(attacker); - bool validVictim = IsValidClient(victim); - bool validAssister = IsValidClient(assister); - - if (!validVictim) { - return; // Not sure how this would happen, but it's not something we care about. - } + Get5Side victimSide = victimPlayer.Side; + Get5Side attackerSide = attackerPlayer != null ? attackerPlayer.Side : Get5Side_None; // Update "clutch" (1vx) data structures to check if the clutcher wins the round - int tCount = CountAlivePlayersOnTeam(CS_TEAM_T); - int ctCount = CountAlivePlayersOnTeam(CS_TEAM_CT); - - if (tCount == 1 && !g_SetTeamClutching[CS_TEAM_T]) { - g_SetTeamClutching[CS_TEAM_T] = true; - int clutcher = GetClutchingClient(CS_TEAM_T); - g_RoundClutchingEnemyCount[clutcher] = ctCount; - } - - if (ctCount == 1 && !g_SetTeamClutching[CS_TEAM_CT]) { - g_SetTeamClutching[CS_TEAM_CT] = true; - int clutcher = GetClutchingClient(CS_TEAM_CT); - g_RoundClutchingEnemyCount[clutcher] = tCount; + int victimSideInt = view_as(victimSide); + if (!g_SetTeamClutching[victimSideInt] && CountAlivePlayersOnTeam(victimSide) == 1) { + g_SetTeamClutching[victimSideInt] = true; + // Don't use attackerSide here as attacker may be invalid, which should still count opposing team correctly. + g_RoundClutchingEnemyCount[GetClutchingClient(victimSide)] = + CountAlivePlayersOnTeam(victimSide == Get5Side_CT ? Get5Side_T : Get5Side_CT); } - bool headshot = event.GetBool("headshot"); - char weapon[32]; event.GetString("weapon", weapon, sizeof(weapon)); - CSWeaponID weaponId = CS_AliasToWeaponID(weapon); - int attackerTeam = validAttacker ? GetClientTeam(attacker) : 0; - int victimTeam = GetClientTeam(victim); - // suicide (kill console) is attacker == victim, weapon id 0, weapon "world" // fall damage is weapon id 0, attacker 0, weapon "worldspawn" // falling from vertigo is attacker 0, weapon id 0, weapon "trigger_hurt" @@ -675,7 +661,8 @@ static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBr // is unreliable. with those in mind, we can determine that suicide must be true if attacker is 0 // or attacker == victim and it was **not** the bomb. bool killedByBomb = StrEqual("planted_c4", weapon); - bool isSuicide = (!validAttacker || attacker == victim) && !killedByBomb; + bool isSuicide = (attackerPlayer == null || attacker == victim) && !killedByBomb; + bool headshot = event.GetBool("headshot"); IncrementPlayerStat(victim, STAT_DEATHS); // used for calculating round KAST @@ -683,18 +670,18 @@ static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBr if (!g_FirstDeathDone) { g_FirstDeathDone = true; - IncrementPlayerStat(victim, (victimTeam == CS_TEAM_CT) ? STAT_FIRSTDEATH_CT : STAT_FIRSTDEATH_T); + IncrementPlayerStat(victim, victimSide == Get5Side_CT ? STAT_FIRSTDEATH_CT : STAT_FIRSTDEATH_T); } if (isSuicide) { IncrementPlayerStat(victim, STAT_SUICIDES); } else if (!killedByBomb) { - if (attackerTeam == victimTeam) { + if (attackerSide == victimSide) { IncrementPlayerStat(attacker, STAT_TEAMKILLS); } else { if (!g_FirstKillDone) { g_FirstKillDone = true; - IncrementPlayerStat(attacker, (attackerTeam == CS_TEAM_CT) ? STAT_FIRSTKILL_CT : STAT_FIRSTKILL_T); + IncrementPlayerStat(attacker, attackerSide == Get5Side_CT ? STAT_FIRSTKILL_CT : STAT_FIRSTKILL_T); } g_RoundKills[attacker]++; @@ -721,20 +708,21 @@ static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBr } Get5PlayerDeathEvent playerDeathEvent = new Get5PlayerDeathEvent( - g_MatchID, g_MapNumber, g_RoundNumber, GetRoundTime(), GetPlayerObject(victim), new Get5Weapon(weapon, weaponId), - headshot, validAttacker ? attackerTeam == victimTeam : false, event.GetBool("thrusmoke"), event.GetBool("noscope"), - event.GetBool("attackerblind"), isSuicide, event.GetInt("penetrated"), killedByBomb); + g_MatchID, g_MapNumber, g_RoundNumber, GetRoundTime(), victimPlayer, new Get5Weapon(weapon, weaponId), headshot, + attackerSide == victimSide, event.GetBool("thrusmoke"), event.GetBool("noscope"), event.GetBool("attackerblind"), + isSuicide, event.GetInt("penetrated"), killedByBomb); - if (validAttacker) { - playerDeathEvent.Attacker = GetPlayerObject(attacker); + if (attackerPlayer != null) { + // Setter does not accept null. + playerDeathEvent.Attacker = attackerPlayer; } - if (validAssister) { + int assister = GetClientOfUserId(event.GetInt("assister")); + if (IsValidClient(assister)) { + Get5Player assisterPlayer = GetPlayerObject(assister); + bool friendlyFire = assisterPlayer.Side == victimSide; bool assistedFlash = event.GetBool("assistedflash"); - bool friendlyFire = GetClientTeam(assister) == victimTeam; - - playerDeathEvent.Assist = new Get5AssisterObject(GetPlayerObject(assister), assistedFlash, friendlyFire); - + playerDeathEvent.Assist = new Get5AssisterObject(assisterPlayer, assistedFlash, friendlyFire); // Assists should only count towards opposite team if (!friendlyFire) { // You cannot flash-assist and regular-assist for the same kill. @@ -756,6 +744,7 @@ static Action Stats_PlayerDeathEvent(Event event, const char[] name, bool dontBr Call_Finish(); EventLogger_LogAndDeleteEvent(playerDeathEvent); + return Plugin_Continue; } static void UpdateTradeStat(int attacker, int victim) { @@ -1040,21 +1029,14 @@ static void GoBackFromPlayer() { g_StatsKv.GoBack(); } -static int GetClutchingClient(int csTeam) { - int client = -1; - int count = 0; +// Assumes the team has only one player left when called. +static int GetClutchingClient(const Get5Side side) { LOOP_CLIENTS(i) { - if (IsPlayer(i) && IsPlayerAlive(i) && GetClientTeam(i) == csTeam) { - client = i; - count++; + if (IsValidClient(i) && IsPlayerAlive(i) && view_as(GetClientTeam(i)) == side) { + return i; } } - - if (count == 1) { - return client; - } else { - return -1; - } + return 0; } static void DumpToFile() { diff --git a/scripting/get5/util.sp b/scripting/get5/util.sp index d6c6a8e36..17092832f 100644 --- a/scripting/get5/util.sp +++ b/scripting/get5/util.sp @@ -40,20 +40,20 @@ stock int GetNumHumansOnTeam(int team) { return count; } -stock int CountAlivePlayersOnTeam(int csTeam) { +stock int CountAlivePlayersOnTeam(const Get5Side side) { int count = 0; LOOP_CLIENTS(i) { - if (IsPlayer(i) && IsPlayerAlive(i) && GetClientTeam(i) == csTeam) { + if (IsValidClient(i) && IsPlayerAlive(i) && view_as(GetClientTeam(i)) == side) { count++; } } return count; } -stock int SumHealthOfTeam(int team) { +stock int SumHealthOfTeam(Get5Side side) { int sum = 0; LOOP_CLIENTS(i) { - if (IsPlayer(i) && IsPlayerAlive(i) && GetClientTeam(i) == team) { + if (IsValidClient(i) && IsPlayerAlive(i) && view_as(GetClientTeam(i)) == side) { sum += GetClientHealth(i); } } From 1bdf7a6154f6b05c6e8d4e66704e4ab3573d6da7 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 9 Dec 2022 20:57:46 +0100 Subject: [PATCH 13/27] More tests + better fromfile support + various refactor + increase cvar length (#946) Clean up logic that handles team-specific mp_ values and updated docs Allow non-clinch max team scores Quote KV header in team fromfile example Support 512 characters in cvar restore Add a lot more tests Prevent duplicate pause timers Prevent overwriting live backups with prelive config backups by mistake Don't kill players with teamswap when loading a live backup Refactor backup code a bit Color backup file path in chat --- configs/get5/tests/default_valid.cfg | 61 ++ configs/get5/tests/default_valid.json | 52 ++ configs/get5/tests/default_valid_team1t.json | 30 + .../get5/tests/fromfile_maplist_empty.json | 18 + .../tests/fromfile_maplist_empty_string.json | 18 + .../get5/tests/fromfile_maplist_invalid.cfg | 20 + .../tests/fromfile_maplist_not_array.json | 18 + .../tests/fromfile_maplist_not_found.json | 18 + configs/get5/tests/fromfile_maplist_valid.cfg | 20 + .../get5/tests/fromfile_maplist_valid.json | 15 + configs/get5/tests/invalid_config.cfg | 1 + configs/get5/tests/invalid_config.json | 1 + configs/get5/tests/maplist_empty.json | 1 + configs/get5/tests/maplist_invalid.cfg | 6 + .../get5/tests/maplist_invalid_not_array.json | 4 + configs/get5/tests/maplist_valid.cfg | 6 + configs/get5/tests/maplist_valid.json | 5 + configs/get5/tests/missing_maplist.cfg | 11 + configs/get5/tests/missing_maplist.json | 8 + configs/get5/tests/missing_team1.cfg | 13 + configs/get5/tests/missing_team1.json | 10 + configs/get5/tests/missing_team2.cfg | 13 + configs/get5/tests/missing_team2.json | 10 + configs/get5/tests/readme.txt | 2 + configs/get5/tests/team2.cfg | 13 + configs/get5/tests/team2.json | 11 + configs/get5/tests/team2_array.json | 12 + documentation/docs/match_schema.md | 14 +- scripting/get5.sp | 101 +++- scripting/get5/backups.sp | 81 +-- scripting/get5/jsonhelpers.sp | 19 - scripting/get5/matchconfig.sp | 559 +++++++++--------- scripting/get5/natives.sp | 3 +- scripting/get5/pausing.sp | 18 +- scripting/get5/tests.sp | 446 ++++++++++++-- scripting/get5/util.sp | 49 +- scripting/include/restorecvars.inc | 2 +- 37 files changed, 1274 insertions(+), 415 deletions(-) create mode 100644 configs/get5/tests/default_valid.cfg create mode 100644 configs/get5/tests/default_valid.json create mode 100644 configs/get5/tests/default_valid_team1t.json create mode 100644 configs/get5/tests/fromfile_maplist_empty.json create mode 100644 configs/get5/tests/fromfile_maplist_empty_string.json create mode 100644 configs/get5/tests/fromfile_maplist_invalid.cfg create mode 100644 configs/get5/tests/fromfile_maplist_not_array.json create mode 100644 configs/get5/tests/fromfile_maplist_not_found.json create mode 100644 configs/get5/tests/fromfile_maplist_valid.cfg create mode 100644 configs/get5/tests/fromfile_maplist_valid.json create mode 100644 configs/get5/tests/invalid_config.cfg create mode 100644 configs/get5/tests/invalid_config.json create mode 100644 configs/get5/tests/maplist_empty.json create mode 100644 configs/get5/tests/maplist_invalid.cfg create mode 100644 configs/get5/tests/maplist_invalid_not_array.json create mode 100644 configs/get5/tests/maplist_valid.cfg create mode 100644 configs/get5/tests/maplist_valid.json create mode 100644 configs/get5/tests/missing_maplist.cfg create mode 100644 configs/get5/tests/missing_maplist.json create mode 100644 configs/get5/tests/missing_team1.cfg create mode 100644 configs/get5/tests/missing_team1.json create mode 100644 configs/get5/tests/missing_team2.cfg create mode 100644 configs/get5/tests/missing_team2.json create mode 100644 configs/get5/tests/readme.txt create mode 100644 configs/get5/tests/team2.cfg create mode 100644 configs/get5/tests/team2.json create mode 100644 configs/get5/tests/team2_array.json diff --git a/configs/get5/tests/default_valid.cfg b/configs/get5/tests/default_valid.cfg new file mode 100644 index 000000000..20168cb7a --- /dev/null +++ b/configs/get5/tests/default_valid.cfg @@ -0,0 +1,61 @@ +"Match" +{ + "matchid" "test_match_valid" + "match_title" "Test {MAPNUMBER} of {MAXMAPS}" + "num_maps" "3" + "skip_veto" "1" + "clinch_series" "1" + "side_type" "never_knife" + "players_per_team" "5" + "coaches_per_team" "1" + "min_players_to_ready" "3" + "min_spectators_to_ready" "1" + "veto_first" "team2" + "maplist" + { + "de_dust2" "" + "de_mirage" "" + "de_inferno" "" + "de_ancient" "" + } + "map_sides" + { + "knife" "" + "team1_t" "" + } + "favored_percentage_team1" "75" + "favored_percentage_text" "team percentage text" + "team1" + { + "name" "Team A Default" + "tag" "TAG-A" + "flag" "US" + "logo" "logofilename" + "players" + { + "76561197996413459" "PlayerAName1" + "76561197996426756" "PlayerAName2" + "76561197996426757" "PlayerAName3" + "76561197996426758" "PlayerAName4" + "76561197996426759" "PlayerAName5" + } + "coaches" + { + "76561197996426735" "CoachAName1" + "76561197946789735" "CoachAName2" + } + "matchtext" "Defending Champions" + } + "team2" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } + "spectators" + { + "name" "Spectator Team Name" + "players" + { + "76561197996426761" "SpectatorName1" + } + } +} diff --git a/configs/get5/tests/default_valid.json b/configs/get5/tests/default_valid.json new file mode 100644 index 000000000..f5dc46b36 --- /dev/null +++ b/configs/get5/tests/default_valid.json @@ -0,0 +1,52 @@ +{ + "matchid": "test_match_valid", + "match_title": "Test {MAPNUMBER} of {MAXMAPS}", + "num_maps": 3, + "skip_veto": true, + "clinch_series": true, + "side_type": "never_knife", + "players_per_team": 5, + "coaches_per_team": 1, + "min_players_to_ready": 3, + "min_spectators_to_ready": 1, + "veto_first": "team2", + "maplist": [ + "de_dust2", + "de_mirage", + "de_inferno", + "de_ancient" + ], + "map_sides": [ + "knife", + "team1_t" + ], + "favored_percentage_team1": 75, + "favored_percentage_text": "team percentage text", + "team1": { + "name": "Team A Default", + "tag": "TAG-A", + "flag": "US", + "logo": "logofilename", + "players": { + "76561197996413459": "PlayerAName1", + "76561197996426756": "PlayerAName2", + "76561197996426757": "PlayerAName3", + "76561197996426758": "PlayerAName4", + "76561197996426759": "PlayerAName5" + }, + "coaches": { + "76561197996426735": "CoachAName1", + "76561197946789735": "CoachAName2" + }, + "matchtext": "Defending Champions" + }, + "team2": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + }, + "spectators": { + "name": "Spectator Team Name", + "players": { + "76561197996426761": "SpectatorName1" + } + } +} diff --git a/configs/get5/tests/default_valid_team1t.json b/configs/get5/tests/default_valid_team1t.json new file mode 100644 index 000000000..2a11d7a17 --- /dev/null +++ b/configs/get5/tests/default_valid_team1t.json @@ -0,0 +1,30 @@ +{ + "num_maps": 3, + "skip_veto": true, + "map_sides": [ + "team1_ct", + "team1_t", + "knife" + ], + "maplist": [ + "de_mirage", + "de_dust2", + "de_inferno" + ], + "team1": { + "name": "Team A Start T", + "flag": "NO", + "matchtext": "GG T WIN", + "logo": "start_t_logo", + "series_score": 1, + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "name": "Team B", + "players": [ + "STEAM_1:1:46796472" + ] + } +} diff --git a/configs/get5/tests/fromfile_maplist_empty.json b/configs/get5/tests/fromfile_maplist_empty.json new file mode 100644 index 000000000..e67fed0c7 --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_empty.json @@ -0,0 +1,18 @@ +{ + "num_maps": 3, + "maplist": { + "fromfile": "addons/sourcemod/configs/get5/tests/maplist_empty.json" + }, + "team1": { + "name": "Team A", + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "name": "Team B", + "players": [ + "STEAM_1:1:46796472" + ] + } +} diff --git a/configs/get5/tests/fromfile_maplist_empty_string.json b/configs/get5/tests/fromfile_maplist_empty_string.json new file mode 100644 index 000000000..dfc1fd5b5 --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_empty_string.json @@ -0,0 +1,18 @@ +{ + "num_maps": 3, + "maplist": { + "fromfile": "" + }, + "team1": { + "name": "Team A", + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "name": "Team B", + "players": [ + "STEAM_1:1:46796472" + ] + } +} diff --git a/configs/get5/tests/fromfile_maplist_invalid.cfg b/configs/get5/tests/fromfile_maplist_invalid.cfg new file mode 100644 index 000000000..4e5f5486e --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_invalid.cfg @@ -0,0 +1,20 @@ +"Match" +{ + "num_maps" "3" + "maplist" + { + "fromfile" "addons/sourcemod/configs/get5/tests/maplist_invalid.cfg" + } + "team1" + { + "name" "Team A" + "players" + { + "STEAM_0:1:52245092" "PlayerName" + } + } + "team2" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } +} diff --git a/configs/get5/tests/fromfile_maplist_not_array.json b/configs/get5/tests/fromfile_maplist_not_array.json new file mode 100644 index 000000000..a54a2ffd0 --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_not_array.json @@ -0,0 +1,18 @@ +{ + "num_maps": 3, + "maplist": { + "fromfile": "addons/sourcemod/configs/get5/tests/maplist_invalid_not_array.json" + }, + "team1": { + "name": "Team A", + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "name": "Team B", + "players": [ + "STEAM_1:1:46796472" + ] + } +} diff --git a/configs/get5/tests/fromfile_maplist_not_found.json b/configs/get5/tests/fromfile_maplist_not_found.json new file mode 100644 index 000000000..95bbbc229 --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_not_found.json @@ -0,0 +1,18 @@ +{ + "num_maps": 3, + "maplist": { + "fromfile": "addons/sourcemod/configs/get5/tests/maplist_not_found.json" + }, + "team1": { + "name": "Team A", + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "name": "Team B", + "players": [ + "STEAM_1:1:46796472" + ] + } +} diff --git a/configs/get5/tests/fromfile_maplist_valid.cfg b/configs/get5/tests/fromfile_maplist_valid.cfg new file mode 100644 index 000000000..450d2f50a --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_valid.cfg @@ -0,0 +1,20 @@ +"Match" +{ + "num_maps" "3" + "maplist" + { + "fromfile" "addons/sourcemod/configs/get5/tests/maplist_valid.cfg" + } + "team1" + { + "name" "Team A" + "players" + { + "STEAM_0:1:52245092" "PlayerName" + } + } + "team2" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } +} diff --git a/configs/get5/tests/fromfile_maplist_valid.json b/configs/get5/tests/fromfile_maplist_valid.json new file mode 100644 index 000000000..2580905ae --- /dev/null +++ b/configs/get5/tests/fromfile_maplist_valid.json @@ -0,0 +1,15 @@ +{ + "num_maps": 3, + "maplist": { + "fromfile": "addons/sourcemod/configs/get5/tests/maplist_valid.json" + }, + "team1": { + "name": "Team A", + "players": { + "STEAM_0:1:52245092": "PlayerName" + } + }, + "team2": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + } +} diff --git a/configs/get5/tests/invalid_config.cfg b/configs/get5/tests/invalid_config.cfg new file mode 100644 index 000000000..1dbcb0169 --- /dev/null +++ b/configs/get5/tests/invalid_config.cfg @@ -0,0 +1 @@ +invalid file diff --git a/configs/get5/tests/invalid_config.json b/configs/get5/tests/invalid_config.json new file mode 100644 index 000000000..1dbcb0169 --- /dev/null +++ b/configs/get5/tests/invalid_config.json @@ -0,0 +1 @@ +invalid file diff --git a/configs/get5/tests/maplist_empty.json b/configs/get5/tests/maplist_empty.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/configs/get5/tests/maplist_empty.json @@ -0,0 +1 @@ +[] diff --git a/configs/get5/tests/maplist_invalid.cfg b/configs/get5/tests/maplist_invalid.cfg new file mode 100644 index 000000000..ec8c757fd --- /dev/null +++ b/configs/get5/tests/maplist_invalid.cfg @@ -0,0 +1,6 @@ +"maplist" +{ + "" "" + "de_overpass" "" + "de_inferno" "" +} diff --git a/configs/get5/tests/maplist_invalid_not_array.json b/configs/get5/tests/maplist_invalid_not_array.json new file mode 100644 index 000000000..5006f8cbd --- /dev/null +++ b/configs/get5/tests/maplist_invalid_not_array.json @@ -0,0 +1,4 @@ +{ + "bigtime": "json", + "definitely": "not an array" +} diff --git a/configs/get5/tests/maplist_valid.cfg b/configs/get5/tests/maplist_valid.cfg new file mode 100644 index 000000000..b79fd87a9 --- /dev/null +++ b/configs/get5/tests/maplist_valid.cfg @@ -0,0 +1,6 @@ +"maplist" +{ + "de_ancient" "" + "de_overpass" "" + "de_inferno" "" +} diff --git a/configs/get5/tests/maplist_valid.json b/configs/get5/tests/maplist_valid.json new file mode 100644 index 000000000..bedfc52d2 --- /dev/null +++ b/configs/get5/tests/maplist_valid.json @@ -0,0 +1,5 @@ +[ + "de_ancient", + "de_overpass", + "de_inferno" +] diff --git a/configs/get5/tests/missing_maplist.cfg b/configs/get5/tests/missing_maplist.cfg new file mode 100644 index 000000000..5be92dcf3 --- /dev/null +++ b/configs/get5/tests/missing_maplist.cfg @@ -0,0 +1,11 @@ +"Match" +{ + "team1" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } + "team2" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } +} diff --git a/configs/get5/tests/missing_maplist.json b/configs/get5/tests/missing_maplist.json new file mode 100644 index 000000000..d4599b5ea --- /dev/null +++ b/configs/get5/tests/missing_maplist.json @@ -0,0 +1,8 @@ +{ + "team1": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + }, + "team2": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + } +} diff --git a/configs/get5/tests/missing_team1.cfg b/configs/get5/tests/missing_team1.cfg new file mode 100644 index 000000000..a8898c4ae --- /dev/null +++ b/configs/get5/tests/missing_team1.cfg @@ -0,0 +1,13 @@ +"Match" +{ + "team2" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } + "maplist" + { + "de_dust2" "" + "de_mirage" "" + "de_ancient" "" + } +} diff --git a/configs/get5/tests/missing_team1.json b/configs/get5/tests/missing_team1.json new file mode 100644 index 000000000..6a63d44b4 --- /dev/null +++ b/configs/get5/tests/missing_team1.json @@ -0,0 +1,10 @@ +{ + "team2": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + }, + "maplist": [ + "de_dust2", + "de_mirage", + "de_ancient" + ] +} diff --git a/configs/get5/tests/missing_team2.cfg b/configs/get5/tests/missing_team2.cfg new file mode 100644 index 000000000..c44505b2a --- /dev/null +++ b/configs/get5/tests/missing_team2.cfg @@ -0,0 +1,13 @@ +"Match" +{ + "team1" + { + "fromfile" "addons/sourcemod/configs/get5/tests/team2.cfg" + } + "maplist" + { + "de_dust2" "" + "de_mirage" "" + "de_ancient" "" + } +} diff --git a/configs/get5/tests/missing_team2.json b/configs/get5/tests/missing_team2.json new file mode 100644 index 000000000..8c4b4129a --- /dev/null +++ b/configs/get5/tests/missing_team2.json @@ -0,0 +1,10 @@ +{ + "team1": { + "fromfile": "addons/sourcemod/configs/get5/tests/team2.json" + }, + "maplist": [ + "de_dust2", + "de_mirage", + "de_ancient" + ] +} diff --git a/configs/get5/tests/readme.txt b/configs/get5/tests/readme.txt new file mode 100644 index 000000000..daac74976 --- /dev/null +++ b/configs/get5/tests/readme.txt @@ -0,0 +1,2 @@ +These files are used for testing Get5. You can ignore them or delete the entire "tests" folder. It makes no difference +to your Get5 installation. diff --git a/configs/get5/tests/team2.cfg b/configs/get5/tests/team2.cfg new file mode 100644 index 000000000..315958343 --- /dev/null +++ b/configs/get5/tests/team2.cfg @@ -0,0 +1,13 @@ +"Team" +{ + "name" "Team B Default" + "tag" "TAG-FF" + "flag" "DE" + "logo" "fromfile_team" + "players" + { + "STEAM_0:1:52351591" "PlayerBName1" + "STEAM_0:1:52351094" "PlayerBName2" + "STEAM_0:1:52351195" "PlayerBName3" + } +} diff --git a/configs/get5/tests/team2.json b/configs/get5/tests/team2.json new file mode 100644 index 000000000..ef0f4ab8c --- /dev/null +++ b/configs/get5/tests/team2.json @@ -0,0 +1,11 @@ +{ + "name": "Team B Default", + "tag": "TAG-FF", + "flag": "DE", + "logo": "fromfile_team", + "players": { + "STEAM_0:1:52351591": "PlayerBName1", + "STEAM_0:1:52351094": "PlayerBName2", + "STEAM_0:1:52351195": "PlayerBName3" + } +} diff --git a/configs/get5/tests/team2_array.json b/configs/get5/tests/team2_array.json new file mode 100644 index 000000000..b012a415e --- /dev/null +++ b/configs/get5/tests/team2_array.json @@ -0,0 +1,12 @@ +{ + "name": "Team B Array", + "tag": "TAG-FA", + "flag": "SE", + "logo": "fromfile_team_array", + "players": [ + "STEAM_0:1:52381591", + "STEAM_0:1:52381094", + "STEAM_0:1:52381195", + "STEAM_0:1:52381197" + ] +} diff --git a/documentation/docs/match_schema.md b/documentation/docs/match_schema.md index 591d62361..32037027d 100644 --- a/documentation/docs/match_schema.md +++ b/documentation/docs/match_schema.md @@ -118,15 +118,13 @@ interface Get5Match { if [`sv_coaching_enabled`](https://totalcsgo.com/command/svcoachingenabled) is disabled, anyone defined as a coach will be considered a regular player for the team instead.

**`Default: undefined`** 24. _Required_
The players on the team. -25. _Optional_
Wrapper of the server's `mp_teammatchstat_txt` cvar, but can use `{MAPNUMBER}` and `{MAXMAPS}` as - variables that get replaced with their integer values. In a BoX series, you probably don't want to set this since - Get5 automatically sets `mp_teamscore` cvars for the current series score, and take the place of - the `mp_teammatchstat` cvars.

**`Default: "Map {MAPNUMBER} of {MAXMAPS}"`** +25. _Optional_
Sets the server's `mp_teammatchstat_txt` ConVar, but lets you use `{MAPNUMBER}` and `{MAXMAPS}` as + variables that get replaced with their integer values. You should **not** set `mp_teammatchstat_txt` yourself, as it + will be overridden by this parameter.

**`Default: "Map {MAPNUMBER} of {MAXMAPS}"`** 26. _Optional_
The current score in the series, this can be used to give a team a map advantage or used as a manual backup method.

**`Default: 0`** -27. _Optional_
Wraps `mp_teammatchstat_1` and `mp_teammatchstat_2`. You probably don't want to set this, in BoX - series, `mp_teamscore` cvars are automatically set and take the place of the `mp_teammatchstat_x` - cvars.

**`Default: ""`** +27. _Optional_
Assigns values to `mp_teammatchstat_1` and `mp_teammatchstat_2`, respectively. If you don't set this + value in a BoX series, it is set to each team's map series score automatically.

**`Default: ""`** 28. Match teams can also be loaded from a separate file, allowing you to easily re-use a match configuration for different sets of teams. A `fromfile` value could be `"addons/sourcemod/configs/get5/team_nip.json"`, and is always relative to the `csgo` directory. The file should contain a valid `Get5MatchTeam` object. You **are** allowed to mix @@ -383,7 +381,7 @@ These examples are identical in the way they would work if loaded. ``` `fromfile` example: ```cfg title="addons/sourcemod/get5/team_navi.cfg" - Team + "Team" { "name" "Natus Vincere" "tag" "NaVi" diff --git a/scripting/get5.sp b/scripting/get5.sp index 9df320959..e48907e77 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -143,7 +143,7 @@ int g_RoundNumber = -1; // The round number, 0-indexed. -1 if the match is not int g_MapNumber = 0; // the current map number, starting at 0. int g_NumberOfMapsInSeries = 0; // the number of maps to play in the series. char g_MatchID[MATCH_ID_LENGTH]; -ArrayList g_MapPoolList = null; +ArrayList g_MapPoolList; ArrayList g_TeamPlayers[MATCHTEAM_COUNT]; ArrayList g_TeamCoaches[MATCHTEAM_COUNT]; StringMap g_PlayerNames; @@ -169,8 +169,8 @@ Get5BombSite g_BombSiteLastPlanted = Get5BombSite_Unknown; bool g_SkipVeto = false; float g_VetoMenuTime = 0.0; MatchSideType g_MatchSideType = MatchSideType_Standard; -ArrayList g_CvarNames = null; -ArrayList g_CvarValues = null; +ArrayList g_CvarNames; +ArrayList g_CvarValues; bool g_InScrimMode = false; /** Knife for sides **/ @@ -184,6 +184,7 @@ Handle g_KnifeCountdownTimer = INVALID_HANDLE; bool g_IsChangingPauseState = false; // Used to prevent mp_pause_match and mp_unpause_match from being called directly. Get5Team g_PausingTeam = Get5Team_None; // The team that last called for a pause. Get5PauseType g_PauseType = Get5PauseType_None; // The type of pause last initiated. +Handle g_PauseTimer = INVALID_HANDLE; int g_LatestPauseDuration = 0; bool g_TeamReadyForUnpause[MATCHTEAM_COUNT]; bool g_TeamGivenStopCommand[MATCHTEAM_COUNT]; @@ -203,12 +204,13 @@ Get5Team g_ForfeitingTeam = Get5Team_None; /** Other state **/ Get5State g_GameState = Get5State_None; -ArrayList g_MapsToPlay = null; -ArrayList g_MapSides = null; -ArrayList g_MapsLeftInVetoPool = null; +ArrayList g_MapsToPlay; +ArrayList g_MapSides; +ArrayList g_MapsLeftInVetoPool; Get5Team g_LastVetoTeam; -Menu g_ActiveVetoMenu = null; +Menu g_ActiveVetoMenu; Handle g_InfoTimer = INVALID_HANDLE; +Handle g_MatchConfigExecTimer = INVALID_HANDLE; /** Backup data **/ bool g_DoingBackupRestoreNow = false; @@ -967,7 +969,8 @@ bool CheckAutoLoadConfig() { char autoloadConfig[PLATFORM_MAX_PATH]; g_AutoLoadConfigCvar.GetString(autoloadConfig, sizeof(autoloadConfig)); if (!StrEqual(autoloadConfig, "")) { - bool loaded = LoadMatchConfig(autoloadConfig); // return false if match config load fails! + char error[PLATFORM_MAX_PATH]; + bool loaded = LoadMatchConfig(autoloadConfig, error); // return false if match config load fails! if (loaded) { LogMessage("Match configuration was loaded via get5_autoload_config."); } @@ -1038,17 +1041,19 @@ static Action Command_EndMatch(int client, int args) { static Action Command_LoadMatch(int client, int args) { if (g_GameState != Get5State_None) { ReplyToCommand(client, "Cannot load a match config when another is already loaded."); - return; + return Plugin_Handled; } char arg[PLATFORM_MAX_PATH]; if (args >= 1 && GetCmdArg(1, arg, sizeof(arg))) { - if (!LoadMatchConfig(arg)) { - ReplyToCommand(client, "Failed to load match config."); + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchConfig(arg, error)) { + ReplyToCommand(client, error); } } else { ReplyToCommand(client, "Usage: get5_loadmatch "); } + return Plugin_Handled; } static Action Command_LoadMatchUrl(int client, int args) { @@ -1166,7 +1171,9 @@ void RestoreLastRound(int client) { g_LastGet5BackupCvar.GetString(lastBackup, sizeof(lastBackup)); if (!StrEqual(lastBackup, "")) { if (RestoreFromBackup(lastBackup)) { - Get5_MessageToAll("%t", "BackupLoadedInfoMessage", lastBackup); + char fileFormatted[PLATFORM_MAX_PATH]; + FormatCvarName(fileFormatted, sizeof(fileFormatted), lastBackup); + Get5_MessageToAll("%t", "BackupLoadedInfoMessage", fileFormatted); // Fix the last backup cvar since it gets reset. g_LastGet5BackupCvar.SetString(lastBackup); } else { @@ -1362,7 +1369,6 @@ void EndSeries(Get5Team winningTeam, bool printWinnerMessage, float restoreDelay EventLogger_LogAndDeleteEvent(event); ChangeState(Get5State_None); - g_MatchID = ""; // We don't want to kick players until after the specified delay, as it will kick casters // potentially before GOTV ends. @@ -1410,11 +1416,80 @@ void EndSeries(Get5Team winningTeam, bool printWinnerMessage, float restoreDelay g_ActiveVetoMenu.Cancel(); } + // If a config exec callback is in progress, stop it; + if (g_MatchConfigExecTimer != INVALID_HANDLE) { + LogDebug("Killing g_MatchConfigExecTimer as match was ended."); + delete g_MatchConfigExecTimer; + } + // If a forfeit by disconnect is counting down and the match ends, ensure that no timer is running so a new game // won't be forfeited if it is started before the timer runs out. // Also end vote-to-surrender timers. ResetForfeitTimer(); EndSurrenderTimers(); + if (IsPaused()) { + UnpauseGame(Get5Team_None); + } + ResetMatchConfigVariables(false); +} + +void ResetMatchConfigVariables(bool backup = false) { + // Resets all match config variables and parameter used to track game state when Get5 is running. + g_InScrimMode = false; + g_MatchID = ""; + g_SkipVeto = false; + g_MatchSideType = MatchSideType_Standard; + g_MapsToWin = 1; + g_SeriesCanClinch = true; + g_LastVetoTeam = Get5Team_2; + g_NumberOfMapsInSeries = 0; + g_MapPoolList.Clear(); + g_PlayerNames.Clear(); + g_MapsToPlay.Clear(); + g_MapSides.Clear(); + g_MapsLeftInVetoPool.Clear(); + g_TeamScoresPerMap.Clear(); + for (int i = 0; i < MATCHTEAM_COUNT; i++) { + g_TeamNames[i] = ""; + g_TeamTags[i] = ""; + g_FormattedTeamNames[i] = ""; + g_TeamFlags[i] = ""; + g_TeamLogos[i] = ""; + g_TeamMatchTexts[i] = ""; + g_TeamPlayers[i].Clear(); + g_TeamCoaches[i].Clear(); + g_TeamSeriesScores[i] = 0; + g_TeamReadyForUnpause[i] = false; + g_TeamGivenStopCommand[i] = false; + if (!backup) { + g_TacticalPauseTimeUsed[i] = 0; + g_TacticalPausesUsed[i] = 0; + g_TechnicalPausesUsed[i] = 0; + } + } + g_FavoredTeamPercentage = 0; + g_FavoredTeamText = ""; + g_PlayersPerTeam = 5; + g_CoachesPerTeam = 2; + g_MinPlayersToReady = 1; + g_CoachesMustReady = false; + g_MinSpectatorsToReady = 0; + g_ReadyTimeWaitingUsed = 0; + g_HasKnifeRoundStarted = false; + g_KnifeWinnerTeam = Get5Team_None; + g_RoundStartedTime = 0.0; + g_BombPlantedTime = 0.0; + g_BombSiteLastPlanted = Get5BombSite_Unknown; + g_RoundNumber = -1; + g_MapNumber = 0; + g_PausingTeam = Get5Team_None; + g_LatestPauseDuration = 0; + g_PauseType = Get5PauseType_None; + if (!backup) { + // All hell breaks loose if these are reset during a backup. + g_DoingBackupRestoreNow = false; + g_MapChangePending = false; + } } static Action Timer_KickOnEnd(Handle timer) { diff --git a/scripting/get5/backups.sp b/scripting/get5/backups.sp index bdb9237c3..78a8eb0c1 100644 --- a/scripting/get5/backups.sp +++ b/scripting/get5/backups.sp @@ -21,7 +21,9 @@ Action Command_LoadBackup(int client, int args) { char path[PLATFORM_MAX_PATH]; if (args >= 1 && GetCmdArg(1, path, sizeof(path))) { if (RestoreFromBackup(path)) { - Get5_MessageToAll("%t", "BackupLoadedInfoMessage", path); + char fileFormatted[PLATFORM_MAX_PATH]; + FormatCvarName(fileFormatted, sizeof(fileFormatted), path); + Get5_MessageToAll("%t", "BackupLoadedInfoMessage", fileFormatted); g_LastGet5BackupCvar.SetString(path); } else { ReplyToCommand(client, "Failed to load backup %s - check error logs", path); @@ -246,26 +248,32 @@ static void WriteBackupStructure(const char[] path) { WriteMatchToKv(kv); kv.GoBack(); - ConVar lastBackupCvar = FindConVar("mp_backup_round_file_last"); - if (lastBackupCvar != null) { + if (g_GameState == Get5State_Live) { char lastBackup[PLATFORM_MAX_PATH]; + ConVar lastBackupCvar = FindConVar("mp_backup_round_file_last"); lastBackupCvar.GetString(lastBackup, sizeof(lastBackup)); - if (strlen(lastBackup) > 0) { - if (g_GameState == Get5State_Live) { - // Write valve's backup format into the file. This only applies to live rounds, as any pre-live - // backups should just restart the game to warmup (post-veto). - KeyValues valveBackup = new KeyValues("valve_backup"); - if (valveBackup.ImportFromFile(lastBackup)) { - kv.SetNum("gamestate", view_as(Get5State_Live)); - kv.JumpToKey("valve_backup", true); - KvCopySubkeys(valveBackup, kv); - kv.GoBack(); - } - delete valveBackup; - } - if (DeleteFile(lastBackup)) { - lastBackupCvar.SetString(""); - } + if (strlen(lastBackup) == 0) { + LogError("Found no Valve backup when attempting to write a backup during the live state. This is a bug!"); + delete kv; + return; + } + // Write valve's backup format into the file. This only applies to live rounds, as any pre-live + // backups should just restart the game to warmup (post-veto). + KeyValues valveBackup = new KeyValues("valve_backup"); + bool success = valveBackup.ImportFromFile(lastBackup); + if (success) { + kv.SetNum("gamestate", view_as(Get5State_Live)); + kv.JumpToKey("valve_backup", true); + KvCopySubkeys(valveBackup, kv); + kv.GoBack(); + } + delete valveBackup; + if (!success) { + LogError("Failed to import Valve backup into Get5 backup."); + delete kv; + } + if (DeleteFile(lastBackup)) { + lastBackupCvar.SetString(""); } } @@ -274,7 +282,9 @@ static void WriteBackupStructure(const char[] path) { KvCopySubkeys(g_StatsKv, kv); kv.GoBack(); - kv.ExportToFile(path); + if (!kv.ExportToFile(path)) { + LogError("Failed to write Get5 backup to file \"%s\".", path); + } delete kv; } @@ -338,9 +348,9 @@ bool RestoreFromBackup(const char[] path) { char tempBackupFile[PLATFORM_MAX_PATH]; GetTempFilePath(tempBackupFile, sizeof(tempBackupFile), TEMP_MATCHCONFIG_BACKUP_PATTERN); kv.ExportToFile(tempBackupFile); - if (!LoadMatchConfig(tempBackupFile, true)) { + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchConfig(tempBackupFile, error, true)) { delete kv; - LogError("Could not restore from match config \"%s\"", tempBackupFile); // If the backup load fails, all the game configs will have been reset by LoadMatchConfig, // but the game state won't. This ensures we don't end up a in a "live" state with no get5 // variables set, which would prevent a call to load a new match. @@ -440,18 +450,9 @@ bool RestoreFromBackup(const char[] path) { ChangeState(valveBackup ? Get5State_PendingRestore : Get5State_Warmup); ChangeMap(loadedMapName, 3.0); } else { - // We must assign players to their teams. This is normally done inside LoadMatchConfig, but - // since we need the team sides to be applied from the backup, we skip it then and do it here. - if (g_CheckAuthsCvar.BoolValue) { - LOOP_CLIENTS(i) { - if (IsPlayer(i)) { - CheckClientTeam(i); - } - } - } if (valveBackup) { // Same map, but round restore with a Valve backup; do normal restore immediately with no - // ready-up and no game-state change. + // ready-up and no game-state change. Players' teams are checked after the backup file is loaded. RestoreGet5Backup(shouldRestartRecording); } else { // We are restarting to the same map for prelive; just go back into warmup and let players @@ -461,6 +462,18 @@ bool RestoreFromBackup(const char[] path) { ChangeState(Get5State_Warmup); ExecCfg(g_WarmupCfgCvar); StartWarmup(); + // We must assign players to their teams. This is normally done inside LoadMatchConfig, but + // since we need the team sides to be applied from the backup, we skip it then and do it here. + // We *do not* do this before loading from a valve backup, as it will kill every player on the wrong + // team and cause various events to misbehave. This is also why it comes after the Get5State_Warmup + // state change above, to suppress all live events. + if (g_CheckAuthsCvar.BoolValue) { + LOOP_CLIENTS(i) { + if (IsPlayer(i)) { + CheckClientTeam(i); + } + } + } } } @@ -479,6 +492,8 @@ bool RestoreFromBackup(const char[] path) { } void RestoreGet5Backup(bool restartRecording) { + g_DoingBackupRestoreNow = true; // reset after the backup has completed, suppresses various + // events and hooks until then. // If you load a backup during a live round, the game might get stuck if there are only bots // remaining and no players are alive. Other stuff will probably also go wrong, so we put the game // into warmup. We **cannot** restart the game as that causes problems for tournaments using the @@ -488,8 +503,6 @@ void RestoreGet5Backup(bool restartRecording) { } ExecCfg(g_LiveCfgCvar); PauseGame(Get5Team_None, Get5PauseType_Backup); - g_DoingBackupRestoreNow = true; // reset after the backup has completed, suppresses various - // events and hooks until then. // We add a 2 second delay here to give the server time to // flush the current GOTV recording *if* one is running. CreateTimer(2.0, Timer_StartRestore, restartRecording, TIMER_FLAG_NO_MAPCHANGE); diff --git a/scripting/get5/jsonhelpers.sp b/scripting/get5/jsonhelpers.sp index 9dd4d0090..4f8d88d60 100644 --- a/scripting/get5/jsonhelpers.sp +++ b/scripting/get5/jsonhelpers.sp @@ -68,25 +68,6 @@ stock float json_object_get_float_safe(JSON_Object json, const char[] key, float } } -// Used for parsing an Array[String] to a sourcepawn ArrayList of strings -stock int AddJsonSubsectionArrayToList(JSON_Object json, const char[] key, ArrayList list, int maxValueLength) { - if (!json_has_key(json, key, JSON_Type_Object)) { - return 0; - } - - int count = 0; - JSON_Array array = view_as(json.GetObject(key)); - if (array != null) { - char[] buffer = new char[maxValueLength]; - for (int i = 0; i < array.Length; i++) { - array.GetString(i, buffer, maxValueLength); - list.PushString(buffer); - count++; - } - } - return count; -} - // Used for mapping a keyvalue section stock int AddJsonAuthsToList(JSON_Object json, const char[] key, ArrayList list, int maxValueLength) { int count = 0; diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 2828e7856..f727bdfe6 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -15,59 +15,39 @@ #define CONFIG_VETOFIRST_DEFAULT "team1" #define CONFIG_SIDETYPE_DEFAULT "standard" -bool LoadMatchConfig(const char[] config, bool restoreBackup = false) { +bool LoadMatchConfig(const char[] config, char[] error, bool restoreBackup = false) { if (g_GameState != Get5State_None && !restoreBackup) { + Format(error, PLATFORM_MAX_PATH, "Cannot load a match configuration when a match is already loaded."); + MatchConfigFail(error); return false; } EndSurrenderTimers(); ResetForfeitTimer(); - + ResetMatchConfigVariables(restoreBackup); ResetReadyStatus(); - LOOP_TEAMS(team) { - g_TeamSeriesScores[team] = 0; - g_TeamReadyForUnpause[team] = false; - g_TeamGivenStopCommand[team] = false; - // We only reset these on a new game. - // During restore we want to keep our - // current pauses used. - if (!restoreBackup) { - g_TacticalPauseTimeUsed[team] = 0; - g_TacticalPausesUsed[team] = 0; - g_TechnicalPausesUsed[team] = 0; - } - ClearArray(GetTeamCoaches(team)); - ClearArray(GetTeamPlayers(team)); - } - - g_MatchID = ""; - g_ReadyTimeWaitingUsed = 0; - g_HasKnifeRoundStarted = false; - g_KnifeWinnerTeam = Get5Team_None; - g_MapChangePending = false; - g_MapNumber = 0; - g_NumberOfMapsInSeries = 0; - g_RoundNumber = -1; - g_LastVetoTeam = Get5Team_2; - g_MapPoolList.Clear(); - g_MapsLeftInVetoPool.Clear(); - g_MapsToPlay.Clear(); - g_MapSides.Clear(); + g_CvarNames.Clear(); g_CvarValues.Clear(); - g_TeamScoresPerMap.Clear(); g_LastGet5BackupCvar.SetString(""); CloseCvarStorage(g_KnifeChangedCvars); CloseCvarStorage(g_MatchConfigChangedCvars); - if (!restoreBackup) { + if (g_GameState == Get5State_None) { // Loading a backup should not override this, as the original hostname pre-Get5 will then be lost. GetConVarStringSafe("hostname", g_HostnamePreGet5, sizeof(g_HostnamePreGet5)); } - if (!LoadMatchFile(config)) { + if (!LoadMatchFile(config, error)) { + MatchConfigFail(error); + return false; + } + + if (g_NumberOfMapsInSeries > g_MapPoolList.Length) { + FormatEx(error, PLATFORM_MAX_PATH, "Cannot play a series of %d maps with a maplist of only %d maps.", g_NumberOfMapsInSeries, g_MapPoolList.Length); + MatchConfigFail(error); return false; } @@ -81,12 +61,6 @@ bool LoadMatchConfig(const char[] config, bool restoreBackup = false) { g_TeamScoresPerMap.Set(g_TeamScoresPerMap.Length - 1, 0, 1); } - if (g_NumberOfMapsInSeries > g_MapPoolList.Length) { - MatchConfigFail("Cannot play a series of %d maps with a maplist of %d maps", g_NumberOfMapsInSeries, - g_MapPoolList.Length); - return false; - } - if (g_SkipVeto) { // Copy the first k maps from the maplist to the final match maps. for (int i = 0; i < g_NumberOfMapsInSeries; i++) { @@ -130,10 +104,10 @@ bool LoadMatchConfig(const char[] config, bool restoreBackup = false) { // depends on it. We set this one first as the others may depend on something changed in the match // cvars section. ExecuteMatchConfigCvars(); + SetStartingTeams(); // must go before SetMatchTeamCvars as it depends on correct starting teams! SetMatchTeamCvars(); LoadPlayerNames(); AddTeamLogosToDownloadTable(); - SetStartingTeams(); UpdateHostname(); // Set mp_backup_round_file to prevent backup file collisions @@ -202,7 +176,7 @@ static Action Timer_PlacePlayerFromTeamNone(Handle timer, int client) { } } -static bool LoadMatchFile(const char[] config) { +static bool LoadMatchFile(const char[] config, char[] error) { Get5PreloadMatchConfigEvent event = new Get5PreloadMatchConfigEvent(config); LogDebug("Calling Get5_OnPreLoadMatchConfig()"); @@ -214,40 +188,35 @@ static bool LoadMatchFile(const char[] config) { EventLogger_LogAndDeleteEvent(event); if (!FileExists(config)) { - MatchConfigFail("Match config file doesn't exist: \"%s\"", config); + FormatEx(error, PLATFORM_MAX_PATH, "Match config file doesn't exist: \"%s\".", config); return false; } + bool success = false; if (IsJSONPath(config)) { - JSON_Object json = json_read_from_file(config); + JSON_Object json = json_read_from_file(config, JSON_DECODE_ORDERED_KEYS); if (json == null) { - MatchConfigFail("Failed to read match config as JSON."); - return false; - } - - if (!LoadMatchFromJson(json)) { // This prints its own error + FormatEx(error, PLATFORM_MAX_PATH, "Failed to read match config from file \"%s\" as JSON.", config); + } else { + success = LoadMatchFromJson(json, error); json_cleanup_and_delete(json); - return false; } - json_cleanup_and_delete(json); - } else { // Assume its a key-values file. - KeyValues kv = new KeyValues("Match"); - if (!kv.ImportFromFile(config)) { - delete kv; - MatchConfigFail("Failed to read match config as KV."); - return false; - } - - if (!LoadMatchFromKv(kv)) { // This prints its own error + char parseError[PLATFORM_MAX_PATH]; + if (!CheckKeyValuesFile(config, parseError, sizeof(parseError))) { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to read match config from file \"%s\" as KV: %s", config, parseError); + } else { + KeyValues kv = new KeyValues("Match"); + if (!kv.ImportFromFile(config)) { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to import match config from file \"%s\".", config); + } else { + success = LoadMatchFromKeyValue(kv, error); + } delete kv; - return false; } - delete kv; } - - return true; + return success; } static void MatchConfigFail(const char[] reason, any...) { @@ -358,10 +327,10 @@ static int SteamWorks_OnMatchConfigReceived(Handle request, bool failure, bool r } char remoteConfig[PLATFORM_MAX_PATH]; + char error[PLATFORM_MAX_PATH]; GetTempFilePath(remoteConfig, sizeof(remoteConfig), REMOTE_CONFIG_PATTERN); if (SteamWorks_WriteHTTPResponseBodyToFile(request, remoteConfig)) { - if (LoadMatchConfig(remoteConfig)) { - // LoadMatchConfig prints its own error via MatchConfigFail. + if (LoadMatchConfig(remoteConfig, error)) { // Override g_LoadedConfigFile to point to the URL instead of the local temp file. strcopy(g_LoadedConfigFile, sizeof(g_LoadedConfigFile), loadedUrl); // We only delete the file if it loads successfully, as it may be used for debugging otherwise. @@ -403,17 +372,11 @@ void WriteMatchToKv(KeyValues kv) { } kv.GoBack(); - kv.JumpToKey("team1", true); - AddTeamBackupData(kv, Get5Team_1); - kv.GoBack(); - - kv.JumpToKey("team2", true); - AddTeamBackupData(kv, Get5Team_2); - kv.GoBack(); - - kv.JumpToKey("spectators", true); - AddTeamBackupData(kv, Get5Team_Spec); - kv.GoBack(); + char auth[AUTH_LENGTH]; + char name[MAX_NAME_LENGTH]; + AddTeamBackupData("team1", kv, Get5Team_1, auth, name); + AddTeamBackupData("team2", kv, Get5Team_2, auth, name); + AddTeamBackupData("spectators", kv, Get5Team_Spec, auth, name); kv.JumpToKey("cvars", true); char cvarName[MAX_CVAR_LENGTH]; @@ -426,55 +389,45 @@ void WriteMatchToKv(KeyValues kv) { kv.GoBack(); } -static void AddTeamBackupData(KeyValues kv, Get5Team team) { - kv.JumpToKey("players", true); - char auth[AUTH_LENGTH]; - char name[MAX_NAME_LENGTH]; - for (int i = 0; i < GetTeamPlayers(team).Length; i++) { - GetTeamPlayers(team).GetString(i, auth, sizeof(auth)); - if (!g_PlayerNames.GetString(auth, name, sizeof(name))) { - strcopy(name, sizeof(name), KEYVALUE_STRING_PLACEHOLDER); - } - kv.SetString(auth, name); - } - kv.GoBack(); - +static void AddTeamBackupData(const char[] key, const KeyValues kv, const Get5Team team, char[] auth, char[] name) { + kv.JumpToKey(key, true); + WritePlayerDataToKV("players", GetTeamPlayers(team), kv, auth, name); kv.SetString("name", g_TeamNames[team]); if (team != Get5Team_Spec) { kv.SetString("tag", g_TeamTags[team]); kv.SetString("flag", g_TeamFlags[team]); kv.SetString("logo", g_TeamLogos[team]); kv.SetString("matchtext", g_TeamMatchTexts[team]); - kv.JumpToKey("coaches", true); - for (int i = 0; i < GetTeamCoaches(team).Length; i++) { - GetTeamCoaches(team).GetString(i, auth, sizeof(auth)); - if (!g_PlayerNames.GetString(auth, name, sizeof(name))) { - strcopy(name, sizeof(name), KEYVALUE_STRING_PLACEHOLDER); - } - kv.SetString(auth, name); + WritePlayerDataToKV("coaches", GetTeamCoaches(team), kv, auth, name); + } + kv.GoBack(); +} + +static void WritePlayerDataToKV(const char[] key, const ArrayList players, const KeyValues kv, char[] auth, char[] name) { + kv.JumpToKey(key, true); + for (int i = 0; i < players.Length; i++) { + players.GetString(i, auth, AUTH_LENGTH); + if (!g_PlayerNames.GetString(auth, name, MAX_NAME_LENGTH)) { + strcopy(name, MAX_NAME_LENGTH, KEYVALUE_STRING_PLACEHOLDER); } - kv.GoBack(); + kv.SetString(auth, name); } + kv.GoBack(); } -static bool LoadMatchFromKv(KeyValues kv) { +static bool LoadMatchFromKeyValue(KeyValues kv, char[] error) { kv.GetString("matchid", g_MatchID, sizeof(g_MatchID), CONFIG_MATCHID_DEFAULT); g_InScrimMode = kv.GetNum("scrim") != 0; + g_SeriesCanClinch = kv.GetNum("clinch_series", CONFIG_CLINCH_SERIES_DEFAULT) != 0; kv.GetString("match_title", g_MatchTitle, sizeof(g_MatchTitle), CONFIG_MATCHTITLE_DEFAULT); g_PlayersPerTeam = kv.GetNum("players_per_team", CONFIG_PLAYERSPERTEAM_DEFAULT); - g_SeriesCanClinch = kv.GetNum("clinch_series", CONFIG_CLINCH_SERIES_DEFAULT) != 0; g_CoachesPerTeam = kv.GetNum("coaches_per_team", CONFIG_COACHESPERTEAM_DEFAULT); g_MinPlayersToReady = kv.GetNum("min_players_to_ready", CONFIG_MINPLAYERSTOREADY_DEFAULT); g_MinSpectatorsToReady = kv.GetNum("min_spectators_to_ready", CONFIG_MINSPECTATORSTOREADY_DEFAULT); g_SkipVeto = kv.GetNum("skip_veto", CONFIG_SKIPVETO_DEFAULT) != 0; g_CoachesMustReady = kv.GetNum("coaches_must_ready", CONFIG_COACHES_MUST_READY_DEFAULT) != 0; - g_NumberOfMapsInSeries = kv.GetNum("num_maps", CONFIG_NUM_MAPSDEFAULT); - g_MapsToWin = MapsToWin(g_NumberOfMapsInSeries); - if (g_NumberOfMapsInSeries != 2 && g_NumberOfMapsInSeries % 2 == 0) { - MatchConfigFail("Cannot create a series of %d maps. Use an odd number or 2.", g_NumberOfMapsInSeries); - return false; - } + g_MapsToWin = g_SeriesCanClinch ? MapsToWin(g_NumberOfMapsInSeries) : g_NumberOfMapsInSeries; char vetoFirstBuffer[64]; kv.GetString("veto_first", vetoFirstBuffer, sizeof(vetoFirstBuffer), CONFIG_VETOFIRST_DEFAULT); @@ -489,36 +442,38 @@ static bool LoadMatchFromKv(KeyValues kv) { GetTeamPlayers(Get5Team_Spec).Clear(); if (kv.JumpToKey("spectators")) { - if (!LoadTeamData(kv, Get5Team_Spec, true)) { + if (!LoadTeamDataKeyValue(kv, Get5Team_Spec, error, true)) { return false; } kv.GoBack(); } - if (kv.JumpToKey("team1")) { - if (!LoadTeamData(kv, Get5Team_1, true)) { - return false; - } - kv.GoBack(); - } else { - MatchConfigFail("Missing \"team1\" section in match config KeyValues."); + if (!kv.JumpToKey("team1")) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"team1\" section in match config KeyValues."); return false; } + if (!LoadTeamDataKeyValue(kv, Get5Team_1, error, true)) { + return false; + } + kv.GoBack(); - if (kv.JumpToKey("team2")) { - if (!LoadTeamData(kv, Get5Team_2, true)) { - return false; - } - kv.GoBack(); - } else { - MatchConfigFail("Missing \"team2\" section in match config KeyValues."); + if (!kv.JumpToKey("team2")) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"team2\" section in match config KeyValues."); return false; } + if (!LoadTeamDataKeyValue(kv, Get5Team_2, error, true)) { + return false; + } + kv.GoBack(); - if (AddSubsectionKeysToList(kv, "maplist", g_MapPoolList, PLATFORM_MAX_PATH) <= 0) { - LogMessage("Failed to find \"maplist\" section in config, using fallback maplist."); - LoadDefaultMapList(g_MapPoolList); + if (!kv.JumpToKey("maplist")) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"maplist\" section in match config KeyValues."); + return false; } + if (!LoadMapListKeyValue(kv, error, true)) { + return false; + } + kv.GoBack(); if (g_SkipVeto) { if (kv.JumpToKey("map_sides")) { @@ -552,26 +507,19 @@ static bool LoadMatchFromKv(KeyValues kv) { return true; } -static bool LoadMatchFromJson(JSON_Object json) { +static bool LoadMatchFromJson(const JSON_Object json, char[] error) { json_object_get_string_safe(json, "matchid", g_MatchID, sizeof(g_MatchID), CONFIG_MATCHID_DEFAULT); g_InScrimMode = json_object_get_bool_safe(json, "scrim", false); g_SeriesCanClinch = json_object_get_bool_safe(json, "clinch_series", true); json_object_get_string_safe(json, "match_title", g_MatchTitle, sizeof(g_MatchTitle), CONFIG_MATCHTITLE_DEFAULT); - g_PlayersPerTeam = json_object_get_int_safe(json, "players_per_team", CONFIG_PLAYERSPERTEAM_DEFAULT); g_CoachesPerTeam = json_object_get_int_safe(json, "coaches_per_team", CONFIG_COACHESPERTEAM_DEFAULT); g_MinPlayersToReady = json_object_get_int_safe(json, "min_players_to_ready", CONFIG_MINPLAYERSTOREADY_DEFAULT); - g_MinSpectatorsToReady = - json_object_get_int_safe(json, "min_spectators_to_ready", CONFIG_MINSPECTATORSTOREADY_DEFAULT); + g_MinSpectatorsToReady = json_object_get_int_safe(json, "min_spectators_to_ready", CONFIG_MINSPECTATORSTOREADY_DEFAULT); g_SkipVeto = json_object_get_bool_safe(json, "skip_veto", CONFIG_SKIPVETO_DEFAULT); g_CoachesMustReady = json_object_get_bool_safe(json, "coaches_must_ready", CONFIG_COACHES_MUST_READY_DEFAULT); - g_NumberOfMapsInSeries = json_object_get_int_safe(json, "num_maps", CONFIG_NUM_MAPSDEFAULT); - g_MapsToWin = MapsToWin(g_NumberOfMapsInSeries); - if (g_NumberOfMapsInSeries != 2 && g_NumberOfMapsInSeries % 2 == 0) { - MatchConfigFail("Cannot create a series of %d maps. Use an odd number or 2.", g_NumberOfMapsInSeries); - return false; - } + g_MapsToWin = g_SeriesCanClinch ? MapsToWin(g_NumberOfMapsInSeries) : g_NumberOfMapsInSeries; char vetoFirstBuffer[64]; json_object_get_string_safe(json, "veto_first", vetoFirstBuffer, sizeof(vetoFirstBuffer), CONFIG_VETOFIRST_DEFAULT); @@ -584,42 +532,43 @@ static bool LoadMatchFromJson(JSON_Object json) { json_object_get_string_safe(json, "favored_percentage_text", g_FavoredTeamText, sizeof(g_FavoredTeamText)); g_FavoredTeamPercentage = json_object_get_int_safe(json, "favored_percentage_team1", 0); - GetTeamPlayers(Get5Team_Spec).Clear(); JSON_Object spec = json.GetObject("spectators"); - if (spec != null && !LoadTeamDataJson(spec, Get5Team_Spec, true)) { + if (spec != null && !LoadTeamDataJson(spec, Get5Team_Spec, error, true)) { return false; } JSON_Object team1 = json.GetObject("team1"); - if (team1 != null) { - if (!LoadTeamDataJson(team1, Get5Team_1, true)) { - return false; - } - } else { - MatchConfigFail("Missing \"team1\" section in match config JSON."); + if (team1 == null) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"team1\" section in match config JSON."); + return false; + } + if (!LoadTeamDataJson(team1, Get5Team_1, error, true)) { return false; } JSON_Object team2 = json.GetObject("team2"); - if (team2 != null) { - if (!LoadTeamDataJson(team2, Get5Team_2, true)) { - return false; - } - } else { - MatchConfigFail("Missing \"team2\" section in match config JSON."); + if (team2 == null) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"team2\" section in match config JSON."); + return false; + } + if (!LoadTeamDataJson(team2, Get5Team_2, error, true)) { return false; } - if (AddJsonSubsectionArrayToList(json, "maplist", g_MapPoolList, PLATFORM_MAX_PATH) <= 0) { - LogMessage("Failed to find \"maplist\" array in match json, using fallback maplist."); - LoadDefaultMapList(g_MapPoolList); + JSON_Object mapList = json.GetObject("maplist"); + if (mapList == null) { + FormatEx(error, PLATFORM_MAX_PATH, "Missing \"maplist\" section in match config JSON."); + return false; + } + if (!LoadMapListJson(mapList, error, true)) { + return false; } if (g_SkipVeto) { JSON_Array array = view_as(json.GetObject("map_sides")); if (array != null) { if (!array.IsArray) { - MatchConfigFail("Expected \"map_sides\" section to be an array"); + FormatEx(error, PLATFORM_MAX_PATH, "Expected \"map_sides\" section to be an array, found object."); return false; } for (int i = 0; i < array.Length; i++) { @@ -652,7 +601,7 @@ static bool LoadMatchFromJson(JSON_Object json) { } else if (type == JSON_Type_String) { cvars.GetString(cvarName, cvarValue, sizeof(cvarValue)); } else { - MatchConfigFail("Expected \"cvars\" section to contain only strings or numbers."); + FormatEx(error, PLATFORM_MAX_PATH, "Expected \"cvars\" section to contain only strings or numbers."); return false; } g_CvarNames.PushString(cvarName); @@ -663,8 +612,99 @@ static bool LoadMatchFromJson(JSON_Object json) { return true; } -static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, const bool loadFromMatchConfig, - const bool allowFromFile = true) { +static bool LoadMapListKeyValue(const KeyValues kv, char[] error, const bool allowFromFile) { + if (!kv.GotoFirstSubKey(false)) { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" has no valid subkeys in match config KV file."); + return false; + } + bool success = false; + char buffer[PLATFORM_MAX_PATH]; + if (!ReadKeyValueMaplistSection(kv, buffer, error)) { + return false; + } + if (allowFromFile && StrEqual("fromfile", buffer)) { + kv.GetString(NULL_STRING, buffer, PLATFORM_MAX_PATH); + success = LoadMapListFromFile(buffer, error); + } else { + g_MapPoolList.PushString(buffer); + while (kv.GotoNextKey(false)) { + if (!ReadKeyValueMaplistSection(kv, buffer, error)) { + return false; + } + g_MapPoolList.PushString(buffer); + } + success = true; + } + kv.GoBack(); + return success; +} + +static bool ReadKeyValueMaplistSection(const KeyValues kv, char[] buffer, char[] error) { + if (!kv.GetSectionName(buffer, PLATFORM_MAX_PATH)) { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" property contains invalid map name in match config KeyValues."); + return false; + } + return true; +} + +static bool LoadMapListJson(const JSON_Object json, char[] error, const bool allowFromFile) { + bool success = false; + if (json.IsArray) { + JSON_Array array = view_as(json); + if (array.Length == 0) { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" is empty array."); + } else { + char buffer[PLATFORM_MAX_PATH]; + for (int i = 0; i < array.Length; i++) { + array.GetString(i, buffer, PLATFORM_MAX_PATH); + g_MapPoolList.PushString(buffer); + } + success = true; + } + } else { + char mapFileName[PLATFORM_MAX_PATH]; + if (allowFromFile && json.GetString("fromfile", mapFileName, PLATFORM_MAX_PATH) && strlen(mapFileName) > 0) { + success = LoadMapListFromFile(mapFileName, error); + } else { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + } + } + return success; +} + +static bool LoadTeamDataKeyValue(const KeyValues kv, const Get5Team matchTeam, char[] error, + const bool allowFromFile) { + char fromfile[PLATFORM_MAX_PATH]; + if (allowFromFile) { + kv.GetString("fromfile", fromfile, sizeof(fromfile)); + } + if (StrEqual(fromfile, "")) { + GetTeamPlayers(matchTeam).Clear(); + GetTeamCoaches(matchTeam).Clear(); + kv.GetString("name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, + matchTeam == Get5Team_Spec ? CONFIG_SPECTATORSNAME_DEFAULT : ""); + FormatTeamName(matchTeam); + AddSubsectionAuthsToList(kv, "players", GetTeamPlayers(matchTeam)); + if (matchTeam != Get5Team_Spec) { + AddSubsectionAuthsToList(kv, "coaches", GetTeamCoaches(matchTeam)); + kv.GetString("tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH, ""); + kv.GetString("matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH, ""); + g_TeamSeriesScores[matchTeam] = kv.GetNum("series_score", 0); + } + return true; + } else { + return LoadTeamDataFromFile(fromfile, matchTeam, error); + } +} + +static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, char[] error, + const bool allowFromFile) { + if (json.IsArray) { + FormatEx(error, PLATFORM_MAX_PATH, "Team data in JSON is array. Must be object."); + return false; + } char fromfile[PLATFORM_MAX_PATH]; if (allowFromFile) { json_object_get_string_safe(json, "fromfile", fromfile, sizeof(fromfile)); @@ -672,8 +712,6 @@ static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, c if (StrEqual(fromfile, "")) { GetTeamPlayers(matchTeam).Clear(); GetTeamCoaches(matchTeam).Clear(); - // TODO: this needs to support both an array and a dictionary - // For now, it only supports an array json_object_get_string_safe(json, "name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, matchTeam == Get5Team_Spec ? CONFIG_SPECTATORSNAME_DEFAULT : ""); FormatTeamName(matchTeam); @@ -691,56 +729,70 @@ static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, c } return true; } else { - return LoadTeamDataFromFile(fromfile, matchTeam, loadFromMatchConfig); + return LoadTeamDataFromFile(fromfile, matchTeam, error); } } -static bool LoadTeamData(const KeyValues kv, const Get5Team matchTeam, const bool loadFromMatchConfig, - const bool allowFromFile = true) { - char fromfile[PLATFORM_MAX_PATH]; - if (allowFromFile) { - kv.GetString("fromfile", fromfile, sizeof(fromfile)); +static bool LoadMapListFromFile(const char[] fromFile, char[] error) { + LogDebug("Loading maplist using fromfile."); + if (!FileExists(fromFile)) { + FormatEx(error, PLATFORM_MAX_PATH, "Maplist fromfile file does not exist: \"%s\".", fromFile); + return false; } - if (StrEqual(fromfile, "")) { - // TODO: Probably add some validation here and use loadFromMatchConfig to determine error and return false? - GetTeamPlayers(matchTeam).Clear(); - GetTeamCoaches(matchTeam).Clear(); - kv.GetString("name", g_TeamNames[matchTeam], MAX_CVAR_LENGTH, - matchTeam == Get5Team_Spec ? CONFIG_SPECTATORSNAME_DEFAULT : ""); - FormatTeamName(matchTeam); - AddSubsectionAuthsToList(kv, "players", GetTeamPlayers(matchTeam)); - if (matchTeam != Get5Team_Spec) { - AddSubsectionAuthsToList(kv, "coaches", GetTeamCoaches(matchTeam)); - kv.GetString("tag", g_TeamTags[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("flag", g_TeamFlags[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("logo", g_TeamLogos[matchTeam], MAX_CVAR_LENGTH, ""); - kv.GetString("matchtext", g_TeamMatchTexts[matchTeam], MAX_CVAR_LENGTH, ""); - g_TeamSeriesScores[matchTeam] = kv.GetNum("series_score", 0); + bool success = false; + if (IsJSONPath(fromFile)) { + JSON_Object jsonFromFile = json_read_from_file(fromFile, JSON_DECODE_ORDERED_KEYS); + if (jsonFromFile == null) { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" -> \"fromfile\" points to an invalid or unreadable JSON file: \"%s\".", fromFile); + } else { + success = LoadMapListJson(jsonFromFile, error, false); + json_cleanup_and_delete(jsonFromFile); } - return true; } else { - return LoadTeamDataFromFile(fromfile, matchTeam, loadFromMatchConfig); + char parseError[PLATFORM_MAX_PATH]; + if (!CheckKeyValuesFile(fromFile, parseError, sizeof(parseError))) { + FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" -> \"fromfile\" points to an invalid or unreadable KV file: \"%s\". Error: %s", fromFile, parseError); + } else { + KeyValues kvFromFile = new KeyValues("maplist"); + if (kvFromFile.ImportFromFile(fromFile)) { + success = LoadMapListKeyValue(kvFromFile, error, false); + } else { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to read maplist from KV file: \"%s\".", fromFile); + } + delete kvFromFile; + } } + return success; } -static bool LoadTeamDataFromFile(const char[] fromFile, const Get5Team team, const bool loadFromMatchConfig) { +bool LoadTeamDataFromFile(const char[] fromFile, const Get5Team team, char[] error) { LogDebug("Loading team data for team %d using fromfile.", team); + if (!FileExists(fromFile)) { + FormatEx(error, PLATFORM_MAX_PATH, "Team fromfile file does not exist: \"%s\".", fromFile); + return false; + } bool success = false; if (IsJSONPath(fromFile)) { - JSON_Object fromFileJson = json_read_from_file(fromFile); - if (fromFileJson != null) { - success = LoadTeamDataJson(fromFileJson, team, loadFromMatchConfig, false); - json_cleanup_and_delete(fromFileJson); + JSON_Object jsonFromFile = json_read_from_file(fromFile, JSON_DECODE_ORDERED_KEYS); + if (jsonFromFile != null) { + success = LoadTeamDataJson(jsonFromFile, team, error, false); + json_cleanup_and_delete(jsonFromFile); + } else { + FormatEx(error, PLATFORM_MAX_PATH, "Cannot read team config from JSON file: \"%s\".", fromFile); } } else { - KeyValues kvFromFile = new KeyValues("Team"); - if (kvFromFile.ImportFromFile(fromFile)) { - success = LoadTeamData(kvFromFile, team, loadFromMatchConfig, false); + char parseError[PLATFORM_MAX_PATH]; + if (!CheckKeyValuesFile(fromFile, parseError, sizeof(parseError))) { + FormatEx(error, PLATFORM_MAX_PATH, "Cannot read team config from KV file \"%s\": %s", fromFile, parseError); + } else { + KeyValues kvFromFile = new KeyValues("Team"); + if (kvFromFile.ImportFromFile(fromFile)) { + success = LoadTeamDataKeyValue(kvFromFile, team, error, false); + } else { + FormatEx(error, PLATFORM_MAX_PATH, "Cannot read team config from KV file \"%s\".", fromFile); + } + delete kvFromFile; } - delete kvFromFile; - } - if (!success && loadFromMatchConfig) { - MatchConfigFail("Cannot load team config from file: \"%s\".", fromFile); } return success; } @@ -761,41 +813,7 @@ static void FormatTeamName(const Get5Team team) { strlen(g_TeamNames[team]) > 0 ? g_TeamNames[team] : teamNameFallback); } -static void LoadDefaultMapList(ArrayList list) { - list.PushString("de_ancient"); - list.PushString("de_dust2"); - list.PushString("de_inferno"); - list.PushString("de_mirage"); - list.PushString("de_nuke"); - list.PushString("de_overpass"); - list.PushString("de_vertigo"); - - if (g_SkipVeto) { - char currentMap[PLATFORM_MAX_PATH]; - GetCurrentMap(currentMap, sizeof(currentMap)); - - int currentMapIndex = list.FindString(currentMap); - if (currentMapIndex > 0) { - list.SwapAt(0, currentMapIndex); - } - } -} - void SetMatchTeamCvars() { - Get5Team ctTeam = Get5Team_1; - Get5Team tTeam = Get5Team_2; - if (g_TeamStartingSide[Get5Team_1] == CS_TEAM_T) { - ctTeam = Get5Team_2; - tTeam = Get5Team_1; - } - - // Get the match configs set by the config file. - // These might be modified so copies are made here. - char ctMatchText[MAX_CVAR_LENGTH]; - char tMatchText[MAX_CVAR_LENGTH]; - strcopy(ctMatchText, sizeof(ctMatchText), g_TeamMatchTexts[ctTeam]); - strcopy(tMatchText, sizeof(tMatchText), g_TeamMatchTexts[tTeam]); - // Update mp_teammatchstat_txt with the match title. char mapstat[MAX_CVAR_LENGTH]; strcopy(mapstat, sizeof(mapstat), g_MatchTitle); @@ -803,20 +821,8 @@ void SetMatchTeamCvars() { ReplaceStringWithInt(mapstat, sizeof(mapstat), "{MAXMAPS}", g_NumberOfMapsInSeries); SetConVarStringSafe("mp_teammatchstat_txt", mapstat); - if (g_MapsToWin >= 3) { - char team1Text[MAX_CVAR_LENGTH]; - char team2Text[MAX_CVAR_LENGTH]; - IntToString(g_TeamSeriesScores[Get5Team_1], team1Text, sizeof(team1Text)); - IntToString(g_TeamSeriesScores[Get5Team_2], team2Text, sizeof(team2Text)); - - MatchTeamStringsToCSTeam(team1Text, team2Text, ctMatchText, sizeof(ctMatchText), tMatchText, sizeof(tMatchText)); - } - - SetTeamInfo(CS_TEAM_CT, g_TeamNames[ctTeam], g_TeamFlags[ctTeam], g_TeamLogos[ctTeam], ctMatchText, - g_TeamSeriesScores[ctTeam]); - - SetTeamInfo(CS_TEAM_T, g_TeamNames[tTeam], g_TeamFlags[tTeam], g_TeamLogos[tTeam], tMatchText, - g_TeamSeriesScores[tTeam]); + SetTeamSpecificCvars(Get5Team_1); + SetTeamSpecificCvars(Get5Team_2); // Set prediction cvars. SetConVarStringSafe("mp_teamprediction_txt", g_FavoredTeamText); @@ -828,6 +834,19 @@ void SetMatchTeamCvars() { SetConVarIntSafe("mp_teamscore_max", g_MapsToWin > 1 ? g_MapsToWin : 0); } +static void SetTeamSpecificCvars(const Get5Team team) { + char teamText[MAX_CVAR_LENGTH]; + strcopy(teamText, sizeof(teamText), g_TeamMatchTexts[team]); // Copy as we don't want to modify the original values. + int teamScore = g_TeamSeriesScores[team]; + if (g_MapsToWin > 1 && strlen(teamText) == 0) { + // If we play BoX > 1 and no match team text was specifically set, overwrite with the map series score: + IntToString(teamScore, teamText, sizeof(teamText)); + } + // For this specifically, the starting side is the one to use, as the game swaps _1 and _2 cvars itself after halftime. + Get5Side side = view_as(g_TeamStartingSide[team]); + SetTeamInfo(side, g_TeamNames[team], g_TeamFlags[team], g_TeamLogos[team], teamText, teamScore); +} + static void ExecuteMatchConfigCvars() { // Save the original match cvar values if we haven't already. if (g_MatchConfigChangedCvars == INVALID_HANDLE) { @@ -877,7 +896,8 @@ Action Command_LoadTeam(int client, int args) { return Plugin_Handled; } - if (LoadTeamDataFromFile(arg2, team, false)) { + char error[PLATFORM_MAX_PATH]; + if (LoadTeamDataFromFile(arg2, team, error)) { ReplyToCommand(client, "Loaded team data for %s.", arg1); SetMatchTeamCvars(); if (g_CheckAuthsCvar.BoolValue) { @@ -888,7 +908,7 @@ Action Command_LoadTeam(int client, int args) { } } } else { - ReplyToCommand(client, "Failed to load data for %s from file: \"%s\".", arg1, arg2); + ReplyToCommand(client, error); } return Plugin_Handled; } @@ -1117,7 +1137,7 @@ Action Command_RemoveKickedPlayer(int client, int args) { Action Command_CreateMatch(int client, int args) { if (g_GameState != Get5State_None) { - ReplyToCommand(client, "Cannot create a match when a match is already loaded"); + ReplyToCommand(client, "Cannot create a match when a match is already loaded."); return Plugin_Handled; } @@ -1173,19 +1193,20 @@ Action Command_CreateMatch(int client, int args) { kv.GoBack(); if (!kv.ExportToFile(path)) { - delete kv; - MatchConfigFail("Failed to read write match config to %s", path); - return Plugin_Handled; + ReplyToCommand(client, "Failed to write match config file to: \"%s\".", path); + } else { + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchConfig(path, error)) { + ReplyToCommand(client, error); + } } - delete kv; - LoadMatchConfig(path); return Plugin_Handled; } Action Command_CreateScrim(int client, int args) { if (g_GameState != Get5State_None) { - ReplyToCommand(client, "Cannot create a match when a match is already loaded"); + ReplyToCommand(client, "Cannot create a scrim when a match is already loaded."); return Plugin_Handled; } @@ -1223,7 +1244,7 @@ Action Command_CreateScrim(int client, int args) { BuildPath(Path_SM, templateFile, sizeof(templateFile), "configs/get5/scrim_template.cfg"); if (!kv.ImportFromFile(templateFile)) { delete kv; - MatchConfigFail("Failed to read scrim template in %s", templateFile); + ReplyToCommand(client, "Failed to read scrim template from file: \"%s\"", templateFile); return Plugin_Handled; } // Because we read the field and write it again, then load it as a match config, we have to make @@ -1236,7 +1257,7 @@ Action Command_CreateScrim(int client, int args) { kv.Rewind(); } else { delete kv; - MatchConfigFail("You must add players to team1 on your scrim template!"); + ReplyToCommand(client, "You must add players to team1 on your scrim template!"); return Plugin_Handled; } @@ -1266,13 +1287,14 @@ Action Command_CreateScrim(int client, int args) { kv.GoBack(); if (!kv.ExportToFile(path)) { - delete kv; - MatchConfigFail("Failed to read write scrim config to %s", path); - return Plugin_Handled; + ReplyToCommand(client, "Failed to write scrim config file to: \"%s\".", path); + } else { + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchConfig(path, error)) { + ReplyToCommand(client, error); + } } - delete kv; - LoadMatchConfig(path); return Plugin_Handled; } @@ -1328,17 +1350,6 @@ static int AddPlayersToAuthKv(KeyValues kv, Get5Team team, char teamName[MAX_CVA return count; } -static void MatchTeamStringsToCSTeam(const char[] team1Str, const char[] team2Str, char[] ctStr, int ctLen, char[] tStr, - int tLen) { - if (Get5TeamToCSTeam(Get5Team_1) == CS_TEAM_CT) { - strcopy(ctStr, ctLen, team1Str); - strcopy(tStr, tLen, team2Str); - } else { - strcopy(tStr, tLen, team1Str); - strcopy(ctStr, ctLen, team2Str); - } -} - // Adds the team logos to the download table. static void AddTeamLogosToDownloadTable() { AddTeamLogoToDownloadTable(g_TeamLogos[Get5Team_1]); @@ -1388,14 +1399,22 @@ void ExecCfg(ConVar cvar) { char cfg[PLATFORM_MAX_PATH]; cvar.GetString(cfg, sizeof(cfg)); ServerCommand("exec \"%s\"", cfg); - CreateTimer(0.1, Timer_ExecMatchConfig, _, TIMER_FLAG_NO_MAPCHANGE); + g_MatchConfigExecTimer = CreateTimer(0.1, Timer_ExecMatchConfig); } static Action Timer_ExecMatchConfig(Handle timer) { + if (timer != g_MatchConfigExecTimer) { + LogDebug("Ignoring exec callback as timer handle was incorrect."); + // This prevents multiple calls to this function from stacking the calls. + return Plugin_Handled; + } // When we load config files using ServerCommand("exec") above, which is async, we want match // config cvars to always override. - ExecuteMatchConfigCvars(); - SetMatchTeamCvars(); + if (g_GameState != Get5State_None) { + ExecuteMatchConfigCvars(); + SetMatchTeamCvars(); + } + g_MatchConfigExecTimer = INVALID_HANDLE; return Plugin_Handled; } diff --git a/scripting/get5/natives.sp b/scripting/get5/natives.sp index 5d367ced6..dd30c7110 100644 --- a/scripting/get5/natives.sp +++ b/scripting/get5/natives.sp @@ -118,7 +118,8 @@ public int Native_MessageToAll(Handle plugin, int numParams) { public int Native_LoadMatchConfig(Handle plugin, int numParams) { char filename[PLATFORM_MAX_PATH]; GetNativeString(1, filename, sizeof(filename)); - return LoadMatchConfig(filename); + char error[PLATFORM_MAX_PATH]; + return LoadMatchConfig(filename, error); } public int Native_LoadMatchConfigFromURL(Handle plugin, int numParams) { diff --git a/scripting/get5/pausing.sp b/scripting/get5/pausing.sp index 45c9818bc..9c1781122 100644 --- a/scripting/get5/pausing.sp +++ b/scripting/get5/pausing.sp @@ -23,11 +23,10 @@ void PauseGame(Get5Team team, Get5PauseType type) { EventLogger_LogAndDeleteEvent(event); - // Only create a pause timer if a pause is not already ongoing. - if (g_PauseType == Get5PauseType_None) { - CreateTimer(1.0, Timer_PauseTimeCheck, _, TIMER_REPEAT | TIMER_FLAG_NO_MAPCHANGE); - } - + // Stop existing pause timer and restart it. + delete g_PauseTimer; + g_PauseTimer = CreateTimer(1.0, Timer_PauseTimeCheck, _, TIMER_REPEAT); + g_LatestPauseDuration = 0; g_PauseType = type; g_PausingTeam = team; g_IsChangingPauseState = true; @@ -50,6 +49,7 @@ void UnpauseGame(Get5Team team) { EventLogger_LogAndDeleteEvent(event); + delete g_PauseTimer; // Immediately stop pause timer if running. g_PauseType = Get5PauseType_None; g_PausingTeam = Get5Team_None; g_LatestPauseDuration = 0; @@ -267,8 +267,13 @@ Action Command_Unpause(int client, int args) { } static Action Timer_PauseTimeCheck(Handle timer) { + if (timer != g_PauseTimer) { + LogDebug("Stopping pause timer as handle was incorrect."); + return Plugin_Stop; + } if (g_PauseType == Get5PauseType_None || !IsPaused()) { LogDebug("Stopping pause timer as game is not paused."); + g_PauseTimer = INVALID_HANDLE; return Plugin_Stop; } @@ -303,6 +308,7 @@ static Action Timer_PauseTimeCheck(Handle timer) { if (fixedPauseTime > 0) { timeLeft = fixedPauseTime - g_LatestPauseDuration; if (timeLeft <= 0) { + g_PauseTimer = INVALID_HANDLE; UnpauseGame(team); return Plugin_Stop; } @@ -311,6 +317,7 @@ static Action Timer_PauseTimeCheck(Handle timer) { // pauses while a pause is active. Kind of a weird edge-case, but it should be handled // gracefully. Get5_MessageToAll("%t", "MaxPausesUsedInfoMessage", maxTacticalPauses, g_FormattedTeamNames[g_PausingTeam]); + g_PauseTimer = INVALID_HANDLE; UnpauseGame(team); return Plugin_Stop; } else if (!g_TeamReadyForUnpause[team]) { @@ -324,6 +331,7 @@ static Action Timer_PauseTimeCheck(Handle timer) { timeLeft = maxTacticalPauseTime - g_TacticalPauseTimeUsed[team]; if (timeLeft <= 0) { Get5_MessageToAll("%t", "PauseRunoutInfoMessage", g_FormattedTeamNames[team]); + g_PauseTimer = INVALID_HANDLE; UnpauseGame(team); return Plugin_Stop; } diff --git a/scripting/get5/tests.sp b/scripting/get5/tests.sp index 86d776b8b..1192d7f76 100644 --- a/scripting/get5/tests.sp +++ b/scripting/get5/tests.sp @@ -5,19 +5,400 @@ Action Command_Test(int args) { static void Get5_Test() { if (g_GameState != Get5State_None) { - g_GameState = Get5State_None; + LogMessage("Cannot run Get5 tests while a match is loaded."); + return; } - char path[PLATFORM_MAX_PATH]; - BuildPath(Path_SM, path, sizeof(path), "configs/get5/example_match.cfg"); - LoadMatchConfig(path); - Utils_Test(); - KV_Test(); + char mapName[255]; + GetCleanMapName(mapName, sizeof(mapName)); + if (!StrEqual(mapName, "de_dust2")) { + LogMessage("Tests should be run with de_dust2 loaded only. Please change the map and run the command again."); + return; + } + + // We reset these to default as tests need them to be consistent. + SetConVarStringSafe("mp_teamscore_max", "0"); + SetConVarStringSafe("mp_teammatchstat_txt", ""); + SetConVarStringSafe("mp_teamprediction_pct", "0"); + + SetConVarStringSafe("mp_teamname_1", ""); + SetConVarStringSafe("mp_teamflag_1", ""); + SetConVarStringSafe("mp_teamlogo_1", ""); + SetConVarStringSafe("mp_teammatchstat_1", ""); + SetConVarStringSafe("mp_teamscore_1", ""); + + SetConVarStringSafe("mp_teamname_2", ""); + SetConVarStringSafe("mp_teamflag_2", ""); + SetConVarStringSafe("mp_teamlogo_2", ""); + SetConVarStringSafe("mp_teammatchstat_2", ""); + SetConVarStringSafe("mp_teamscore_2", ""); + + ValidMatchConfigTest("addons/sourcemod/configs/get5/tests/default_valid.json"); + ValidMatchConfigTest("addons/sourcemod/configs/get5/tests/default_valid.cfg"); - g_GameState = Get5State_None; + MatchConfigNotFoundTest(); + + InvalidMatchConfigFile("addons/sourcemod/configs/get5/tests/invalid_config.json"); + InvalidMatchConfigFile("addons/sourcemod/configs/get5/tests/invalid_config.cfg"); + + MapListFromFileTest(); + LoadTeamFromFileTest(); + Team1StartTTest(); + MissingPropertiesTest(); + + Utils_Test(); LogMessage("Tests complete!"); } +static void MissingPropertiesTest() { + SetTestContext("MissingPropertiesTest"); + + char error[PLATFORM_MAX_PATH]; + AssertFalse("Load missing team1 JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team1.json", error)); + AssertStrEq("Load missing team1 JSON error", error, "Missing \"team1\" section in match config JSON."); + + AssertFalse("Load missing team2 JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team2.json", error)); + AssertStrEq("Load missing team2 JSON error", error, "Missing \"team2\" section in match config JSON."); + + AssertFalse("Load missing maplist JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.json", error)); + AssertStrEq("Load missing maplist JSON error", error, "Missing \"maplist\" section in match config JSON."); + + AssertFalse("Load missing team1 KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team1.cfg", error)); + AssertStrEq("Load missing team1 KV error", error, "Missing \"team1\" section in match config KeyValues."); + + AssertFalse("Load missing team2 KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team2.cfg", error)); + AssertStrEq("Load missing team2 KV error", error, "Missing \"team2\" section in match config KeyValues."); + + AssertFalse("Load missing maplist KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.cfg", error)); + AssertStrEq("Load missing maplist KV error", error, "Missing \"maplist\" section in match config KeyValues."); +} + +static void MatchConfigNotFoundTest() { + SetTestContext("MatchConfigNotFoundTest"); + char error[PLATFORM_MAX_PATH]; + AssertFalse("Load match config does not exist", LoadMatchConfig("addons/sourcemod/configs/get5/tests/file_not_found.cfg", error)); + AssertTrue("Match config does not exist error", StrContains(error, "Match config file doesn't exist") != -1); +} + +static void MapListFromFileTest() { + SetTestContext("MapListFromFileTest"); + char error[PLATFORM_MAX_PATH]; + + // JSON + MapListValid("addons/sourcemod/configs/get5/tests/fromfile_maplist_valid.json"); + + AssertFalse("Load empty maplist config JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty.json", error)); + AssertStrEq("Load empty maplist config JSON", error, "\"maplist\" is empty array."); + + AssertFalse("Load maplist fromfile file not found config", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_found.json", error)); + AssertEq("Load maplist fromfile file not found config", StrContains(error, "Maplist fromfile file does not exist"), 0); + + AssertFalse("Load maplist fromfile config not array JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_array.json", error)); + AssertStrEq("Load maplist fromfile config not array JSON", error, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + + AssertFalse("Load maplist fromfile config empty string JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty_string.json", error)); + AssertStrEq("Load maplist fromfile config empty string JSON", error, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + + // KeyValues + MapListValid("addons/sourcemod/configs/get5/tests/fromfile_maplist_valid.cfg"); + + AssertFalse("Load maplist fromfile config invalid KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_invalid.cfg", error)); + AssertStrEq("Load maplist fromfile config invalid KV", error, "\"maplist\" has no valid subkeys in match config KV file."); + +} + +static void InvalidMatchConfigFile(const char[] matchConfig) { + SetTestContext("InvalidMatchConfigFile"); + char error[PLATFORM_MAX_PATH]; + AssertFalse("Load invalid match config file", LoadMatchConfig(matchConfig, error)); + AssertTrue("Invalid config file error", StrContains(error, "Failed to read match config from file") != -1); +} + +static void MapListValid(const char[] file) { + char mapName[32]; + char err[32]; + AssertTrue("Load valid fromfile maplist config", LoadMatchConfig(file, err)); + AssertEq("Map List Length", g_MapPoolList.Length, 3); + g_MapPoolList.GetString(0, mapName, sizeof(mapName)); + AssertStrEq("Map 1 Fromfile Name", mapName, "de_ancient"); + g_MapPoolList.GetString(1, mapName, sizeof(mapName)); + AssertStrEq("Map 2 Fromfile Name", mapName, "de_overpass"); + g_MapPoolList.GetString(2, mapName, sizeof(mapName)); + AssertStrEq("Map 3 Fromfile Name", mapName, "de_inferno"); + EndSeries(Get5Team_None, false, 0.0); +} + +static void Team1StartTTest() { + SetTestContext("Team1StartTTest"); + char err[255]; + AssertTrue("load config", LoadMatchConfig("addons/sourcemod/configs/get5/tests/default_valid_team1t.json", err)); + + // We test that the mp_ cvars are correctly inverted when team 1 starts T. + // Series score 1 in the loaded config puts them on the second map, where they start T. + AssertConVarEquals("mp_teamname_2", "Team A Start T [NOT READY]"); + AssertConVarEquals("mp_teamflag_2", "NO"); + AssertConVarEquals("mp_teamlogo_2", "start_t_logo"); + AssertConVarEquals("mp_teammatchstat_2", "GG T WIN"); + AssertConVarEquals("mp_teamscore_2", "1"); + + EndSeries(Get5Team_None, false, 0.0); +} + +static void LoadTeamFromFileTest() { + SetTestContext("LoadTeamFromFileTest"); + char err[255]; + AssertTrue("load config", LoadMatchConfig("addons/sourcemod/configs/get5/tests/default_valid.json", err)); + AssertTrue("load team", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/team2_array.json", Get5Team_2, err)); + + char playerId[32]; + char playerName[32]; + ArrayList playersTeam2 = GetTeamPlayers(Get5Team_2); + AssertEq("Team B Player Length", playersTeam2.Length, 4); + + playersTeam2.GetString(0, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 1 Team B", playerId, "76561198065028911"); + AssertStrEq("Name Player 1 Team B", playerName, ""); + + playersTeam2.GetString(1, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team B", playerId, "76561198065027917"); + AssertStrEq("Name Player 2 Team B", playerName, ""); + + playersTeam2.GetString(2, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 3 Team B", playerId, "76561198065028119"); + AssertStrEq("Name Player 3 Team B", playerName, ""); + + AssertStrEq("Team B Name", g_TeamNames[Get5Team_2], "Team B Array"); + AssertStrEq("Team B Logo", g_TeamLogos[Get5Team_2], "fromfile_team_array"); + AssertStrEq("Team B Flag", g_TeamFlags[Get5Team_2], "SE"); + AssertStrEq("Team B Tag", g_TeamTags[Get5Team_2], "TAG-FA"); + AssertStrEq("Team B MatchText", g_TeamMatchTexts[Get5Team_2], ""); + + AssertFalse("load team file not found", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/file_not_found.json", Get5Team_2, err)); + AssertEq("load team file not found", StrContains(err, "Team fromfile file does not exist"), 0); + + AssertFalse("JSON load team file invalid", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.json", Get5Team_2, err)); + AssertEq("JSON load team file invalid", StrContains(err, "Cannot read team config from JSON file"), 0); + + AssertFalse("KV load team file invalid", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.cfg", Get5Team_2, err)); + AssertEq("KV load team file invalid", StrContains(err, "Cannot read team config from KV file"), 0); + + EndSeries(Get5Team_None, false, 0.0); +} + +static void ValidMatchConfigTest(const char[] matchConfig) { + SetTestContext("ValidMatchConfigTest"); + char error[PLATFORM_MAX_PATH]; + AssertTrue("Load match config", LoadMatchConfig(matchConfig, error)); + + char playerId[32]; + char playerName[32]; + ArrayList playersTeam1 = GetTeamPlayers(Get5Team_1); + AssertEq("Team A Player Length", playersTeam1.Length, 5); + + playersTeam1.GetString(0, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 1 Team A", playerId, "76561197996413459"); + AssertStrEq("Name Player 1 Team A", playerName, "PlayerAName1"); + + playersTeam1.GetString(1, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team A", playerId, "76561197996426756"); + AssertStrEq("Name Player 2 Team A", playerName, "PlayerAName2"); + + playersTeam1.GetString(2, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team A", playerId, "76561197996426757"); + AssertStrEq("Name Player 3 Team A", playerName, "PlayerAName3"); + + playersTeam1.GetString(3, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team A", playerId, "76561197996426758"); + AssertStrEq("Name Player 4 Team A", playerName, "PlayerAName4"); + + playersTeam1.GetString(4, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team A", playerId, "76561197996426759"); + AssertStrEq("Name Player 5 Team A", playerName, "PlayerAName5"); + + ArrayList coachesTeam1 = GetTeamCoaches(Get5Team_1); + AssertEq("Team A Coaches Length", coachesTeam1.Length, 2); + coachesTeam1.GetString(0, playerId, sizeof(playerId)); + + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Coach 1 Team A", playerId, "76561197996426735"); + AssertStrEq("Name Coach 1 Team A", playerName, "CoachAName1"); + + coachesTeam1.GetString(1, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Coach 2 Team A", playerId, "76561197946789735"); + AssertStrEq("Name Coach 2 Team A", playerName, "CoachAName2"); + + AssertStrEq("Team A Name", g_TeamNames[Get5Team_1], "Team A Default"); + AssertStrEq("Team A Logo", g_TeamLogos[Get5Team_1], "logofilename"); + AssertStrEq("Team A Flag", g_TeamFlags[Get5Team_1], "US"); + AssertStrEq("Team A Tag", g_TeamTags[Get5Team_1], "TAG-A"); + AssertStrEq("Team A MatchText", g_TeamMatchTexts[Get5Team_1], "Defending Champions"); + + ArrayList playersTeam2 = GetTeamPlayers(Get5Team_2); + AssertEq("Team B Player Length", playersTeam2.Length, 3); + + playersTeam2.GetString(0, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 1 Team B", playerId, "76561198064968911"); + AssertStrEq("Name Player 1 Team B", playerName, "PlayerBName1"); + + playersTeam2.GetString(1, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 2 Team B", playerId, "76561198064967917"); + AssertStrEq("Name Player 2 Team B", playerName, "PlayerBName2"); + + playersTeam2.GetString(2, playerId, sizeof(playerId)); + g_PlayerNames.GetString(playerId, playerName, sizeof(playerName)); + AssertStrEq("Steam ID Player 3 Team B", playerId, "76561198064968119"); + AssertStrEq("Name Player 3 Team B", playerName, "PlayerBName3"); + + AssertEq("Team B Coaches Empty", GetTeamCoaches(Get5Team_2).Length, 0); + + AssertStrEq("Team B Name", g_TeamNames[Get5Team_2], "Team B Default"); + AssertStrEq("Team B Logo", g_TeamLogos[Get5Team_2], "fromfile_team"); + AssertStrEq("Team B Flag", g_TeamFlags[Get5Team_2], "DE"); + AssertStrEq("Team B Tag", g_TeamTags[Get5Team_2], "TAG-FF"); + AssertStrEq("Team B MatchText", g_TeamMatchTexts[Get5Team_2], ""); + + GetTeamPlayers(Get5Team_Spec).GetString(0, playerId, sizeof(playerId)); + AssertStrEq("Steam ID Spectator", playerId, "76561197996426761"); + AssertStrEq("Spectator Team Name", g_TeamNames[Get5Team_Spec], "Spectator Team Name"); + + AssertEq("Map List Length", g_MapsToPlay.Length, 3); + char mapName[32]; + g_MapsToPlay.GetString(0, mapName, sizeof(mapName)); + AssertStrEq("Map 1 Name", mapName, "de_dust2"); + g_MapsToPlay.GetString(1, mapName, sizeof(mapName)); + AssertStrEq("Map 2 Name", mapName, "de_mirage"); + g_MapsToPlay.GetString(2, mapName, sizeof(mapName)); + AssertStrEq("Map 3 Name", mapName, "de_inferno"); + + AssertEq("Map sides length", g_MapSides.Length, 3); + AssertEq("Sides 0", view_as(g_MapSides.Get(0)), view_as(SideChoice_KnifeRound)); + AssertEq("Sides 1", view_as(g_MapSides.Get(1)), view_as(SideChoice_Team1T)); + AssertEq("Sides 2", view_as(g_MapSides.Get(2)), view_as(SideChoice_Team1CT)); // only 2 sides present in the file, and side_type: never_knife = team 1 ct + + AssertStrEq("Match ID", g_MatchID, "test_match_valid"); + AssertStrEq("Match Title", g_MatchTitle, "Test {MAPNUMBER} of {MAXMAPS}"); + AssertEq("Maps to win", g_MapsToWin, 2); + AssertEq("Maps in series", g_NumberOfMapsInSeries, 3); + AssertEq("Players per team", g_PlayersPerTeam, 5); + AssertEq("Coaches per team", g_CoachesPerTeam, 1); + AssertEq("Min players to ready", g_MinPlayersToReady, 3); + AssertEq("Min spectators to ready", g_MinSpectatorsToReady, 1); + AssertEq("Clinch series", g_SeriesCanClinch, true); + AssertEq("Sides type", view_as(g_MatchSideType), view_as(MatchSideType_NeverKnife)); + AssertEq("Veto first", view_as(g_LastVetoTeam), view_as(Get5Team_1)); + AssertEq("Skip veto", g_SkipVeto, true); + AssertEq("Favored percentage team 1", g_FavoredTeamPercentage, 75); + AssertStrEq("Favored team text", g_FavoredTeamText, "team percentage text"); + AssertEq("Game state", view_as(g_GameState), view_as(Get5State_Warmup)); + + AssertConVarEquals("mp_teamname_1", "Team A Default [NOT READY]"); + AssertConVarEquals("mp_teamflag_1", "US"); + AssertConVarEquals("mp_teamlogo_1", "logofilename"); + AssertConVarEquals("mp_teammatchstat_1", "Defending Champions"); + AssertConVarEquals("mp_teamscore_1", ""); + + AssertConVarEquals("mp_teamname_2", "Team B Default [NOT READY]"); + AssertConVarEquals("mp_teamflag_2", "DE"); + AssertConVarEquals("mp_teamlogo_2", "fromfile_team"); + AssertConVarEquals("mp_teammatchstat_2", "0"); // blank match text = use map series score + AssertConVarEquals("mp_teamscore_2", ""); + + AssertConVarEquals("mp_teamprediction_txt", "team percentage text"); + AssertConVarEquals("mp_teamprediction_pct", "75"); + AssertConVarEquals("mp_teammatchstat_txt", "Test 1 of 3"); + + g_RoundBackupPathCvar.SetString("addons/sourcemod/configs/get5/tests/backups/{MATCHID}/"); + g_BackupSystemEnabledCvar.BoolValue = true; + g_ServerIdCvar.IntValue = 1234; + WriteBackup(); + + char backupFilePath[PLATFORM_MAX_PATH]; + FormatEx(backupFilePath, sizeof(backupFilePath), "addons/sourcemod/configs/get5/tests/backups/%s/%s", g_MatchID, "get5_backup1234_matchtest_match_valid_map0_prelive.cfg"); + AssertTrue("Check backup file exists", FileExists(backupFilePath)); + + KeyValues backup = new KeyValues("Backup"); + AssertTrue("Read backup file", backup.ImportFromFile(backupFilePath)); + + AssertEq("Backup game state", backup.GetNum("gamestate", -1), view_as(Get5State_Warmup)); + AssertEq("Backup team1 side", backup.GetNum("team1_side", -1), view_as(Get5Side_CT)); + AssertEq("Backup team2 side", backup.GetNum("team2_side", -1), view_as(Get5Side_T)); + AssertEq("Backup team1 start side", backup.GetNum("team1_start_side", -1), view_as(Get5Side_CT)); + AssertEq("Backup team2 start side", backup.GetNum("team2_start_side", -1), view_as(Get5Side_T)); + AssertEq("Backup team1 score", backup.GetNum("team1_series_score", -1), 0); + AssertEq("Backup team2 score", backup.GetNum("team2_series_score", -1), 0); + AssertEq("Backup draws", backup.GetNum("series_draw", -1), 0); + AssertEq("Backup team1 tac pause used", backup.GetNum("team1_tac_pauses_used", -1), 0); + AssertEq("Backup team2 tac pause used", backup.GetNum("team2_tac_pauses_used", -1), 0); + AssertEq("Backup team1 tech pause used", backup.GetNum("team1_tech_pauses_used", -1), 0); + AssertEq("Backup team2 tech pause used", backup.GetNum("team2_tech_pauses_used", -1), 0); + AssertEq("Backup team1 pause time used", backup.GetNum("team1_pause_time_used", -1), 0); + AssertEq("Backup team2 pause time used", backup.GetNum("team2_pause_time_used", -1), 0); + AssertEq("Backup map number", backup.GetNum("mapnumber", -1), 0); + AssertTrue("Check maps key exists in backup", backup.JumpToKey("maps", false)); + + int index = -1; + if (backup.GotoFirstSubKey(false)) { + do { + index++; + AssertTrue("Read map name from backup", backup.GetSectionName(mapName, sizeof(mapName))); + if (index == 0) { + AssertStrEq("Check map name 1 in backup", mapName, "de_dust2"); + AssertEq("Check map side 1 in backup", backup.GetNum(NULL_STRING), view_as(SideChoice_KnifeRound)); + } else if (index == 1) { + AssertStrEq("Check map name 2 in backup",mapName, "de_mirage"); + AssertEq("Check map side 2 in backup", backup.GetNum(NULL_STRING), view_as(SideChoice_Team1T)); + } else if (index == 2) { + AssertStrEq("Check map name 3 in backup", mapName, "de_inferno"); + AssertEq("Check map side 3 in backup", backup.GetNum(NULL_STRING), view_as(SideChoice_Team1CT)); + } + } while (backup.GotoNextKey(false)); + AssertTrue("Go back from maps key", backup.GoBack()); + AssertEq("Map list length in backup", index, 2); + } + backup.GoBack(); + + AssertTrue("Check map scores exist in backup", backup.JumpToKey("map_scores", false)); + char keyName[16]; + char sectionName[16]; + + index = -1; + if (backup.GotoFirstSubKey(false)) { + do { + index++; + AssertTrue("Read map index for score from backup", backup.GetSectionName(sectionName, sizeof(sectionName))); + IntToString(index, keyName, sizeof(keyName)); + AssertStrEq("Check map index key in map score backup", sectionName, keyName); + AssertTrue("Go to team1 score in map", backup.GotoFirstSubKey(false)); + backup.GetSectionName(sectionName, sizeof(sectionName)); + AssertStrEq("Check team1 key in backup scores", sectionName, "team1"); + AssertEq("Check team1 value in backup scores", backup.GetNum(NULL_STRING, -1), 0); + AssertTrue("Go to team2 score in map", backup.GotoNextKey(false)); + backup.GetSectionName(sectionName, sizeof(sectionName)); + AssertStrEq("Check team2 key in backup scores", sectionName, "team2"); + AssertEq("Check team2 value in backup scores", backup.GetNum(NULL_STRING, -1), 0); + AssertFalse("No more keys in backup scores", backup.GotoNextKey(false)); + backup.GoBack(); + } while (backup.GotoNextKey(false)); + AssertTrue("Go back from map_scores key", backup.GoBack()); + AssertEq("Map scores length in backup", index, 2); + } + backup.GoBack(); + + AssertTrue("Delete test backup file", DeleteFile(backupFilePath)); + EndSeries(Get5Team_None, false, 0.0); +} + static void Utils_Test() { SetTestContext("Utils_Test"); @@ -36,56 +417,33 @@ static void Utils_Test() { char expected[64] = "76561198064755913"; char output[64] = ""; AssertTrue("ConvertAuthToSteam64_1_return", ConvertAuthToSteam64(input, output)); - AssertTrue("ConvertAuthToSteam64_1_value", StrEqual(output, expected)); + AssertStrEq("ConvertAuthToSteam64_1_value", output, expected); input = "76561198064755913"; expected = "76561198064755913"; AssertTrue("ConvertAuthToSteam64_2_return", ConvertAuthToSteam64(input, output)); - AssertTrue("ConvertAuthToSteam64_2_value", StrEqual(output, expected)); + AssertStrEq("ConvertAuthToSteam64_2_value", output, expected); input = "_0:1:52245092"; expected = "76561198064755913"; AssertFalse("ConvertAuthToSteam64_3_return", ConvertAuthToSteam64(input, output, false)); - AssertTrue("ConvertAuthToSteam64_3_value", StrEqual(output, expected)); + AssertStrEq("ConvertAuthToSteam64_3_value", output, expected); input = "[U:1:104490185]"; expected = "76561198064755913"; AssertTrue("ConvertAuthToSteam64_4_return", ConvertAuthToSteam64(input, output)); - AssertTrue("ConvertAuthToSteam64_4_value", StrEqual(output, expected)); - - // AddSubsectionKeysToList - KeyValues kv = new KeyValues("test"); - char kvstr[] = "\"test\"{ \"a\" { \"x\" \"y\" \"c\" \"d\" } }"; - kv.ImportFromString(kvstr); - ArrayList list = new ArrayList(64); - AssertEq("AddSubsectionKeysToList1", AddSubsectionKeysToList(kv, "a", list, 64), 2); - delete kv; - - AssertEq("AddSubsectionKeysToList2", list.Length, 2); - - char key[64]; - list.GetString(0, key, sizeof(key)); - AssertTrue("AddSubsectionKeysToList3", StrEqual(key, "x", false)); - - list.GetString(1, key, sizeof(key)); - AssertTrue("AddSubsectionKeysToList4", StrEqual(key, "c", false)); + AssertStrEq("ConvertAuthToSteam64_4_value", output, expected); } -static void KV_Test() { - SetTestContext("KV_Test"); - - AssertEq("maps_to_win", g_MapsToWin, 2); - AssertEq("num_maps", g_NumberOfMapsInSeries, 3); - AssertEq("skip_veto", g_SkipVeto, false); - AssertEq("players_per_team", g_PlayersPerTeam, 5); - AssertEq("coaches_must_ready", g_CoachesMustReady, false); - AssertEq("favored_percentage_team1", g_FavoredTeamPercentage, 65); - - AssertTrue("team1.name", StrEqual(g_TeamNames[Get5Team_1], "EnvyUs", false)); - AssertTrue("team1.flag", StrEqual(g_TeamFlags[Get5Team_1], "FR", false)); - AssertTrue("team1.logo", StrEqual(g_TeamLogos[Get5Team_1], "nv", false)); +static void AssertConVarEquals(const char[] conVarName, const char[] expectedValue) { + char convarBuffer[MAX_CVAR_LENGTH]; + GetConVarStringSafe(conVarName, convarBuffer, sizeof(convarBuffer)); + char testName[128]; + FormatEx(testName, sizeof(testName), "Test \"%s\" is \"%s\"", conVarName, expectedValue); + AssertStrEq(testName, convarBuffer, expectedValue); +} - AssertTrue("team2.name", StrEqual(g_TeamNames[Get5Team_2], "fnatic", false)); - AssertTrue("team2.flag", StrEqual(g_TeamFlags[Get5Team_2], "SE", false)); - AssertTrue("team2.logo", StrEqual(g_TeamLogos[Get5Team_2], "fntc", false)); +// TODO: Remove when compiling with SM 1.11 as it's built-in. +static void AssertStrEq(const char[] text, const char[] value, const char[] expected) { + AssertTrue(text, StrEqual(value, expected)); } diff --git a/scripting/get5/util.sp b/scripting/get5/util.sp index 17092832f..d4be3a516 100644 --- a/scripting/get5/util.sp +++ b/scripting/get5/util.sp @@ -174,6 +174,19 @@ stock bool InFreezeTime() { return GameRules_GetProp("m_bFreezePeriod") != 0; } +stock bool CheckKeyValuesFile(const char[] file, char[] error, const int errSize) { + // Because KeyValues.ImportFromFile does not actually return false if the syntax is invalid, we use the SMC parser to + // parse the file before trying to import it, as this correctly detects syntax errors which we can return to the + // user, instead of trying to load data from an invalid KV structure. + SMCParser parser = new SMCParser(); + SMCError result = parser.ParseFile(file); + if (result != SMCError_Okay) { + parser.GetErrorString(result, error, errSize); + } + delete parser; + return result == SMCError_Okay; +} + stock void StartWarmup(int warmupTime = 0) { ServerCommand("mp_do_warmup_period 1"); ServerCommand("mp_warmuptime_all_players_connected 0"); @@ -199,9 +212,9 @@ stock void RestartGame(int delay = 1) { ServerCommand("mp_restartgame %d", delay); } -stock void SetTeamInfo(int csTeam, const char[] name, const char[] flag = "", const char[] logo = "", - const char[] matchstat = "", int series_score = 0) { - int team_int = (csTeam == CS_TEAM_CT) ? 1 : 2; +stock void SetTeamInfo(const Get5Side side, const char[] name, const char[] flag, const char[] logo, + const char[] matchstat, int series_score) { + int team_int = (side == Get5Side_CT) ? 1 : 2; char teamCvarName[MAX_CVAR_LENGTH]; char flagCvarName[MAX_CVAR_LENGTH]; @@ -218,7 +231,7 @@ stock void SetTeamInfo(int csTeam, const char[] name, const char[] flag = "", co char taggedName[MAX_CVAR_LENGTH]; if (g_ReadyTeamTagCvar.BoolValue) { if (IsReadyGameState()) { - Get5Team matchTeam = CSTeamToGet5Team(csTeam); + Get5Team matchTeam = CSTeamToGet5Team(view_as(side)); if (IsTeamReady(matchTeam)) { FormatEx(taggedName, sizeof(taggedName), "%s %T", name, "ReadyTag", LANG_SERVER); } else { @@ -235,7 +248,13 @@ stock void SetTeamInfo(int csTeam, const char[] name, const char[] flag = "", co SetConVarStringSafe(flagCvarName, flag); SetConVarStringSafe(logoCvarName, logo); SetConVarStringSafe(textCvarName, matchstat); - SetConVarIntSafe(scoreCvarName, g_MapsToWin > 1 ? series_score : 0); + + // We do this because IntValue = 0 does not consistently set an empty string, relevant for testing. + if (g_MapsToWin > 1 && series_score > 0) { + SetConVarIntSafe(scoreCvarName, series_score); + } else { + SetConVarStringSafe(scoreCvarName, ""); + } } stock void SetConVarIntSafe(const char[] name, int value) { @@ -349,7 +368,7 @@ stock bool InHalftimePhase() { return GetGamePhase() == GamePhase_HalfTime; } -stock int AddSubsectionKeysToList(KeyValues kv, const char[] section, ArrayList list, int maxKeyLength) { +stock int AddSubsectionKeysToList(const KeyValues kv, const char[] section, const ArrayList list, int maxKeyLength) { int count = 0; if (kv.JumpToKey(section)) { count = AddKeysToList(kv, list, maxKeyLength); @@ -358,7 +377,7 @@ stock int AddSubsectionKeysToList(KeyValues kv, const char[] section, ArrayList return count; } -stock int AddKeysToList(KeyValues kv, ArrayList list, int maxKeyLength) { +stock int AddKeysToList(const KeyValues kv, const ArrayList list, int maxKeyLength) { int count = 0; char[] buffer = new char[maxKeyLength]; if (kv.GotoFirstSubKey(false)) { @@ -372,7 +391,7 @@ stock int AddKeysToList(KeyValues kv, ArrayList list, int maxKeyLength) { return count; } -stock int AddSubsectionAuthsToList(KeyValues kv, const char[] section, ArrayList list) { +stock int AddSubsectionAuthsToList(const KeyValues kv, const char[] section, const ArrayList list) { int count = 0; if (kv.JumpToKey(section)) { count = AddAuthsToList(kv, list); @@ -381,7 +400,7 @@ stock int AddSubsectionAuthsToList(KeyValues kv, const char[] section, ArrayList return count; } -stock int AddAuthsToList(KeyValues kv, ArrayList list) { +stock int AddAuthsToList(const KeyValues kv, const ArrayList list) { int count = 0; char buffer[AUTH_LENGTH]; char steam64[AUTH_LENGTH]; @@ -401,7 +420,7 @@ stock int AddAuthsToList(KeyValues kv, ArrayList list) { return count; } -stock bool RemoveStringFromArray(ArrayList list, const char[] str) { +stock bool RemoveStringFromArray(const ArrayList list, const char[] str) { int index = list.FindString(str); if (index != -1) { list.Erase(index); @@ -430,16 +449,6 @@ stock bool WritePlaceholderInsteadOfEmptyString(const KeyValues kv, char[] buffe return false; } -stock int OtherCSTeam(int team) { - if (team == CS_TEAM_CT) { - return CS_TEAM_T; - } else if (team == CS_TEAM_T) { - return CS_TEAM_CT; - } else { - return team; - } -} - stock Get5Team OtherMatchTeam(Get5Team team) { if (team == Get5Team_1) { return Get5Team_2; diff --git a/scripting/include/restorecvars.inc b/scripting/include/restorecvars.inc index 26467edd7..5b5999b1d 100644 --- a/scripting/include/restorecvars.inc +++ b/scripting/include/restorecvars.inc @@ -1,5 +1,5 @@ #define CVAR_NAME_MAX_LENGTH 128 -#define CVAR_VALUE_MAX_LENGTH 128 +#define CVAR_VALUE_MAX_LENGTH 513 // Returns a cvar Handle that can be used to restore cvars. stock Handle SaveCvars(ArrayList cvarNames) { From 93bb9fbcf55ff9d7e3d7b5ef93c36b4b8e2fda97 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 9 Dec 2022 21:44:11 +0100 Subject: [PATCH 14/27] Add option to disable stop command on damage/time passed (#930) --- documentation/docs/commands.md | 4 +++- documentation/docs/configuration.md | 10 ++++++++++ documentation/docs/translations.md | 2 ++ scripting/get5.sp | 25 +++++++++++++++++++++++-- scripting/get5/stats.sp | 1 + translations/da/get5.phrases.txt | 8 ++++++++ translations/de/get5.phrases.txt | 8 ++++++++ translations/fr/get5.phrases.txt | 8 ++++++++ translations/get5.phrases.txt | 9 +++++++++ translations/pl/get5.phrases.txt | 8 ++++++++ translations/pt/get5.phrases.txt | 8 ++++++++ translations/ru/get5.phrases.txt | 8 ++++++++ 12 files changed, 96 insertions(+), 3 deletions(-) diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index 68da3bd5e..e478b39af 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -45,7 +45,9 @@ if possible. Can only be used during warmup. : Asks to reload the last match backup file, i.e. restart the current round. The opposing team must confirm before the round ends. Only works if the [backup system is enabled](../configuration#get5_backup_system_enabled) -and [`get5_stop_command_enabled`](../configuration#get5_stop_command_enabled) is set to `1`. +and [`get5_stop_command_enabled`](../configuration#get5_stop_command_enabled) is set to `1`. You can also set +a [time](../configuration#get5_stop_command_time_limit) or +[damage](../configuration#get5_stop_command_no_damage) restriction on the use of this command. ####`!forceready` diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 4c52c93a9..73c720e9e 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -275,6 +275,16 @@ command as well as the [`get5_loadbackup`](../commands#get5_loadbackup) command. ####`get5_stop_command_enabled` : Whether the [`!stop`](../commands#stop) command is enabled.
**`Default: 1`** +####`get5_stop_command_no_damage` +: Whether the [`!stop`](../commands#stop) command becomes unavailable after a player takes damage during a round. Only +damage from one team to another counts (no friendly fire, no fall damage etc.). The command may still be used by admins +via console at any time (`sm_stop`).
**`Default: 0`** + +####`get5_stop_command_time_limit` +: The number of seconds into a round after which the [`!stop`](../commands#stop) command can no longer be used. The +command may still be used by admins via console at any time (`sm_stop`). Set to zero to remove the +limit.
**`Default: 0`** + ####`get5_max_backup_age` : Number of seconds before a Get5 backup file is automatically deleted. If you define [`get5_backup_path`](#get5_backup_path), only files in that path will be deleted. Set to zero to diff --git a/documentation/docs/translations.md b/documentation/docs/translations.md index 1dd5fd6b2..12ad5358b 100644 --- a/documentation/docs/translations.md +++ b/documentation/docs/translations.md @@ -106,6 +106,8 @@ end with a full stop as this is added automatically. | `TimeRemainingBeforeAnyoneCanUnpausePrefix` | _Team A_ (_CT_) technical pause (_1_/_2_). __Time remaining before anyone can unpause__: _2:30_ | HintText | | `StopCommandNotEnabled` | The stop command is not enabled. | Chat | | `StopCommandVotingReset` | The request by _Team A_ to stop the game was canceled as the round ended. | Chat | +| `StopCommandRequiresNoDamage` | A request to restart the round cannot be given once a player has damaged any opposing player. | Chat | +| `StopCommandTimeLimitExceeded` | A request to restart the round must be given within _0:30_ after the round has started. | Chat | | `PauseTimeRemainingPrefix` | _Team A_ (_CT_) tactical pause. __Remaining pause time__: _2:15_ | HintText | | `PausedForBackup` | The game was restored from a backup. Both teams must unpause to continue. | HintText | | `AwaitingUnpause` | _Team A_ (_CT_) tactical pause. __Awaiting unpause__. | HintText | diff --git a/scripting/get5.sp b/scripting/get5.sp index e48907e77..081ad130f 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -101,6 +101,8 @@ ConVar g_SetClientClanTagCvar; ConVar g_SetHostnameCvar; ConVar g_StatsPathFormatCvar; ConVar g_StopCommandEnabledCvar; +ConVar g_StopCommandNoDamageCvar; +ConVar g_StopCommandTimeLimitCvar; ConVar g_TeamTimeToKnifeDecisionCvar; ConVar g_TimeToStartCvar; ConVar g_TimeToStartVetoCvar; @@ -238,6 +240,7 @@ bool g_DamageDoneAssist[MAXPLAYERS + 1][MAXPLAYERS + 1]; bool g_DamageDoneFlashAssist[MAXPLAYERS + 1][MAXPLAYERS + 1]; bool g_PlayerRoundKillOrAssistOrTradedDeath[MAXPLAYERS + 1]; bool g_PlayerSurvived[MAXPLAYERS + 1]; +bool g_PlayerHasTakenDamage = false; KeyValues g_StatsKv; ArrayList g_TeamScoresPerMap = null; @@ -383,6 +386,8 @@ public void OnPluginStart() { g_BackupSystemEnabledCvar = CreateConVar("get5_backup_system_enabled", "1", "Whether the Get5 backup system is enabled."); g_MaxBackupAgeCvar = CreateConVar("get5_max_backup_age", "172800", "Number of seconds before a backup file is automatically deleted. Set to 0 to disable. Default is 2 days."); g_StopCommandEnabledCvar = CreateConVar("get5_stop_command_enabled", "1", "Whether clients can use the !stop command to restore to the beginning of the current round."); + g_StopCommandNoDamageCvar = CreateConVar("get5_stop_command_no_damage", "0", "Whether the stop command becomes unavailable if a player damages a player from the opposing team."); + g_StopCommandTimeLimitCvar = CreateConVar("get5_stop_command_time_limit", "0", "The number of seconds into a round after which a team can no longer request/confirm to stop and restart the round."); // Demos g_DemoUploadDeleteAfterCvar = CreateConVar("get5_demo_delete_after_upload", "0", "Whether to delete the demo from the game server after a successful upload."); @@ -1117,8 +1122,9 @@ static Action Command_Stop(int client, int args) { // Because a live restore to the same match does not change get5 state to warmup, we have to make sure // that successive calls to !stop (spammed by players) does not reload multiple backups. - if (g_GameState != Get5State_Live || InHalftimePhase() || g_DoingBackupRestoreNow || - g_PauseType == Get5PauseType_Backup) { + // Don't allow it during freeze time or after the round has ended either. + if (g_GameState != Get5State_Live || InHalftimePhase() || InFreezeTime() || g_DoingBackupRestoreNow || + GetRoundsPlayed() != g_RoundNumber) { return Plugin_Handled; } @@ -1137,6 +1143,20 @@ static Action Command_Stop(int client, int args) { if (!IsPlayerTeam(team)) { return Plugin_Handled; } + + if (g_PlayerHasTakenDamage && g_StopCommandNoDamageCvar.BoolValue) { + Get5_MessageToAll("%t", "StopCommandRequiresNoDamage"); + return Plugin_Handled; + } + int stopCommandGrace = g_StopCommandTimeLimitCvar.IntValue; + if (stopCommandGrace > 0 && GetRoundTime() / 1000 > stopCommandGrace) { + char formattedGracePeriod[32]; + ConvertSecondsToMinutesAndSeconds(stopCommandGrace, formattedGracePeriod, sizeof(formattedGracePeriod)); + FormatTimeString(formattedGracePeriod, sizeof(formattedGracePeriod), formattedGracePeriod); + Get5_MessageToAll("%t", "StopCommandTimeLimitExceeded", formattedGracePeriod); + return Plugin_Handled; + } + g_TeamGivenStopCommand[team] = true; char stopCommandFormatted[64]; @@ -1581,6 +1601,7 @@ static Action Event_RoundStart(Event event, const char[] name, bool dontBroadcas g_RoundStartedTime = 0.0; g_BombPlantedTime = 0.0; g_BombSiteLastPlanted = Get5BombSite_Unknown; + g_PlayerHasTakenDamage = false; RestartInfoTimer(); if (g_GameState == Get5State_None || IsDoingRestoreOrMapChange()) { diff --git a/scripting/get5/stats.sp b/scripting/get5/stats.sp index c370ac709..956471a88 100644 --- a/scripting/get5/stats.sp +++ b/scripting/get5/stats.sp @@ -61,6 +61,7 @@ static Action HandlePlayerDamage(int victim, int &attacker, int &inflictor, floa if (isUtilityDamage) { AddToPlayerStat(attacker, STAT_UTILITY_DAMAGE, damageAsIntCapped); } + g_PlayerHasTakenDamage = true; } if (!isUtilityDamage) { diff --git a/translations/da/get5.phrases.txt b/translations/da/get5.phrases.txt index 6bd225dbd..d74fa7465 100644 --- a/translations/da/get5.phrases.txt +++ b/translations/da/get5.phrases.txt @@ -272,6 +272,14 @@ { "da" "Anmodningen fra {1} om at stoppe spillet blev annulleret, da runden sluttede." } + "StopCommandRequiresNoDamage" + { + "da" "Anmodning om at genstarte runden kan ikke gives, efter en spiller har skadet en anden spiller fra det modsatte hold." + } + "StopCommandTimeLimitExceeded" + { + "da" "Anmodning om at genstarte runden skal gives inden {1} fra rundens start." + } "BackupLoadedInfoMessage" { "da" "Backup {1} indlæst." diff --git a/translations/de/get5.phrases.txt b/translations/de/get5.phrases.txt index f390718dc..9228cb981 100644 --- a/translations/de/get5.phrases.txt +++ b/translations/de/get5.phrases.txt @@ -272,6 +272,14 @@ { "de" "Die Anfrage von {1} das Spiel zu stoppen wurde abgebrochen da die Runde zu Ende ist." } + "StopCommandRequiresNoDamage" + { + "de" "Ein Neustart der Runde kann nicht angefragt werden, sobald ein Spieler einem anderen Spieler Schaden zugefügt hat." + } + "StopCommandTimeLimitExceeded" + { + "de" "Ein Neustart der Runde muss innerhalb der ersten {1} nach dem Rundenstart angefragt werden." + } "BackupLoadedInfoMessage" { "de" "Die Sicherung {1} wurde erfolgreich geladen." diff --git a/translations/fr/get5.phrases.txt b/translations/fr/get5.phrases.txt index 4da5710ee..d0ea8e50e 100644 --- a/translations/fr/get5.phrases.txt +++ b/translations/fr/get5.phrases.txt @@ -272,6 +272,14 @@ { "fr" "La demande de {1} d'arrêter le match a été annulée car la manche s'est finie." } + "StopCommandRequiresNoDamage" + { + "fr" "Une demande de redémarrage du tour ne peut pas être donnée une fois qu’un joueur a endommagé un joueur adverse." + } + "StopCommandTimeLimitExceeded" + { + "fr" "Une demande de redémarrage du tour doit être faite dans les {1} suivant le début du tour." + } "BackupLoadedInfoMessage" { "fr" "Sauvegarde chargée avec succès {1}." diff --git a/translations/get5.phrases.txt b/translations/get5.phrases.txt index 4507b3563..963f54de7 100644 --- a/translations/get5.phrases.txt +++ b/translations/get5.phrases.txt @@ -315,6 +315,15 @@ "#format" "{1:s}" "en" "The request by {1} to stop the game was canceled as the round ended." } + "StopCommandRequiresNoDamage" + { + "en" "A request to restart the round cannot be given once a player has damaged any opposing player." + } + "StopCommandTimeLimitExceeded" + { + "#format" "{1:s}" + "en" "A request to restart the round must be given within {1} after the round has started." + } "BackupLoadedInfoMessage" { "#format" "{1:s}" diff --git a/translations/pl/get5.phrases.txt b/translations/pl/get5.phrases.txt index f5891be59..b6c356898 100644 --- a/translations/pl/get5.phrases.txt +++ b/translations/pl/get5.phrases.txt @@ -148,6 +148,14 @@ { "pl" "Mecz został zakończony" } + "StopCommandRequiresNoDamage" + { + "pl" "Prośba o wznowienie rundy nie może być zgłoszona, gdy gracz zadał obrażenia jakiemukolwiek graczowi przeciwnika." + } + "StopCommandTimeLimitExceeded" + { + "pl" "Prośba o wznowienie rundy musi zostać zgłoszona w ciągu {1} po rozpoczęciu rundy." + } "BackupLoadedInfoMessage" { "pl" "Pomyślnie wczytano kopię meczu {1}." diff --git a/translations/pt/get5.phrases.txt b/translations/pt/get5.phrases.txt index 595933961..493812d9e 100644 --- a/translations/pt/get5.phrases.txt +++ b/translations/pt/get5.phrases.txt @@ -112,6 +112,14 @@ { "pt" "O pedido de {1} para parar o jogo foi cancelado quando o round terminou." } + "StopCommandRequiresNoDamage" + { + "pt" "Um pedido para reiniciar o round não pode ser dado uma vez que um jogador causou dano a um adversário." + } + "StopCommandTimeLimitExceeded" + { + "pt" "Um pedido para reiniciar o round deve ser feito dentro de {1} após o início do round." + } "PauseTimeRemainingPrefix" { "pt" "Tempo de pausa restante" diff --git a/translations/ru/get5.phrases.txt b/translations/ru/get5.phrases.txt index 6aaee181f..35ed932b3 100644 --- a/translations/ru/get5.phrases.txt +++ b/translations/ru/get5.phrases.txt @@ -272,6 +272,14 @@ { "ru" "Запрос {1} на остановку игры был отменен по окончании раунда." } + "StopCommandRequiresNoDamage" + { + "ru" "Запрос на перезапуск раунда не может быть подан после того, как игрок нанес урон любому игроку соперника." + } + "StopCommandTimeLimitExceeded" + { + "ru" "Запрос на перезапуск раунда должен быть подан в течение {1} после начала раунда." + } "BackupLoadedInfoMessage" { "ru" "Удачно загружена копия {1}." From 5724d0d639a0aead671e151b37729543a865ae6f Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 9 Dec 2022 22:22:24 +0100 Subject: [PATCH 15/27] Adjust fromfile doc Include g_PlayerHasTakenDamage in match config reset --- documentation/docs/match_schema.md | 29 +++++++++++++++++------------ scripting/get5.sp | 1 + 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/documentation/docs/match_schema.md b/documentation/docs/match_schema.md index 32037027d..1e8460a79 100644 --- a/documentation/docs/match_schema.md +++ b/documentation/docs/match_schema.md @@ -28,10 +28,11 @@ interface Get5MatchTeam { "logo": string | undefined // (19) "series_score": number | undefined // (26) "matchtext": string | undefined // (27) + "fromfile": string | undefined // (28) } -interface Get5MatchTeamFromFile { - "fromfile": string // (28) +interface Get5MapListFromFile { + "fromfile": string // (35) } interface Get5Match { @@ -52,12 +53,12 @@ interface Get5Match { "name": string | undefined // (29) "players": Get5PlayerSet | undefined // (30) "fromfile": string | undefined // (34) - } | undefined, - "maplist": string[] // (13) + } | undefined + "maplist": string[] | Get5MapListFromFile // (13) "favored_percentage_team1": number | undefined // (14) "favored_percentage_text": string | undefined // (15) - "team1": Get5MatchTeam | Get5MatchTeamFromFile // (20) - "team2": Get5MatchTeam | Get5MatchTeamFromFile // (21) + "team1": Get5MatchTeam // (20) + "team2": Get5MatchTeam // (21) "cvars": { [key: string]: string | number } | undefined // (22) } ``` @@ -93,7 +94,8 @@ interface Get5Match { maps. `standard` and `always_knife` behave similarly when `skip_veto` is `true`.

**`Default: "standard"`** 13. _Required_
The map pool to pick from, as an array of strings (`["de_dust2", "de_nuke"]` etc.), or if `skip_veto` is `true`, the order of maps played (limited by `num_maps`). **This should always be odd-sized if using the in-game - [veto system](../veto).** + [veto system](../veto).** Similarly to teams, you can set this to an object with a `fromfile` property to load a map + list from a separate file. 14. _Optional_
Wrapper for the server's `mp_teamprediction_pct`. This determines the chances of `team1` winning.

**`Default: 0`** 15. _Optional_
Wrapper for the server's `mp_teamprediction_txt`.

**`Default: ""`** @@ -125,10 +127,12 @@ interface Get5Match { backup method.

**`Default: 0`** 27. _Optional_
Assigns values to `mp_teammatchstat_1` and `mp_teammatchstat_2`, respectively. If you don't set this value in a BoX series, it is set to each team's map series score automatically.

**`Default: ""`** -28. Match teams can also be loaded from a separate file, allowing you to easily re-use a match configuration for - different sets of teams. A `fromfile` value could be `"addons/sourcemod/configs/get5/team_nip.json"`, and is always - relative to the `csgo` directory. The file should contain a valid `Get5MatchTeam` object. You **are** allowed to mix - filetypes, so a JSON file can point to a `fromfile` that's a KeyValue file and vice-versa. +28. _Optional_
Match teams can also be loaded from a separate file, allowing you to easily re-use a match + configuration for different sets of teams. A `fromfile` value could + be `"addons/sourcemod/configs/get5/team_nip.json"`, and is always relative to the `csgo` directory. The file should + contain a valid `Get5MatchTeam` object. You **are** allowed to mix filetypes, so a JSON file can point to + a `fromfile` that's a KeyValue file and vice-versa. If you provide a `fromfile` property, all other properties are + ignored and team data is only read from the provided file. 29. _Optional_
The name of the spectator team.

**`Default: "casters"`** 30. _Optional_
The spectator/caster Steam IDs and names. Setting a Steam ID as spectator takes precedence over being set as a player or coach. @@ -138,7 +142,8 @@ interface Get5Match { 32. _Optional_
If `false`, the entire map list will be played, regardless of score. If `true`, a series will be won when the series score for a team exceeds the number of maps divided by two.

**`Default: true`** 33. _Optional_
Determines if coaches must also [`!ready`](../commands#ready).

**`Default: false`** -34. _Optional_
Similarly to teams, spectators may also be loaded from another file. +34. _Optional_
Similarly to teams and map list, spectators may also be loaded from another file. +35. _Required_
Similarly to teams and spectators, a map list may also be loaded from another file. !!! info "Team assignment priority" diff --git a/scripting/get5.sp b/scripting/get5.sp index 081ad130f..30d9bd7f7 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -1505,6 +1505,7 @@ void ResetMatchConfigVariables(bool backup = false) { g_PausingTeam = Get5Team_None; g_LatestPauseDuration = 0; g_PauseType = Get5PauseType_None; + g_PlayerHasTakenDamage = false; if (!backup) { // All hell breaks loose if these are reset during a backup. g_DoingBackupRestoreNow = false; From 78629c7a535bd1c9917ca0717f90cfed627dc04d Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Sat, 10 Dec 2022 22:46:02 +0100 Subject: [PATCH 16/27] Allow for disabling match cvars restore (#951) Allow for disabling match cvars restore Clean up cvar restore and player kick logic Make hostname reset depend on get5_reset_cvars_on_end --- documentation/docs/configuration.md | 20 +++++++--- documentation/docs/match_schema.md | 7 +++- scripting/get5.sp | 57 ++++++++++++----------------- scripting/get5/matchconfig.sp | 9 ++++- scripting/include/restorecvars.inc | 11 ++---- 5 files changed, 56 insertions(+), 48 deletions(-) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 73c720e9e..64bdf45f4 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -73,7 +73,10 @@ native.
**`Default: 0`** !!! tip "Server ID could be port number" A good candidate for `get5_server_id` would be the port number the server is bound to, since it uniquely identifies - a server instance on a host and ensures that no two instances run with the same server ID at the same time. + a server instance on a host and ensures that no two instances run with the same server ID at the same time. You + should also **not** put this parameter in your [match configuration](../match_schema#schema) `cvars`, as those + parameters will be written to [backup](../backup) files, which would mean that loading a backup created on another + server would change the server ID. ####`get5_kick_immunity` : Whether [admins](../installation#administrators) will be immune to kicks from @@ -105,6 +108,13 @@ removed from the game, or if in [scrim mode](../getting_started#scrims), put on starts, when Get5 is reloaded or if no match is loaded when a player joins the server. Set to empty string to disable.
**`Default: ""`** +####`get5_reset_cvars_on_end` +: Whether the `cvars` of a [match configuration](../match_schema#schema) as well as +the [Get5-determined hostname](#get5_hostname_format) are reset to their original values when a series ends. You may +want to disable this if you only run Get5 on your servers and use `cvars` to +configure [demos](../gotv), [backups](../backup) or [remote URL logging](../events_and_forwards#http) on a per-match +basis, as reverting some of those parameters can be problematic.
**`Default: 1`** + ####`get5_debug` : Enable or disable verbose debug output from Get5. Intended for development and debugging purposes only.
**`Default: 0`** @@ -218,7 +228,6 @@ leaves. Set to zero to disable.
**`Default: 0`** If you always want the pause to trigger if an entire team disconnects, regardless of team size, you can set [`get5_auto_tech_pause_missing_players`](#get5_auto_tech_pause_missing_players) to a large value, as setting it to a value larger than [`players_per_team`](../match_schema#schema) behaves as if it was set to that value. - !!! warning "Auto-pausing is always enabled" @@ -333,9 +342,10 @@ exist.
**`Default: ""`** disable.
**`Default: "get5_matchstats_{MATCHID}.cfg"`** ####`get5_hostname_format` -: The hostname to apply to the server. [State substitutes](#state-substitutes) can be used. Set to an empty string to -disable changing the hostname. This is updated on every round start to allow for the use of team score -substitutes.
**`Default: "Get5: {TEAM1} vs {TEAM2}"`** +: The hostname to apply to the server. [State substitutes](#state-substitutes) can be used. +If [`get5_reset_cvars_on_end`](#get5_reset_cvars_on_end) is enabled, the hostname will be reverted to its original value +when the series ends. The hostname is updated on every round start to allow for the use of team score substitutes. Set +to an empty string to disable changing the hostname.
**`Default: "Get5: {TEAM1} vs {TEAM2}"`** ####`get5_message_prefix` : The tag applied before plugin messages. Note that at least one character must come before diff --git a/documentation/docs/match_schema.md b/documentation/docs/match_schema.md index 1e8460a79..fd2ab425f 100644 --- a/documentation/docs/match_schema.md +++ b/documentation/docs/match_schema.md @@ -113,7 +113,12 @@ interface Get5Match { 21. _Required_
The data for the second team. 22. _Optional_
Various commands to execute on the server when loading the match configuration. This can be both regular server-commands and any [`Get5 configuration parameter`](../configuration), - i.e. `{"hostname": "Match #3123 - Astralis vs. NaVi"}`.

**`Default: undefined`** + i.e. `{"mp_friendlyfire": "0", "get5_max_pauses": "2"}`.

When the match ends, these parameters will by + default + be [reset to the value they had before the match was loaded](../configuration#get5_reset_cvars_on_end).

You + should avoid putting server-specific parameters, such as [`get5_server_id`](../configuration#get5_server_id), in + this property, as these are all written to [backups](../backup) and set when restored from.

+ **`Default: undefined`** 23. _Optional_
Similarly to `players`, this object maps [coaches](../coaching) using their Steam ID and name, locking them to the coach slot unless removed using [`get5_removeplayer`](../commands#get5_removeplayer). Setting a Steam ID as coach takes precedence over being set as a player.

Note that diff --git a/scripting/get5.sp b/scripting/get5.sp index 30d9bd7f7..4b4647722 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -97,6 +97,7 @@ ConVar g_ReadyTeamTagCvar; ConVar g_AllowForceReadyCvar; ConVar g_ResetPausesEachHalfCvar; ConVar g_ServerIdCvar; +ConVar g_ResetCvarsOnEndCvar; ConVar g_SetClientClanTagCvar; ConVar g_SetHostnameCvar; ConVar g_StatsPathFormatCvar; @@ -213,6 +214,7 @@ Get5Team g_LastVetoTeam; Menu g_ActiveVetoMenu; Handle g_InfoTimer = INVALID_HANDLE; Handle g_MatchConfigExecTimer = INVALID_HANDLE; +Handle g_ResetCvarsTimer = INVALID_HANDLE; /** Backup data **/ bool g_DoingBackupRestoreNow = false; @@ -456,6 +458,7 @@ public void OnPluginStart() { g_ServerIdCvar = CreateConVar("get5_server_id", "0", "Integer that identifies your server. This is used in temporary files to prevent collisions."); g_StatsPathFormatCvar = CreateConVar("get5_stats_path_format", "get5_matchstats_{MATCHID}.cfg", "Where match stats are saved (updated each map end). Set to \"\" to disable."); g_WarmupCfgCvar = CreateConVar("get5_warmup_cfg", "get5/warmup.cfg", "Config file to execute during warmup periods."); + g_ResetCvarsOnEndCvar = CreateConVar("get5_reset_cvars_on_end", "1", "Whether parameters from the \"cvars\" section of a match configuration and the Get5-determined hostname are restored to their original values when a series ends."); // clang-format on /** Create and exec plugin's configuration file **/ @@ -1390,26 +1393,16 @@ void EndSeries(Get5Team winningTeam, bool printWinnerMessage, float restoreDelay EventLogger_LogAndDeleteEvent(event); ChangeState(Get5State_None); - // We don't want to kick players until after the specified delay, as it will kick casters - // potentially before GOTV ends. - if (kickPlayers && g_KickClientsWithNoMatchCvar.BoolValue) { - if (restoreDelay < 0.1) { - KickPlayers(); - } else { - CreateTimer(restoreDelay, Timer_KickOnEnd, _, TIMER_FLAG_NO_MAPCHANGE); - } - } - if (restoreDelay < 0.1) { // When force-ending the match there is no delay. - RestoreCvars(g_MatchConfigChangedCvars); - ResetHostname(); + ResetMatchCvarsAndHostnameAndKickPlayers(kickPlayers); } else { // If we restore cvars immediately, it might change the tv_ params or set the // mp_match_restart_delay to something lower, which is noticed by the game and may trigger a map // change before GOTV broadcast ends, so we don't do this until the current match restart delay - // has passed. - CreateTimer(restoreDelay, Timer_RestoreMatchCvars, _, TIMER_FLAG_NO_MAPCHANGE); + // has passed. We also don't want to kick players until after the specified delay, as it will kick + // casters potentially before GOTV ends. + g_ResetCvarsTimer = CreateTimer(restoreDelay, Timer_RestoreMatchCvarsAndKickPlayers, kickPlayers); } // If the match is ended during pending map change; @@ -1513,31 +1506,31 @@ void ResetMatchConfigVariables(bool backup = false) { } } -static Action Timer_KickOnEnd(Handle timer) { - if (g_GameState == Get5State_None) { - // If a match was started before this event is triggered, don't do anything. - KickPlayers(); +static Action Timer_RestoreMatchCvarsAndKickPlayers(Handle timer, bool kickPlayers) { + if (timer != g_ResetCvarsTimer) { + LogDebug("g_ResetCvarsTimer callback has unexpected/invalid handle. Ignoring."); + return Plugin_Handled; } + ResetMatchCvarsAndHostnameAndKickPlayers(kickPlayers); + g_ResetCvarsTimer = INVALID_HANDLE; return Plugin_Handled; } -static void KickPlayers() { - bool kickImmunity = g_KickClientImmunityCvar.BoolValue; - LOOP_CLIENTS(i) { - if (IsPlayer(i) && !(kickImmunity && CheckCommandAccess(i, "get5_kickcheck", ADMFLAG_CHANGEMAP))) { - KickClient(i, "%t", "MatchFinishedInfoMessage"); +void ResetMatchCvarsAndHostnameAndKickPlayers(bool kickPlayers) { + if (kickPlayers && g_KickClientsWithNoMatchCvar.BoolValue) { + bool kickImmunity = g_KickClientImmunityCvar.BoolValue; + LOOP_CLIENTS(i) { + if (IsPlayer(i) && !(kickImmunity && CheckCommandAccess(i, "get5_kickcheck", ADMFLAG_CHANGEMAP))) { + KickClient(i, "%t", "MatchFinishedInfoMessage"); + } } } -} - -static Action Timer_RestoreMatchCvars(Handle timer) { - if (g_GameState == Get5State_None) { - // Only reset if no game is running, otherwise a game started before the restart delay for - // another ends will mess this up. + if (g_ResetCvarsOnEndCvar.BoolValue) { RestoreCvars(g_MatchConfigChangedCvars); ResetHostname(); + } else { + CloseCvarStorage(g_MatchConfigChangedCvars); } - return Plugin_Handled; } static Action Event_RoundPreStart(Event event, const char[] name, bool dontBroadcast) { @@ -1626,9 +1619,7 @@ static Action Event_RoundStart(Event event, const char[] name, bool dontBroadcas // This immediately triggers another Event_RoundStart, so we can return here and avoid // writing backup twice. LogDebug("Changed to warmup post knife."); - if (g_KnifeChangedCvars != INVALID_HANDLE) { - RestoreCvars(g_KnifeChangedCvars, true); - } + RestoreCvars(g_KnifeChangedCvars); ExecCfg(g_WarmupCfgCvar); StartWarmup(); diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index f727bdfe6..2b6d568f0 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -27,6 +27,13 @@ bool LoadMatchConfig(const char[] config, char[] error, bool restoreBackup = fal ResetMatchConfigVariables(restoreBackup); ResetReadyStatus(); + // If a new match is loaded while there is still a pending cvar restore timer running, we + // want to make sure that that timer's callback does *not* fire and mess up our game state. + if (g_ResetCvarsTimer != INVALID_HANDLE) { + LogDebug("Killing g_ResetCvarsTimer as a new match was loaded."); + delete g_ResetCvarsTimer; + } + g_CvarNames.Clear(); g_CvarValues.Clear(); @@ -849,7 +856,7 @@ static void SetTeamSpecificCvars(const Get5Team team) { static void ExecuteMatchConfigCvars() { // Save the original match cvar values if we haven't already. - if (g_MatchConfigChangedCvars == INVALID_HANDLE) { + if (g_MatchConfigChangedCvars == INVALID_HANDLE && g_ResetCvarsOnEndCvar.BoolValue) { g_MatchConfigChangedCvars = SaveCvars(g_CvarNames); } diff --git a/scripting/include/restorecvars.inc b/scripting/include/restorecvars.inc index 5b5999b1d..67d23c410 100644 --- a/scripting/include/restorecvars.inc +++ b/scripting/include/restorecvars.inc @@ -26,8 +26,7 @@ stock Handle SaveCvars(ArrayList cvarNames) { } // Restores cvars to their previous value using a return value of SaveCvars. -stock void RestoreCvars(Handle &cvarStorage, bool close = true) { - LogDebug("Restoring match cvars."); +stock void RestoreCvars(Handle &cvarStorage) { if (cvarStorage == INVALID_HANDLE) { return; } @@ -38,17 +37,13 @@ stock void RestoreCvars(Handle &cvarStorage, bool close = true) { char value[CVAR_VALUE_MAX_LENGTH]; for (int i = 0; i < cvarNameList.Length; i++) { cvarNameList.GetString(i, name, sizeof(name)); - cvarValueList.GetString(i, value, sizeof(value)); - Handle cvar = FindConVar(name); if (cvar != INVALID_HANDLE) { + cvarValueList.GetString(i, value, sizeof(value)); SetConVarString(cvar, value); } } - - if (close) { - CloseCvarStorage(cvarStorage); - } + CloseCvarStorage(cvarStorage); } // Closes a cvar storage object returned by SaveCvars. From 622ad13a274aadeec41c5c9099800c94f3e2fc7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Dec 2022 15:48:44 +0100 Subject: [PATCH 17/27] Bump ncipollo/release-action from 1.11.2 to 1.12.0 (#955) Bumps [ncipollo/release-action](https://github.com/ncipollo/release-action) from 1.11.2 to 1.12.0. - [Release notes](https://github.com/ncipollo/release-action/releases) - [Commits](https://github.com/ncipollo/release-action/compare/v1.11.2...v1.12.0) --- updated-dependencies: - dependency-name: ncipollo/release-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a660c402f..33bc4d41b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: myToken: ${{ secrets.GITHUB_TOKEN }} - name: Tag And Attach Nightly Build - uses: ncipollo/release-action@v1.11.2 + uses: ncipollo/release-action@v1.12.0 with: token: "${{ secrets.GITHUB_TOKEN }}" artifacts: "artifacts/${{ needs.build.outputs.filename }}.zip,artifacts/${{ needs.build.outputs.filename }}.tar.gz" @@ -124,7 +124,7 @@ jobs: path: artifacts - name: Tag And Draft Release - uses: ncipollo/release-action@v1.11.2 + uses: ncipollo/release-action@v1.12.0 with: token: "${{ secrets.GITHUB_TOKEN }}" artifacts: "artifacts/${{ needs.build.outputs.filename }}.zip,artifacts/${{ needs.build.outputs.filename }}.tar.gz" From 353cee59dba2373014960f9b9ba1f602900fcdfa Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 14 Dec 2022 03:45:12 +0100 Subject: [PATCH 18/27] Add support for remote backups (#952) Consolidate a lot of network logic to prevent repetition Added native for remote backup load Add Get5-Version header to all HTTP requests Prevent call to Get5_OnPreLoadMatchConfig when loading a backup Fixed some error feedback Correctly pass full path to demo record/upload events Add file syntax and exist check to backup load command Add warnings about public URL when using get5_loadmatch_url --- documentation/docs/backup.md | 39 +++++ documentation/docs/commands.md | 18 ++- documentation/docs/configuration.md | 13 ++ documentation/docs/gotv.md | 3 +- scripting/get5.sp | 39 +++-- scripting/get5/backups.sp | 221 +++++++++++++++++++++++++--- scripting/get5/events.sp | 33 ++--- scripting/get5/http.sp | 118 ++++++++++++++- scripting/get5/matchconfig.sp | 168 ++++++++------------- scripting/get5/natives.sp | 23 ++- scripting/get5/pausing.sp | 2 +- scripting/get5/recording.sp | 173 ++++++++-------------- scripting/get5/tests.sp | 68 ++++++--- 13 files changed, 607 insertions(+), 311 deletions(-) diff --git a/documentation/docs/backup.md b/documentation/docs/backup.md index 09321d3f2..375c89552 100644 --- a/documentation/docs/backup.md +++ b/documentation/docs/backup.md @@ -51,6 +51,45 @@ get5_backup4_match1844_map0_round17.cfg 2022-07-26 19:03:39 "Team A" "Team B" de After loading a backup, the game state is restored and the game is [paused](../pausing#backup). Both teams must [`!unpause`](../commands#unpause) to continue. +### Automatic Upload {: #upload } + +You can configure Get5 to automatically send all your backups to +a [remote location](../configuration#get5_remote_backup_url) (such as a central server at a LAN). The file will be sent +as the raw HTTP body. Requires the [SteamWorks](../installation#steamworks) extension. + +!!! warning "Pre-live backups are sent multiple times" + + As Get5 writes the `prelive` backup file multiple times, it will also upload it multiple times. If you don't care + about this backup, just discard the file if the `Get5-RoundNumber` header is `-1`. You should still reply with + `200 OK` though. + +#### Headers + +Get5 will add these headers to the request in order for your server to handle it: + +1. `Get5-FileName` is the name of the backup file (see examples above). +2. `Get5-MapNumber` is the zero-indexed map number in the series. +3. `Get5-RoundNumber` is the zero-indexed round number of the map. If the match is not yet live (warmup, knife), this + value is `-1`. +4. `Get5-MatchId` **if** the [match ID](../match_schema#schema) is not an empty string. +5. `Get5-ServerId` **if** [`get5_server_id`](../configuration#get5_server_id) is set to a positive integer. +6. `Get5-Version` is the version of Get5, i.e. `0.12.0`. + +#### Authorization + +You can add a [custom header](../configuration#get5_remote_backup_header_key) to the request for authorization, if +required. + +#### Example + +For an example of how to read the file on your web server, please see the example for [demo uploads](../gotv#example). +The same principles apply to backups. + +### Loading from Remote {: #remote } + +You can use the [`get5_loadbackup_url`](../commands#get5_loadbackup_url) command to load a backup from a remote host. +This assumes the file is a valid KeyValues file and requires the [SteamWorks](../installation#steamworks) extension. + ### Consumed pauses in backups {: #pauses } When restoring from a backup, the [consumed pauses](../pausing) are reset to the state they were in at the beginning diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index e478b39af..2314879e9 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -103,6 +103,12 @@ Please note that these are meant to be used by *admins* in console. The definiti the [backup system is enabled](../configuration#get5_backup_system_enabled). If you define [`get5_backup_path`](../configuration#get5_backup_path), you must include the path in the filename. +####`get5_loadbackup_url [header name] [header value]` {: #get5_loadbackup_url } +: Loads a match backup [from a remote host](../backup#remote) by sending an HTTP(S) `GET` to the given URL. Requires +that the [backup system is enabled](../configuration#get5_backup_system_enabled). 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 (`""`). + ####`get5_last_backup_file` : Prints the name of the last match backup file Get5 wrote in the current series, this is automatically updated each time a backup file is written. Empty string if no backup was written. @@ -117,7 +123,7 @@ You may optionally provide an HTTP header and value pair using the `header name` should put all arguments inside quotation marks (`""`). !!! example - + With `Authorization`:
`get5_loadmatch_url "https://example.com/match_config.json" "Authorization" "Bearer "` @@ -128,6 +134,12 @@ should put all arguments inside quotation marks (`""`). Loading remote matches requires the [SteamWorks](../installation#steamworks) extension. +!!! danger "File URL is public!" + + As the [`get_status`](#get5_status) command is available to all clients, be aware that everyone can see the URL of + the loaded match configuration when loading from a remote. Make sure that your match configuration file does not + contain any sensitive information *or* that it is protected by authorization or is inaccessible to clients. + ####`get5_endmatch [team1|team2]` {: #get5_endmatch } : Force-ends the current match. The team argument will force the winner of the series and the current map to be set to that team. Omitting the team argument sets no winner (tie). @@ -217,7 +229,9 @@ from the server immediately.

**`post_game`**
The map has ended and the countdown to the next map is ongoing. This stage will only occur in multi-map series, as single-map matches end immediately. 3. Whether the game is currently [paused](../pausing). - 4. The match configuration file currently loaded. `Example: "addons/sourcemod/configs/get5/match_config.json"`. + 4. The match configuration file currently loaded. `Example: "addons/sourcemod/configs/get5/match_config.json"`. Note + that this points to the URL of the match configuration when a match was loaded + using [`get5_loadmatch_url`](#get5_loadmatch_url). 5. The current match ID. Empty string if not defined or `scrim` or `manual` if using [`get5_scrim`](../commands#get5_scrim) or [`get5_creatematch`](../commands#get5_creatematch). 6. The current map number, starting at `0`. You can use this to determine the current map by looking at the `maps` diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 64bdf45f4..535ffef91 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -319,6 +319,19 @@ exist.
**`Default: ""`** :no_entry: `/backups/{MATCHID}` +####`get5_remote_backup_url` +: If defined, Get5 will [automatically send backups](../backup#upload) to this URL in an HTTP `POST` request. If no +protocol is provided, `http://` will be prepended to this value. Requires the +[SteamWorks](../installation#steamworks) extension.
**`Default: ""`** + +####`get5_remote_backup_header_key` +: If this **and** [`get5_remote_backup_header_value`](#get5_remote_backup_header_value) are defined, this header name +and value will be used for your [backup upload HTTP request](#get5_remote_backup_url).
**`Default: "Authorization"`** + +####`get5_remote_backup_header_value` +: If this **and** [`get5_remote_backup_header_key`](#get5_remote_backup_header_key) are defined, this header name and +value will be used for your [backup upload HTTP request](#get5_remote_backup_url).
**`Default: ""`** + ## Formats & Paths ####`get5_time_format` diff --git a/documentation/docs/gotv.md b/documentation/docs/gotv.md index 40d3c1f6b..bd3996c94 100644 --- a/documentation/docs/gotv.md +++ b/documentation/docs/gotv.md @@ -46,12 +46,13 @@ read the [headers](#headers) for file metadata. Get5 will add these HTTP headers to its demo upload request: -1. `Get5-DemoName` is the name of the file as defined +1. `Get5-FileName` is the name of the file as defined by [`get5_demo_name_format`](../configuration#get5_demo_name_format), i.e. `2022-09-11_20-49-49_1564_map1_de_vertigo.dem`. 2. `Get5-MapNumber` is the zero-indexed map number in the series. 3. `Get5-MatchId` **if** the [match ID](../match_schema#schema) is not an empty string. 4. `Get5-ServerId` **if** [`get5_server_id`](../configuration#get5_server_id) is set to a positive integer. +5. `Get5-Version` is the version of Get5, i.e. `0.12.0`. #### Authorization {: #authorization } diff --git a/scripting/get5.sp b/scripting/get5.sp index 4b4647722..c2a790902 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -68,6 +68,9 @@ ConVar g_AutoTechPauseMissingPlayersCvar; ConVar g_AutoLoadConfigCvar; ConVar g_AutoReadyActivePlayersCvar; ConVar g_BackupSystemEnabledCvar; +ConVar g_RemoteBackupURLCvar; +ConVar g_RemoteBackupURLHeaderValueCvar; +ConVar g_RemoteBackupURLHeaderKeyCvar; ConVar g_CheckAuthsCvar; ConVar g_DateFormatCvar; ConVar g_DamagePrintCvar; @@ -264,7 +267,8 @@ ArrayList g_ChatAliases; ArrayList g_ChatAliasesCommands; /** Map-game state not related to the actual gameplay. **/ -char g_DemoFileName[PLATFORM_MAX_PATH]; +char g_DemoFilePath[PLATFORM_MAX_PATH]; // full path to demo file being recorded to, including .dem extension +char g_DemoFileName[PLATFORM_MAX_PATH]; // the file name of the demo file, including .dem extension bool g_MapChangePending = false; bool g_PendingSideSwap = false; Handle g_PendingMapChangeTimer = INVALID_HANDLE; @@ -390,6 +394,9 @@ public void OnPluginStart() { g_StopCommandEnabledCvar = CreateConVar("get5_stop_command_enabled", "1", "Whether clients can use the !stop command to restore to the beginning of the current round."); g_StopCommandNoDamageCvar = CreateConVar("get5_stop_command_no_damage", "0", "Whether the stop command becomes unavailable if a player damages a player from the opposing team."); g_StopCommandTimeLimitCvar = CreateConVar("get5_stop_command_time_limit", "0", "The number of seconds into a round after which a team can no longer request/confirm to stop and restart the round."); + g_RemoteBackupURLCvar = CreateConVar("get5_remote_backup_url", "", "A URL to send backup files to over HTTP. Leave empty to disable."); + g_RemoteBackupURLHeaderKeyCvar = CreateConVar("get5_remote_backup_header_key", "Authorization", "If defined, a custom HTTP header with this name is added to the backup HTTP request.", FCVAR_DONTRECORD); + g_RemoteBackupURLHeaderValueCvar = CreateConVar("get5_remote_backup_header_value", "", "If defined, the value of the custom header added to the backup HTTP request.", FCVAR_DONTRECORD | FCVAR_PROTECTED); // Demos g_DemoUploadDeleteAfterCvar = CreateConVar("get5_demo_delete_after_upload", "0", "Whether to delete the demo from the game server after a successful upload."); @@ -500,9 +507,8 @@ public void OnPluginStart() { /** Admin/server commands **/ RegAdminCmd("get5_loadmatch", Command_LoadMatch, ADMFLAG_CHANGEMAP, "Loads a match config file (json or keyvalues) from a file relative to the csgo/ directory"); - RegAdminCmd( - "get5_loadmatch_url", Command_LoadMatchUrl, ADMFLAG_CHANGEMAP, - "Loads a JSON config file by sending a GET request to download it. Requires either the SteamWorks extension."); + RegAdminCmd("get5_loadmatch_url", Command_LoadMatchUrl, ADMFLAG_CHANGEMAP, + "Loads a JSON config file by sending a GET request to download it. Requires the SteamWorks extension."); RegAdminCmd("get5_loadteam", Command_LoadTeam, ADMFLAG_CHANGEMAP, "Loads a team data from a file into a team"); RegAdminCmd("get5_endmatch", Command_EndMatch, ADMFLAG_CHANGEMAP, "Force ends the current match"); RegAdminCmd("get5_addplayer", Command_AddPlayer, ADMFLAG_CHANGEMAP, "Adds a steamid to a match team"); @@ -530,7 +536,8 @@ public void OnPluginStart() { RegAdminCmd("get5_dumpstats", Command_DumpStats, ADMFLAG_CHANGEMAP, "Dumps match stats to a file"); RegAdminCmd("get5_listbackups", Command_ListBackups, ADMFLAG_CHANGEMAP, "Lists get5 match backups for the current matchid or a given one"); - RegAdminCmd("get5_loadbackup", Command_LoadBackup, ADMFLAG_CHANGEMAP, "Loads a get5 match backup"); + RegAdminCmd("get5_loadbackup", Command_LoadBackup, ADMFLAG_CHANGEMAP, "Loads a Get5 match backup from a file relative to the csgo directory."); + RegAdminCmd("get5_loadbackup_url", Command_LoadBackupUrl, ADMFLAG_CHANGEMAP, "Downloads and loads a Get5 match backup from a URL."); RegAdminCmd("get5_debuginfo", Command_DebugInfo, ADMFLAG_CHANGEMAP, "Dumps debug info to a file (addons/sourcemod/logs/get5_debuginfo.txt by default)"); @@ -843,6 +850,7 @@ static Action Timer_ConfigsExecutedCallback(Handle timer) { // Recording is always automatically stopped on map change, and // since there are no hooks to detect tv_stoprecord, we reset // our recording var if a map change is performed unexpectedly. + g_DemoFilePath = ""; g_DemoFileName = ""; DeleteOldBackups(); @@ -981,6 +989,8 @@ bool CheckAutoLoadConfig() { bool loaded = LoadMatchConfig(autoloadConfig, error); // return false if match config load fails! if (loaded) { LogMessage("Match configuration was loaded via get5_autoload_config."); + } else { + MatchConfigFail(error); } return loaded; } @@ -1056,6 +1066,7 @@ static Action Command_LoadMatch(int client, int args) { if (args >= 1 && GetCmdArg(1, arg, sizeof(arg))) { char error[PLATFORM_MAX_PATH]; if (!LoadMatchConfig(arg, error)) { + MatchConfigFail(error); ReplyToCommand(client, error); } } else { @@ -1086,8 +1097,9 @@ static Action Command_LoadMatchUrl(int client, int args) { GetCmdArg(3, headerBuffer, sizeof(headerBuffer)); headerValues.PushString(headerBuffer); } - if (!LoadMatchFromUrl(url, _, _, headerNames, headerValues)) { - ReplyToCommand(client, "Failed to initiate request for remote match config. Please see error logs for details."); + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchFromUrl(url, _, _, headerNames, headerValues, error)) { + ReplyToCommand(client, "Failed to initiate request for remote match config: %s", error); } else { ReplyToCommand(client, "Loading match configuration..."); } @@ -1193,17 +1205,12 @@ void RestoreLastRound(int client) { char lastBackup[PLATFORM_MAX_PATH]; g_LastGet5BackupCvar.GetString(lastBackup, sizeof(lastBackup)); if (!StrEqual(lastBackup, "")) { - if (RestoreFromBackup(lastBackup)) { - char fileFormatted[PLATFORM_MAX_PATH]; - FormatCvarName(fileFormatted, sizeof(fileFormatted), lastBackup); - Get5_MessageToAll("%t", "BackupLoadedInfoMessage", fileFormatted); - // Fix the last backup cvar since it gets reset. - g_LastGet5BackupCvar.SetString(lastBackup); - } else { - ReplyToCommand(client, "Failed to load backup %s - check error logs", lastBackup); + char error[PLATFORM_MAX_PATH]; + if (!RestoreFromBackup(lastBackup, error)) { + ReplyToCommand(client, error); } } else { - ReplyToCommand(client, "Failed to load backup, as previous round backup does not exist."); + ReplyToCommand(client, "Failed to load backup as no backup file from this round exists."); } } diff --git a/scripting/get5/backups.sp b/scripting/get5/backups.sp index 78a8eb0c1..9e7627363 100644 --- a/scripting/get5/backups.sp +++ b/scripting/get5/backups.sp @@ -1,8 +1,9 @@ #define TEMP_MATCHCONFIG_BACKUP_PATTERN "get5_match_config_backup%d.txt" +#define TEMP_REMOTE_BACKUP_PATTERN "get5_backup_remote%d.txt" #define TEMP_VALVE_BACKUP_PATTERN "get5_temp_backup%d.txt" #define TEMP_VALVE_NAMES_FILE_PATTERN "get5_names%d.txt" -Action Command_LoadBackup(int client, int args) { +Action Command_LoadBackupUrl(int client, int args) { if (!g_BackupSystemEnabledCvar.BoolValue) { ReplyToCommand(client, "The backup system is disabled."); return Plugin_Handled; @@ -18,15 +19,45 @@ Action Command_LoadBackup(int client, int args) { return Plugin_Handled; } + char url[PLATFORM_MAX_PATH]; + if ((args != 1 && args != 3) || !GetCmdArg(1, url, sizeof(url))) { + ReplyToCommand(client, "Usage: get5_loadbackup_url [header name] [header value]"); + return Plugin_Handled; + } + + ArrayList headerNames; + ArrayList headerValues; + if (args == 3) { + headerNames = new ArrayList(PLATFORM_MAX_PATH); + headerValues = new ArrayList(PLATFORM_MAX_PATH); + char headerBuffer[PLATFORM_MAX_PATH]; + GetCmdArg(2, headerBuffer, sizeof(headerBuffer)); + headerNames.PushString(headerBuffer); + GetCmdArg(3, headerBuffer, sizeof(headerBuffer)); + headerValues.PushString(headerBuffer); + } + char error[PLATFORM_MAX_PATH]; + if (!LoadBackupFromUrl(url, _, _, headerNames, headerValues, error)) { + ReplyToCommand(client, "Failed to initiate request for remote backup load: %s", error); + } else { + ReplyToCommand(client, "Loading backup from remote..."); + } + delete headerNames; + delete headerValues; + return Plugin_Handled; +} + +Action Command_LoadBackup(int client, int args) { + if (!g_BackupSystemEnabledCvar.BoolValue) { + ReplyToCommand(client, "The backup system is disabled."); + return Plugin_Handled; + } + char path[PLATFORM_MAX_PATH]; if (args >= 1 && GetCmdArg(1, path, sizeof(path))) { - if (RestoreFromBackup(path)) { - char fileFormatted[PLATFORM_MAX_PATH]; - FormatCvarName(fileFormatted, sizeof(fileFormatted), path); - Get5_MessageToAll("%t", "BackupLoadedInfoMessage", fileFormatted); - g_LastGet5BackupCvar.SetString(path); - } else { - ReplyToCommand(client, "Failed to load backup %s - check error logs", path); + char error[PLATFORM_MAX_PATH]; + if (!RestoreFromBackup(path, error)) { + ReplyToCommand(client, error); } } else { ReplyToCommand(client, "Usage: get5_loadbackup "); @@ -175,20 +206,30 @@ void WriteBackup() { char variableSubstitutes[][] = {"{MATCHID}"}; CheckAndCreateFolderPath(g_RoundBackupPathCvar, variableSubstitutes, 1, folder, sizeof(folder)); - char path[PLATFORM_MAX_PATH]; + char filename[PLATFORM_MAX_PATH]; if (g_GameState == Get5State_Live) { - FormatEx(path, sizeof(path), "%sget5_backup%d_match%s_map%d_round%d.cfg", folder, Get5_GetServerID(), g_MatchID, + FormatEx(filename, sizeof(filename), "get5_backup%d_match%s_map%d_round%d.cfg", Get5_GetServerID(), g_MatchID, g_MapNumber, g_RoundNumber); } else { - FormatEx(path, sizeof(path), "%sget5_backup%d_match%s_map%d_prelive.cfg", folder, Get5_GetServerID(), g_MatchID, + FormatEx(filename, sizeof(filename), "get5_backup%d_match%s_map%d_prelive.cfg", Get5_GetServerID(), g_MatchID, g_MapNumber); } + + char path[PLATFORM_MAX_PATH]; + if (strlen(folder) > 0) { + FormatEx(path, sizeof(path), "%s%s", folder, filename); + } else { + strcopy(path, sizeof(path), filename); + } + LogDebug("Writing backup to %s", path); - WriteBackupStructure(path); - g_LastGet5BackupCvar.SetString(path); + if (WriteBackupStructure(path)) { + g_LastGet5BackupCvar.SetString(path); + UploadBackupFile(path, filename, g_MatchID, g_MapNumber, g_RoundNumber); + } } -static void WriteBackupStructure(const char[] path) { +static bool WriteBackupStructure(const char[] path) { KeyValues kv = new KeyValues("Backup"); char timeString[PLATFORM_MAX_PATH]; FormatTime(timeString, sizeof(timeString), "%Y-%m-%d %H:%M:%S", GetTime()); @@ -255,7 +296,7 @@ static void WriteBackupStructure(const char[] path) { if (strlen(lastBackup) == 0) { LogError("Found no Valve backup when attempting to write a backup during the live state. This is a bug!"); delete kv; - return; + return false; } // Write valve's backup format into the file. This only applies to live rounds, as any pre-live // backups should just restart the game to warmup (post-veto). @@ -271,6 +312,7 @@ static void WriteBackupStructure(const char[] path) { if (!success) { LogError("Failed to import Valve backup into Get5 backup."); delete kv; + return false; } if (DeleteFile(lastBackup)) { lastBackupCvar.SetString(""); @@ -282,23 +324,104 @@ static void WriteBackupStructure(const char[] path) { KvCopySubkeys(g_StatsKv, kv); kv.GoBack(); - if (!kv.ExportToFile(path)) { + bool success = kv.ExportToFile(path); + if (!success) { LogError("Failed to write Get5 backup to file \"%s\".", path); } delete kv; + return success; } -bool RestoreFromBackup(const char[] path) { +static void UploadBackupFile(const char[] file, const char[] filename, const char[] matchId, const int mapNumber, + const int roundNumber) { + char backupUrl[1024]; + g_RemoteBackupURLCvar.GetString(backupUrl, sizeof(backupUrl)); + if (strlen(backupUrl) == 0) { + LogDebug("Not uploading backup file as no URL was set."); + return; + } + + char error[PLATFORM_MAX_PATH]; + Handle request = CreateGet5HTTPRequest(k_EHTTPMethodPOST, backupUrl, error); + if (request == INVALID_HANDLE || !AddFileAsHttpBody(request, file, error) || + !SetFileNameHeader(request, filename, error) || !SetMatchIdHeader(request, matchId, error) || + !SetMapNumberHeader(request, mapNumber, error) || !SetRoundNumberHeader(request, roundNumber, error)) { + LogError(error); + delete request; + return; + } + + char backupUrlHeaderKey[1024]; + char backupUrlHeaderValue[1024]; + + g_RemoteBackupURLHeaderKeyCvar.GetString(backupUrlHeaderKey, sizeof(backupUrlHeaderKey)); + g_RemoteBackupURLHeaderValueCvar.GetString(backupUrlHeaderValue, sizeof(backupUrlHeaderValue)); + + if (strlen(backupUrlHeaderKey) > 0 && strlen(backupUrlHeaderValue) > 0 && + !SetHeaderKeyValuePair(request, backupUrlHeaderKey, backupUrlHeaderValue, error)) { + LogError(error); + delete request; + return; + } + + DataPack pack = new DataPack(); + pack.WriteString(backupUrl); + pack.WriteString(filename); + + SteamWorks_SetHTTPRequestContextValue(request, pack); + SteamWorks_SetHTTPCallbacks(request, BackupUpload_Callback); + SteamWorks_SendHTTPRequest(request); +} + +static void BackupUpload_Callback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, + DataPack pack) { + char url[1024]; + char filename[PLATFORM_MAX_PATH]; + pack.Reset(); + pack.ReadString(url, sizeof(url)); + pack.ReadString(filename, sizeof(filename)); + delete pack; + + if (failure || !requestSuccessful) { + LogError("Failed to upload backup file '%s' to '%s'. Make sure your URL is enclosed in quotes.", filename, url); + } else if (!CheckForSuccessfulResponse(request, statusCode)) { + LogError("Failed to upload backup file '%s' to '%s'. HTTP status code: %d.", filename, url, statusCode); + } + delete request; +} + +bool RestoreFromBackup(const char[] path, char[] error) { + if (g_PendingSideSwap || InHalftimePhase()) { + FormatEx(error, PLATFORM_MAX_PATH, "You cannot load a backup during halftime."); + return false; + } + + if (IsDoingRestoreOrMapChange()) { + FormatEx(error, PLATFORM_MAX_PATH, + "A map change or backup restore is in progress. You cannot load a backup right now."); + return false; + } + + if (!FileExists(path)) { + FormatEx(error, PLATFORM_MAX_PATH, "Backup file \"%s\" does not exists or cannot be read.", path); + return false; + } + + if (!CheckKeyValuesFile(path, error, PLATFORM_MAX_PATH)) { + Format(error, PLATFORM_MAX_PATH, "Failed to read backup file \"%s\" as valid KeyValues. Error: %s", path, error); + return false; + } + KeyValues kv = new KeyValues("Backup"); if (!kv.ImportFromFile(path)) { - LogError("Failed to read backup file \"%s\"", path); + FormatEx(error, PLATFORM_MAX_PATH, "Failed to read backup from file: \"%s\".", path); delete kv; return false; } int loadedMapNumber = kv.GetNum("mapnumber", -1); if (loadedMapNumber == -1) { - LogError("The backup was created with an earlier version of Get5 and is not compatible."); + FormatEx(error, PLATFORM_MAX_PATH, "The backup was created with an earlier version of Get5 and is not compatible."); delete kv; return false; } @@ -338,7 +461,7 @@ bool RestoreFromBackup(const char[] path) { if (shouldRestartRecording) { // We must stop recording to fire the Get5_OnDemoFinished event when loading a backup to another match or map, and - // we must do it before we load the match config, or the g_MatchID, g_MapNumber and g_DemoFileName variables will be + // we must do it before we load the match config, or the g_MatchID, g_MapNumber and g_DemoFilePath variables will be // incorrect. This is suppressed if we load to the same match and map ID during a live match, either via // get5_loadbackup or the !stop-command, as we want only 1 demo file in those cases. StopRecording(); @@ -348,7 +471,6 @@ bool RestoreFromBackup(const char[] path) { char tempBackupFile[PLATFORM_MAX_PATH]; GetTempFilePath(tempBackupFile, sizeof(tempBackupFile), TEMP_MATCHCONFIG_BACKUP_PATTERN); kv.ExportToFile(tempBackupFile); - char error[PLATFORM_MAX_PATH]; if (!LoadMatchConfig(tempBackupFile, error, true)) { delete kv; // If the backup load fails, all the game configs will have been reset by LoadMatchConfig, @@ -488,6 +610,10 @@ bool RestoreFromBackup(const char[] path) { EventLogger_LogAndDeleteEvent(backupEvent); + char fileFormatted[PLATFORM_MAX_PATH]; + FormatCvarName(fileFormatted, sizeof(fileFormatted), path); + Get5_MessageToAll("%t", "BackupLoadedInfoMessage", fileFormatted); + g_LastGet5BackupCvar.SetString(path); // Loading a match config resets this Cvar. return true; } @@ -596,3 +722,56 @@ void DeleteOldBackups() { LogError("Failed to list contents of directory '%s' for backup deletion.", path); } } + +bool LoadBackupFromUrl(const char[] url, const ArrayList paramNames = null, const ArrayList paramValues = null, + const ArrayList headerNames = null, const ArrayList headerValues = null, char[] error) { + if (!LibraryExists("SteamWorks")) { + FormatEx(error, PLATFORM_MAX_PATH, "The SteamWorks extension is required in order to load backups over HTTP."); + return false; + } + + Handle request = CreateGet5HTTPRequest(k_EHTTPMethodGET, url, error); + if (request == INVALID_HANDLE || !SetMultipleQueryParameters(request, paramNames, paramValues, error) || + !SetMultipleHeaders(request, headerNames, headerValues, error)) { + delete request; + return false; + } + + DataPack pack = new DataPack(); + pack.WriteString(url); + + SteamWorks_SetHTTPRequestContextValue(request, pack); + SteamWorks_SetHTTPCallbacks(request, LoadBackup_Callback); + SteamWorks_SendHTTPRequest(request); + return true; +} + +static void LoadBackup_Callback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, + DataPack pack) { + + char loadedUrl[PLATFORM_MAX_PATH]; + pack.Reset(); + pack.ReadString(loadedUrl, sizeof(loadedUrl)); + delete pack; + + if (failure || !requestSuccessful) { + LogError("Failed to load backup file from '%s'. Make sure your URL is enclosed in quotes.", loadedUrl); + } else if (!CheckForSuccessfulResponse(request, statusCode)) { + LogError("Failed to load backup file from '%s'. HTTP status code: %d.", loadedUrl, statusCode); + } else { + char remoteBackup[PLATFORM_MAX_PATH]; + char error[PLATFORM_MAX_PATH]; + GetTempFilePath(remoteBackup, sizeof(remoteBackup), TEMP_REMOTE_BACKUP_PATTERN); + if (SteamWorks_WriteHTTPResponseBodyToFile(request, remoteBackup)) { + if (!RestoreFromBackup(remoteBackup, error)) { + LogError(error); + } else if (FileExists(remoteBackup) && !DeleteFile(remoteBackup)) { + // We only delete the file if it loads successfully, as it may be used for debugging otherwise. + LogError("Unable to delete temporary backup file '%s'.", remoteBackup); + } + } else { + LogError("Failed to write temporary backup to file '%s'.", remoteBackup); + } + } + delete request; +} diff --git a/scripting/get5/events.sp b/scripting/get5/events.sp index abca386c0..5966b472a 100644 --- a/scripting/get5/events.sp +++ b/scripting/get5/events.sp @@ -14,8 +14,10 @@ void SendEventJSONToURL(const char[] event) { return; } - Handle eventRequest = CreateGet5HTTPRequest(k_EHTTPMethodPOST, eventUrl); + static char error[PLATFORM_MAX_PATH]; + Handle eventRequest = CreateGet5HTTPRequest(k_EHTTPMethodPOST, eventUrl, error); if (eventRequest == INVALID_HANDLE) { + LogError(error); return; } @@ -25,13 +27,11 @@ void SendEventJSONToURL(const char[] event) { g_EventLogRemoteHeaderKeyCvar.GetString(eventUrlHeaderKey, sizeof(eventUrlHeaderKey)); g_EventLogRemoteHeaderValueCvar.GetString(eventUrlHeaderValue, sizeof(eventUrlHeaderValue)); - if (strlen(eventUrlHeaderKey) > 0 && strlen(eventUrlHeaderValue) > 0) { - if (!SteamWorks_SetHTTPRequestHeaderValue(eventRequest, eventUrlHeaderKey, eventUrlHeaderValue)) { - LogError("Failed to add header '%s' with value '%s' to event HTTP request.", eventUrlHeaderKey, - eventUrlHeaderValue); - delete eventRequest; - return; - } + if (strlen(eventUrlHeaderKey) > 0 && strlen(eventUrlHeaderValue) > 0 && + !SetHeaderKeyValuePair(eventRequest, eventUrlHeaderKey, eventUrlHeaderValue, error)) { + LogError(error); + delete eventRequest; + return; } SteamWorks_SetHTTPRequestRawPostBody(eventRequest, "application/json", event, strlen(event)); SteamWorks_SetHTTPRequestNetworkActivityTimeout(eventRequest, 15); // Default 60 is a bit much. @@ -41,21 +41,10 @@ void SendEventJSONToURL(const char[] event) { static int EventRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode) { if (failure || !requestSuccessful) { - LogError("Event HTTP request failed due to a network or configuration error."); - delete request; - return; - } - int status = view_as(statusCode); - if (status >= 300 || status < 200) { + LogError( + "Event HTTP request failed due to a network or configuration error. Make sure you have enclosed your event URL in quotes."); + } else if (!CheckForSuccessfulResponse(request, statusCode)) { LogError("Event HTTP request failed with status code: %d.", statusCode); - int responseSize; - SteamWorks_GetHTTPResponseBodySize(request, responseSize); - char[] response = new char[responseSize]; - if (SteamWorks_GetHTTPResponseBodyData(request, response, responseSize)) { - LogError("Response body: %s", response); - } else { - LogError("Failed to read response body."); - } } delete request; } diff --git a/scripting/get5/http.sp b/scripting/get5/http.sp index 45eb5b2e2..4f33980f6 100644 --- a/scripting/get5/http.sp +++ b/scripting/get5/http.sp @@ -1,15 +1,17 @@ -#define GET5_HEADER_MATCHID "Get5-MatchId" -#define GET5_HEADER_MAPNUMBER "Get5-MapNumber" -#define GET5_HEADER_SERVERID "Get5-ServerId" -#define GET5_HEADER_DEMONAME "Get5-DemoName" +#define GET5_HEADER_MATCHID "Get5-MatchId" +#define GET5_HEADER_MAPNUMBER "Get5-MapNumber" +#define GET5_HEADER_ROUNDNUMBER "Get5-RoundNumber" +#define GET5_HEADER_SERVERID "Get5-ServerId" +#define GET5_HEADER_FILENAME "Get5-FileName" +#define GET5_HEADER_VERSION "Get5-Version" -Handle CreateGet5HTTPRequest(const EHTTPMethod method, const char[] url) { +Handle CreateGet5HTTPRequest(const EHTTPMethod method, const char[] url, char[] error) { static char formattedUrl[1024]; strcopy(formattedUrl, 1024, url); PrependProtocolToURLIfRequired(formattedUrl, sizeof(formattedUrl)); Handle request = SteamWorks_CreateHTTPRequest(method, formattedUrl); if (request == INVALID_HANDLE) { - LogError("Failed to create HTTP request for URL: %s", formattedUrl); + FormatEx(error, PLATFORM_MAX_PATH, "Failed to create HTTP request for URL: %s", formattedUrl); return INVALID_HANDLE; } SetGet5ServerIdHeader(request); @@ -23,15 +25,46 @@ static void PrependProtocolToURLIfRequired(char[] url, const int urlSize) { } } +bool CheckForSuccessfulResponse(const Handle request, const EHTTPStatusCode statusCode) { + int status = view_as(statusCode); + if (status < 200 || status >= 300) { + int responseSize; + SteamWorks_GetHTTPResponseBodySize(request, responseSize); + char[] response = new char[responseSize]; + if (SteamWorks_GetHTTPResponseBodyData(request, response, responseSize)) { + LogDebug("HTTP response body: %s", response); + } else { + LogDebug("Failed to read HTTP response body."); + } + return false; + } + return true; +} + static bool SetGet5UserAgent(const Handle request) { static char userAgent[128]; static bool didWriteBuffer; if (!didWriteBuffer) { - // Since this never changes during the lifetime of the plugin, we only need to format it once. + // Since this never changes during the lifetime of the plugin, we only need to write it once. FormatEx(userAgent, 128, "SourceMod Get5 %s+https://%s", PLUGIN_VERSION, GET5_GITHUB_PAGE); didWriteBuffer = true; } - return SteamWorks_SetHTTPRequestUserAgentInfo(request, userAgent); + return SteamWorks_SetHTTPRequestUserAgentInfo(request, userAgent) && + SteamWorks_SetHTTPRequestHeaderValue(request, GET5_HEADER_VERSION, PLUGIN_VERSION); +} + +bool SetHeaderKeyValuePair(const Handle request, const char[] header, const char[] value, char[] error) { + if (!SteamWorks_SetHTTPRequestHeaderValue(request, header, value)) { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to add header '%s' with value '%s' to HTTP request.", header, value); + return false; + } + return true; +} + +static bool SetHeaderKeyValuePairInt(const Handle request, const char[] header, const int value, char[] error) { + char strValue[5]; + IntToString(value, strValue, sizeof(strValue)); + return SetHeaderKeyValuePair(request, header, strValue, error); } static bool SetGet5ServerIdHeader(const Handle request) { @@ -43,3 +76,72 @@ static bool SetGet5ServerIdHeader(const Handle request) { IntToString(serverId, serverIdString, sizeof(serverIdString)); return SteamWorks_SetHTTPRequestHeaderValue(request, GET5_HEADER_SERVERID, serverIdString); } + +bool AddFileAsHttpBody(const Handle request, const char[] file, char[] error) { + if (!FileExists(file) || !SteamWorks_SetHTTPRequestRawPostBodyFromFile(request, "application/octet-stream", file)) { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to add file '%s' as POST body for HTTP request.", file); + return false; + } + return true; +} + +bool SetFileNameHeader(const Handle request, const char[] filename, char[] error) { + return SetHeaderKeyValuePair(request, GET5_HEADER_FILENAME, filename, error); +} + +bool SetMatchIdHeader(const Handle request, const char[] matchId, char[] error) { + if (strlen(matchId) == 0) { + return true; + } + return SetHeaderKeyValuePair(request, GET5_HEADER_MATCHID, matchId, error); +} + +bool SetMapNumberHeader(const Handle request, const int mapNumber, char[] error) { + return SetHeaderKeyValuePairInt(request, GET5_HEADER_MAPNUMBER, mapNumber, error); +} + +bool SetRoundNumberHeader(const Handle request, const int roundNumber, char[] error) { + return SetHeaderKeyValuePairInt(request, GET5_HEADER_ROUNDNUMBER, roundNumber, error); +} + +bool SetMultipleHeaders(const Handle request, const ArrayList headerNames, const ArrayList headerValues, char[] error) { + char key[1024]; + char value[1024]; + if (headerNames == null && headerValues == null) { + return true; + } + if (headerNames.Length != headerValues.Length) { + FormatEx(error, PLATFORM_MAX_PATH, "The number of header keys and values must be identical."); + return false; + } + for (int i = 0; i < headerNames.Length; i++) { + headerNames.GetString(i, key, sizeof(key)); + headerValues.GetString(i, value, sizeof(value)); + if (!SetHeaderKeyValuePair(request, key, value, error)) { + return false; + } + } + return true; +} + +bool SetMultipleQueryParameters(const Handle request, const ArrayList paramNames, const ArrayList paramValues, + char[] error) { + char key[1024]; + char value[1024]; + if (paramNames == null && paramValues == null) { + return true; + } + if (paramNames.Length != paramValues.Length) { + FormatEx(error, PLATFORM_MAX_PATH, "The number of query parameter keys and values must be identical."); + return false; + } + for (int i = 0; i < paramNames.Length; i++) { + paramNames.GetString(i, key, sizeof(key)); + paramValues.GetString(i, value, sizeof(value)); + if (!SteamWorks_SetHTTPRequestGetOrPostParameter(request, key, value)) { + FormatEx(error, PLATFORM_MAX_PATH, "Failed to set HTTP query parameter '%s' with value '%s'.", key, value); + return false; + } + } + return true; +} diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 2b6d568f0..2600c1651 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -18,7 +18,6 @@ bool LoadMatchConfig(const char[] config, char[] error, bool restoreBackup = false) { if (g_GameState != Get5State_None && !restoreBackup) { Format(error, PLATFORM_MAX_PATH, "Cannot load a match configuration when a match is already loaded."); - MatchConfigFail(error); return false; } @@ -47,14 +46,13 @@ bool LoadMatchConfig(const char[] config, char[] error, bool restoreBackup = fal GetConVarStringSafe("hostname", g_HostnamePreGet5, sizeof(g_HostnamePreGet5)); } - if (!LoadMatchFile(config, error)) { - MatchConfigFail(error); + if (!LoadMatchFile(config, error, restoreBackup)) { return false; } if (g_NumberOfMapsInSeries > g_MapPoolList.Length) { - FormatEx(error, PLATFORM_MAX_PATH, "Cannot play a series of %d maps with a maplist of only %d maps.", g_NumberOfMapsInSeries, g_MapPoolList.Length); - MatchConfigFail(error); + FormatEx(error, PLATFORM_MAX_PATH, "Cannot play a series of %d maps with a maplist of only %d maps.", + g_NumberOfMapsInSeries, g_MapPoolList.Length); return false; } @@ -111,7 +109,7 @@ bool LoadMatchConfig(const char[] config, char[] error, bool restoreBackup = fal // depends on it. We set this one first as the others may depend on something changed in the match // cvars section. ExecuteMatchConfigCvars(); - SetStartingTeams(); // must go before SetMatchTeamCvars as it depends on correct starting teams! + SetStartingTeams(); // must go before SetMatchTeamCvars as it depends on correct starting teams! SetMatchTeamCvars(); LoadPlayerNames(); AddTeamLogosToDownloadTable(); @@ -183,16 +181,15 @@ static Action Timer_PlacePlayerFromTeamNone(Handle timer, int client) { } } -static bool LoadMatchFile(const char[] config, char[] error) { - Get5PreloadMatchConfigEvent event = new Get5PreloadMatchConfigEvent(config); - - LogDebug("Calling Get5_OnPreLoadMatchConfig()"); - - Call_StartForward(g_OnPreLoadMatchConfig); - Call_PushCell(event); - Call_Finish(); - - EventLogger_LogAndDeleteEvent(event); +static bool LoadMatchFile(const char[] config, char[] error, bool backup) { + if (!backup) { + LogDebug("Calling Get5_OnPreLoadMatchConfig()"); + Get5PreloadMatchConfigEvent event = new Get5PreloadMatchConfigEvent(config); + Call_StartForward(g_OnPreLoadMatchConfig); + Call_PushCell(event); + Call_Finish(); + EventLogger_LogAndDeleteEvent(event); + } if (!FileExists(config)) { FormatEx(error, PLATFORM_MAX_PATH, "Match config file doesn't exist: \"%s\".", config); @@ -226,10 +223,10 @@ static bool LoadMatchFile(const char[] config, char[] error) { return success; } -static void MatchConfigFail(const char[] reason, any...) { +void MatchConfigFail(const char[] reason, any...) { char buffer[512]; VFormat(buffer, sizeof(buffer), reason, 2); - LogError("Failed to load match config: %s", buffer); + LogError("Failed to load match configuration: %s", buffer); Get5LoadMatchConfigFailedEvent event = new Get5LoadMatchConfigFailedEvent(buffer); @@ -243,67 +240,31 @@ static void MatchConfigFail(const char[] reason, any...) { } bool LoadMatchFromUrl(const char[] url, const ArrayList paramNames = null, const ArrayList paramValues = null, - const ArrayList headerNames = null, const ArrayList headerValues = null) { + const ArrayList headerNames = null, const ArrayList headerValues = null, char[] error) { if (!LibraryExists("SteamWorks")) { - MatchConfigFail("The SteamWorks extension is required in order to load match configurations over HTTP."); + FormatEx(error, PLATFORM_MAX_PATH, + "The SteamWorks extension is required in order to load match configurations over HTTP."); return false; } - Handle request = CreateGet5HTTPRequest(k_EHTTPMethodGET, url); - if (request == INVALID_HANDLE) { - MatchConfigFail("Failed to create remote match load request for URL '%s'.", url); + Handle request = CreateGet5HTTPRequest(k_EHTTPMethodGET, url, error); + if (request == INVALID_HANDLE || !SetMultipleHeaders(request, headerNames, headerValues, error) || + !SetMultipleQueryParameters(request, paramNames, paramValues, error)) { + delete request; return false; } - char key[1024]; - char value[1024]; - if (headerNames != null && headerValues != null) { - if (headerNames.Length != headerValues.Length) { - MatchConfigFail("The number of header keys and values must be identical."); - delete request; - return false; - } - - for (int i = 0; i < headerNames.Length; i++) { - headerNames.GetString(i, key, sizeof(key)); - headerValues.GetString(i, value, sizeof(value)); - if (!SteamWorks_SetHTTPRequestHeaderValue(request, key, value)) { - MatchConfigFail("Failed to set HTTP header '%s' with value '%s'.", key, value); - delete request; - return false; - } - } - } - - if (paramNames != null && paramValues != null) { - if (paramNames.Length != paramValues.Length) { - MatchConfigFail("The number of query parameter keys and values must be identical."); - delete request; - return false; - } - - for (int i = 0; i < paramNames.Length; i++) { - paramNames.GetString(i, key, sizeof(key)); - paramValues.GetString(i, value, sizeof(value)); - if (!SteamWorks_SetHTTPRequestGetOrPostParameter(request, key, value)) { - MatchConfigFail("Failed to set HTTP query parameter '%s' with value '%s'.", key, value); - delete request; - return false; - } - } - } - DataPack pack = new DataPack(); pack.WriteString(url); SteamWorks_SetHTTPRequestContextValue(request, pack); - SteamWorks_SetHTTPCallbacks(request, SteamWorks_OnMatchConfigReceived); + SteamWorks_SetHTTPCallbacks(request, LoadMatchFromUrl_Callback); SteamWorks_SendHTTPRequest(request); return true; } -static int SteamWorks_OnMatchConfigReceived(Handle request, bool failure, bool requestSuccessful, - EHTTPStatusCode statusCode, DataPack pack) { +static int LoadMatchFromUrl_Callback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, + DataPack pack) { char loadedUrl[PLATFORM_MAX_PATH]; pack.Reset(); @@ -312,41 +273,28 @@ static int SteamWorks_OnMatchConfigReceived(Handle request, bool failure, bool r if (failure || !requestSuccessful) { MatchConfigFail( - "Match config HTTP request for '%s' failed due to a network or configuration error. Make sure you have enclosed your URL in quotes.", + "HTTP request for '%s' failed due to a network or configuration error. Make sure you have enclosed your URL in quotes.", loadedUrl); - delete request; - return; - } - - int status = view_as(statusCode); - if (status >= 300 || status < 200) { - MatchConfigFail("Match config HTTP request for '%s' failed with HTTP status code: %d.", loadedUrl, statusCode); - int responseSize; - SteamWorks_GetHTTPResponseBodySize(request, responseSize); - char[] response = new char[responseSize]; - if (SteamWorks_GetHTTPResponseBodyData(request, response, responseSize)) { - LogError("Response body: %s", response); - } else { - LogError("Failed to read response body."); - } - delete request; - return; - } - - char remoteConfig[PLATFORM_MAX_PATH]; - char error[PLATFORM_MAX_PATH]; - GetTempFilePath(remoteConfig, sizeof(remoteConfig), REMOTE_CONFIG_PATTERN); - if (SteamWorks_WriteHTTPResponseBodyToFile(request, remoteConfig)) { - if (LoadMatchConfig(remoteConfig, error)) { - // Override g_LoadedConfigFile to point to the URL instead of the local temp file. - strcopy(g_LoadedConfigFile, sizeof(g_LoadedConfigFile), loadedUrl); - // We only delete the file if it loads successfully, as it may be used for debugging otherwise. - if (FileExists(remoteConfig) && !DeleteFile(remoteConfig)) { - LogError("Unable to delete temporary match config file '%s'.", remoteConfig); + } else if (!CheckForSuccessfulResponse(request, statusCode)) { + MatchConfigFail("HTTP request for '%s' failed with HTTP status code: %d.", loadedUrl, statusCode); + } else { + char remoteConfig[PLATFORM_MAX_PATH]; + char error[PLATFORM_MAX_PATH]; + GetTempFilePath(remoteConfig, sizeof(remoteConfig), REMOTE_CONFIG_PATTERN); + if (SteamWorks_WriteHTTPResponseBodyToFile(request, remoteConfig)) { + if (LoadMatchConfig(remoteConfig, error)) { + // Override g_LoadedConfigFile to point to the URL instead of the local temp file. + strcopy(g_LoadedConfigFile, sizeof(g_LoadedConfigFile), loadedUrl); + // We only delete the file if it loads successfully, as it may be used for debugging otherwise. + if (FileExists(remoteConfig) && !DeleteFile(remoteConfig)) { + LogError("Unable to delete temporary match config file '%s'.", remoteConfig); + } + } else { + MatchConfigFail(error); } + } else { + MatchConfigFail("Failed to write match configuration to file '%s'.", remoteConfig); } - } else { - MatchConfigFail("Failed to write match configuration to file '%s'.", remoteConfig); } delete request; } @@ -410,7 +358,8 @@ static void AddTeamBackupData(const char[] key, const KeyValues kv, const Get5Te kv.GoBack(); } -static void WritePlayerDataToKV(const char[] key, const ArrayList players, const KeyValues kv, char[] auth, char[] name) { +static void WritePlayerDataToKV(const char[] key, const ArrayList players, const KeyValues kv, char[] auth, + char[] name) { kv.JumpToKey(key, true); for (int i = 0; i < players.Length; i++) { players.GetString(i, auth, AUTH_LENGTH); @@ -522,7 +471,8 @@ static bool LoadMatchFromJson(const JSON_Object json, char[] error) { g_PlayersPerTeam = json_object_get_int_safe(json, "players_per_team", CONFIG_PLAYERSPERTEAM_DEFAULT); g_CoachesPerTeam = json_object_get_int_safe(json, "coaches_per_team", CONFIG_COACHESPERTEAM_DEFAULT); g_MinPlayersToReady = json_object_get_int_safe(json, "min_players_to_ready", CONFIG_MINPLAYERSTOREADY_DEFAULT); - g_MinSpectatorsToReady = json_object_get_int_safe(json, "min_spectators_to_ready", CONFIG_MINSPECTATORSTOREADY_DEFAULT); + g_MinSpectatorsToReady = + json_object_get_int_safe(json, "min_spectators_to_ready", CONFIG_MINSPECTATORSTOREADY_DEFAULT); g_SkipVeto = json_object_get_bool_safe(json, "skip_veto", CONFIG_SKIPVETO_DEFAULT); g_CoachesMustReady = json_object_get_bool_safe(json, "coaches_must_ready", CONFIG_COACHES_MUST_READY_DEFAULT); g_NumberOfMapsInSeries = json_object_get_int_safe(json, "num_maps", CONFIG_NUM_MAPSDEFAULT); @@ -673,14 +623,15 @@ static bool LoadMapListJson(const JSON_Object json, char[] error, const bool all if (allowFromFile && json.GetString("fromfile", mapFileName, PLATFORM_MAX_PATH) && strlen(mapFileName) > 0) { success = LoadMapListFromFile(mapFileName, error); } else { - FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + FormatEx( + error, PLATFORM_MAX_PATH, + "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); } } return success; } -static bool LoadTeamDataKeyValue(const KeyValues kv, const Get5Team matchTeam, char[] error, - const bool allowFromFile) { +static bool LoadTeamDataKeyValue(const KeyValues kv, const Get5Team matchTeam, char[] error, const bool allowFromFile) { char fromfile[PLATFORM_MAX_PATH]; if (allowFromFile) { kv.GetString("fromfile", fromfile, sizeof(fromfile)); @@ -706,8 +657,7 @@ static bool LoadTeamDataKeyValue(const KeyValues kv, const Get5Team matchTeam, c } } -static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, char[] error, - const bool allowFromFile) { +static bool LoadTeamDataJson(const JSON_Object json, const Get5Team matchTeam, char[] error, const bool allowFromFile) { if (json.IsArray) { FormatEx(error, PLATFORM_MAX_PATH, "Team data in JSON is array. Must be object."); return false; @@ -750,7 +700,8 @@ static bool LoadMapListFromFile(const char[] fromFile, char[] error) { if (IsJSONPath(fromFile)) { JSON_Object jsonFromFile = json_read_from_file(fromFile, JSON_DECODE_ORDERED_KEYS); if (jsonFromFile == null) { - FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" -> \"fromfile\" points to an invalid or unreadable JSON file: \"%s\".", fromFile); + FormatEx(error, PLATFORM_MAX_PATH, + "\"maplist\" -> \"fromfile\" points to an invalid or unreadable JSON file: \"%s\".", fromFile); } else { success = LoadMapListJson(jsonFromFile, error, false); json_cleanup_and_delete(jsonFromFile); @@ -758,7 +709,9 @@ static bool LoadMapListFromFile(const char[] fromFile, char[] error) { } else { char parseError[PLATFORM_MAX_PATH]; if (!CheckKeyValuesFile(fromFile, parseError, sizeof(parseError))) { - FormatEx(error, PLATFORM_MAX_PATH, "\"maplist\" -> \"fromfile\" points to an invalid or unreadable KV file: \"%s\". Error: %s", fromFile, parseError); + FormatEx(error, PLATFORM_MAX_PATH, + "\"maplist\" -> \"fromfile\" points to an invalid or unreadable KV file: \"%s\". Error: %s", fromFile, + parseError); } else { KeyValues kvFromFile = new KeyValues("maplist"); if (kvFromFile.ImportFromFile(fromFile)) { @@ -843,13 +796,14 @@ void SetMatchTeamCvars() { static void SetTeamSpecificCvars(const Get5Team team) { char teamText[MAX_CVAR_LENGTH]; - strcopy(teamText, sizeof(teamText), g_TeamMatchTexts[team]); // Copy as we don't want to modify the original values. + strcopy(teamText, sizeof(teamText), g_TeamMatchTexts[team]); // Copy as we don't want to modify the original values. int teamScore = g_TeamSeriesScores[team]; if (g_MapsToWin > 1 && strlen(teamText) == 0) { // If we play BoX > 1 and no match team text was specifically set, overwrite with the map series score: IntToString(teamScore, teamText, sizeof(teamText)); } - // For this specifically, the starting side is the one to use, as the game swaps _1 and _2 cvars itself after halftime. + // For this specifically, the starting side is the one to use, as the game swaps _1 and _2 cvars itself after + // halftime. Get5Side side = view_as(g_TeamStartingSide[team]); SetTeamInfo(side, g_TeamNames[team], g_TeamFlags[team], g_TeamLogos[team], teamText, teamScore); } diff --git a/scripting/get5/natives.sp b/scripting/get5/natives.sp index dd30c7110..04e9a368b 100644 --- a/scripting/get5/natives.sp +++ b/scripting/get5/natives.sp @@ -7,6 +7,7 @@ public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max CreateNative("Get5_MessageToAll", Native_MessageToAll); CreateNative("Get5_LoadMatchConfig", Native_LoadMatchConfig); CreateNative("Get5_LoadMatchConfigFromURL", Native_LoadMatchConfigFromURL); + CreateNative("Get5_LoadBackupFromURL", Native_LoadBackupFromURL); CreateNative("Get5_AddPlayerToTeam", Native_AddPlayerToTeam); CreateNative("Get5_SetPlayerName", Native_SetPlayerName); CreateNative("Get5_RemovePlayerFromTeam", Native_RemovePlayerFromTeam); @@ -119,7 +120,9 @@ public int Native_LoadMatchConfig(Handle plugin, int numParams) { char filename[PLATFORM_MAX_PATH]; GetNativeString(1, filename, sizeof(filename)); char error[PLATFORM_MAX_PATH]; - return LoadMatchConfig(filename, error); + if (!LoadMatchConfig(filename, error)) { + MatchConfigFail(error); + } } public int Native_LoadMatchConfigFromURL(Handle plugin, int numParams) { @@ -129,7 +132,23 @@ public int Native_LoadMatchConfigFromURL(Handle plugin, int numParams) { ArrayList paramValues = view_as(GetNativeCell(3)); ArrayList headerNames = view_as(GetNativeCell(4)); ArrayList headerValues = view_as(GetNativeCell(5)); - return LoadMatchFromUrl(url, paramNames, paramValues, headerNames, headerValues); + char error[PLATFORM_MAX_PATH]; + if (!LoadMatchFromUrl(url, paramNames, paramValues, headerNames, headerValues, error)) { + LogError(error); + } +} + +public int Native_LoadBackupFromURL(Handle plugin, int numParams) { + char url[PLATFORM_MAX_PATH]; + GetNativeString(1, url, sizeof(url)); + ArrayList paramNames = view_as(GetNativeCell(2)); + ArrayList paramValues = view_as(GetNativeCell(3)); + ArrayList headerNames = view_as(GetNativeCell(4)); + ArrayList headerValues = view_as(GetNativeCell(5)); + char error[PLATFORM_MAX_PATH]; + if (!LoadBackupFromUrl(url, paramNames, paramValues, headerNames, headerValues, error)) { + LogError(error); + } } public int Native_AddPlayerToTeam(Handle plugin, int numParams) { diff --git a/scripting/get5/pausing.sp b/scripting/get5/pausing.sp index 9c1781122..17947ebcf 100644 --- a/scripting/get5/pausing.sp +++ b/scripting/get5/pausing.sp @@ -49,7 +49,7 @@ void UnpauseGame(Get5Team team) { EventLogger_LogAndDeleteEvent(event); - delete g_PauseTimer; // Immediately stop pause timer if running. + delete g_PauseTimer; // Immediately stop pause timer if running. g_PauseType = Get5PauseType_None; g_PausingTeam = Get5Team_None; g_LatestPauseDuration = 0; diff --git a/scripting/get5/recording.sp b/scripting/get5/recording.sp index 5cc2d56ff..af007f454 100644 --- a/scripting/get5/recording.sp +++ b/scripting/get5/recording.sp @@ -8,14 +8,15 @@ bool StartRecording() { if (!IsTVEnabled()) { LogError("Demo recording will not work with \"tv_enable 0\". Set \"tv_enable 1\" and restart the map to fix this."); + g_DemoFilePath = ""; g_DemoFileName = ""; return false; } char demoName[PLATFORM_MAX_PATH + 1]; - if (!FormatCvarString(g_DemoNameFormatCvar, demoName, sizeof(demoName))) { - LogError("Failed to format demo filename. Please check your demo file format convar."); + LogError("Failed to format demo filename. Please check your demo file format ConVar."); + g_DemoFilePath = ""; g_DemoFileName = ""; return false; } @@ -24,20 +25,23 @@ bool StartRecording() { char variableSubstitutes[][] = {"{MATCHID}", "{DATE}"}; CheckAndCreateFolderPath(g_DemoPathCvar, variableSubstitutes, 2, demoFolder, sizeof(demoFolder)); + // If there is no path (folder empty string), this just becomes = demoName char demoPath[PLATFORM_MAX_PATH]; FormatEx(demoPath, sizeof(demoPath), "%s%s", demoFolder, demoName); - FormatEx(g_DemoFileName, sizeof(g_DemoFileName), "%s%s.dem", demoFolder, demoName); - LogMessage("Recording to %s", g_DemoFileName); - // Escape unsafe characters and start recording. .dem is appended to the filename automatically. ReplaceString(demoPath, sizeof(demoPath), "\"", "\\\""); ServerCommand("tv_record \"%s\"", demoPath); - Stats_SetDemoName(g_DemoFileName); + + // Global reference needs the .dem file extension for the uploader to be able to find the file. + FormatEx(g_DemoFileName, sizeof(g_DemoFileName), "%s.dem", demoName); + FormatEx(g_DemoFilePath, sizeof(g_DemoFilePath), "%s%s", demoFolder, g_DemoFileName); + LogMessage("Recording to %s", g_DemoFilePath); + Stats_SetDemoName(g_DemoFilePath); return true; } void StopRecording(float delay = 0.0) { - if (StrEqual("", g_DemoFileName)) { + if (StrEqual("", g_DemoFilePath)) { LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished() or stopping recording."); return; } @@ -47,8 +51,8 @@ void StopRecording(float delay = 0.0) { g_DemoUploadHeaderKeyCvar.GetString(uploadUrlHeaderKey, sizeof(uploadUrlHeaderKey)); char uploadUrlHeaderValue[1024]; g_DemoUploadHeaderValueCvar.GetString(uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue)); - DataPack pack = GetDemoInfoDataPack(g_MatchID, g_MapNumber, g_DemoFileName, uploadUrl, uploadUrlHeaderKey, - uploadUrlHeaderValue, g_DemoUploadDeleteAfterCvar.BoolValue); + DataPack pack = GetDemoInfoDataPack(g_MatchID, g_MapNumber, g_DemoFilePath, g_DemoFileName, uploadUrl, + uploadUrlHeaderKey, uploadUrlHeaderValue, g_DemoUploadDeleteAfterCvar.BoolValue); if (delay < 0.1) { LogDebug("Stopping GOTV recording immediately."); StopRecordingCallback(pack); @@ -56,6 +60,7 @@ void StopRecording(float delay = 0.0) { LogDebug("Starting timer that will end GOTV recording in %f seconds.", delay); CreateTimer(delay, Timer_StopGoTVRecording, pack); } + g_DemoFilePath = ""; g_DemoFileName = ""; } @@ -73,6 +78,7 @@ static void StopRecordingCallback(DataPack pack) { static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { char matchId[MATCH_ID_LENGTH]; + char demoFilePath[PLATFORM_MAX_PATH]; char demoFileName[PLATFORM_MAX_PATH]; int mapNumber; char uploadUrl[1024]; @@ -80,29 +86,30 @@ static Action Timer_FireStopRecordingEvent(Handle timer, DataPack pack) { char uploadUrlHeaderValue[1024]; bool deleteAfterUpload; ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, - sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, - sizeof(demoFileName), deleteAfterUpload); + sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFilePath, + sizeof(demoFilePath), demoFileName, sizeof(demoFileName), deleteAfterUpload); delete pack; - Get5DemoFinishedEvent event = new Get5DemoFinishedEvent(matchId, mapNumber, demoFileName); + Get5DemoFinishedEvent event = new Get5DemoFinishedEvent(matchId, mapNumber, demoFilePath); LogDebug("Calling Get5_OnDemoFinished()"); Call_StartForward(g_OnDemoFinished); Call_PushCell(event); Call_Finish(); EventLogger_LogAndDeleteEvent(event); - UploadDemoToServer(demoFileName, matchId, mapNumber, uploadUrl, uploadUrlHeaderKey, uploadUrlHeaderValue, - deleteAfterUpload); + UploadDemoToServer(demoFilePath, demoFileName, matchId, mapNumber, uploadUrl, uploadUrlHeaderKey, + uploadUrlHeaderValue, deleteAfterUpload); return Plugin_Handled; } -static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, const char[] demoFileName, - const char[] uploadUrl, const char[] uploadHeaderKey, +static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, const char[] demoFilePath, + const char[] demoFileName, const char[] uploadUrl, const char[] uploadHeaderKey, const char[] uploadHeaderValue, const bool deleteAfterUpload) { DataPack pack = CreateDataPack(); pack.WriteString(matchId); pack.WriteCell(mapNumber); - pack.WriteString(demoFileName); + pack.WriteString(demoFilePath); // Full path, including file name and extension + pack.WriteString(demoFileName); // File name and extension only pack.WriteString(uploadUrl); pack.WriteString(uploadHeaderKey); pack.WriteString(uploadHeaderValue); @@ -112,11 +119,13 @@ static DataPack GetDemoInfoDataPack(const char[] matchId, const int mapNumber, c static void ReadDemoDataPack(DataPack pack, char[] matchId, const int matchIdLength, int &mapNumber, char[] uploadUrl, const int uploadUrlLength, char[] uploadHeaderKey, const int uploadHeaderKeyLength, - char[] uploadeHeaderValue, const int uploadHeaderValueLength, char[] demoFileName, - const int demoFileNameLength, bool &deleteAfterUpload) { + char[] uploadeHeaderValue, const int uploadHeaderValueLength, char[] demoFilePath, + const int demoFilePathLength, char[] demoFileName, const int demoFileNameLength, + bool &deleteAfterUpload) { pack.Reset(); pack.ReadString(matchId, matchIdLength); mapNumber = pack.ReadCell(); + pack.ReadString(demoFilePath, demoFilePathLength); pack.ReadString(demoFileName, demoFileNameLength); pack.ReadString(uploadUrl, uploadUrlLength); pack.ReadString(uploadHeaderKey, uploadHeaderKeyLength); @@ -124,8 +133,9 @@ static void ReadDemoDataPack(DataPack pack, char[] matchId, const int matchIdLen deleteAfterUpload = pack.ReadCell(); } -static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, int mapNumber, const char[] demoUrl, - const char[] demoHeaderKey, const char[] demoHeaderValue, const bool deleteAfterUpload) { +static void UploadDemoToServer(const char[] demoFilePath, const char[] demoFileName, const char[] matchId, + int mapNumber, const char[] demoUrl, const char[] demoHeaderKey, + const char[] demoHeaderValue, const bool deleteAfterUpload) { if (StrEqual(demoUrl, "")) { LogDebug("Skipping demo upload as upload URL is not set."); @@ -138,69 +148,33 @@ static void UploadDemoToServer(const char[] demoFileName, const char[] matchId, return; } - Handle demoRequest = CreateGet5HTTPRequest(k_EHTTPMethodPOST, demoUrl); - if (demoRequest == INVALID_HANDLE) { - CallUploadEvent(matchId, mapNumber, demoFileName, false); + char error[PLATFORM_MAX_PATH]; + Handle demoRequest = CreateGet5HTTPRequest(k_EHTTPMethodPOST, demoUrl, error); + if (demoRequest == INVALID_HANDLE || !AddFileAsHttpBody(demoRequest, demoFilePath, error) || + !SetFileNameHeader(demoRequest, demoFileName, error) || !SetMatchIdHeader(demoRequest, matchId, error) || + !SetMapNumberHeader(demoRequest, mapNumber, error)) { + LogError(error); + delete demoRequest; + CallUploadEvent(matchId, mapNumber, demoFilePath, false); return; } // Set the auth keys only if they are defined. If not, we can still technically POST // to an end point that has no authentication. - if (!StrEqual(demoHeaderKey, "") && !StrEqual(demoHeaderValue, "")) { - if (!SteamWorks_SetHTTPRequestHeaderValue(demoRequest, demoHeaderKey, demoHeaderValue)) { - LogError("Failed to add custom header '%s' with value '%s' to demo upload request.", demoHeaderKey, - demoHeaderValue); - delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - } - - if (!SteamWorks_SetHTTPRequestHeaderValue(demoRequest, GET5_HEADER_DEMONAME, demoFileName)) { - LogError("Failed to add filename header with value '%s' to demo upload request.", demoFileName); - delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - - if (strlen(matchId) > 0) { - if (!SteamWorks_SetHTTPRequestHeaderValue(demoRequest, GET5_HEADER_MATCHID, matchId)) { - LogError("Failed to add match ID header with value '%s' to demo upload request.", matchId); - delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - } - - char strMapNumber[5]; - IntToString(mapNumber, strMapNumber, sizeof(strMapNumber)); - if (!SteamWorks_SetHTTPRequestHeaderValue(demoRequest, GET5_HEADER_MAPNUMBER, strMapNumber)) { - LogError("Failed to add map number header with value '%s' to demo upload request.", strMapNumber); - delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - - const timeout = 180; - if (!SteamWorks_SetHTTPRequestNetworkActivityTimeout(demoRequest, timeout)) { - LogError("Failed to change demo upload request timeout to %d seconds.", timeout); + if (strlen(demoHeaderKey) > 0 && strlen(demoHeaderValue) > 0 && + !SetHeaderKeyValuePair(demoRequest, demoHeaderKey, demoHeaderValue, error)) { + LogError(error); delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); + CallUploadEvent(matchId, mapNumber, demoFilePath, false); return; } - if (!FileExists(demoFileName) || - !SteamWorks_SetHTTPRequestRawPostBodyFromFile(demoRequest, "application/octet-stream", demoFileName)) { - LogError("Failed to add file '%s' as POST body for demo upload request.", demoFileName); - delete demoRequest; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } + DataPack pack = GetDemoInfoDataPack(matchId, mapNumber, demoFilePath, demoFileName, demoUrl, demoHeaderKey, + demoHeaderValue, deleteAfterUpload); - SteamWorks_SetHTTPRequestContextValue( - demoRequest, - GetDemoInfoDataPack(matchId, mapNumber, demoFileName, demoUrl, demoHeaderKey, demoHeaderValue, deleteAfterUpload)); - SteamWorks_SetHTTPCallbacks(demoRequest, DemoRequestCallback); + SteamWorks_SetHTTPRequestNetworkActivityTimeout(demoRequest, 180); + SteamWorks_SetHTTPRequestContextValue(demoRequest, pack); + SteamWorks_SetHTTPCallbacks(demoRequest, DemoRequest_Callback); SteamWorks_SendHTTPRequest(demoRequest); } @@ -254,9 +228,10 @@ void SetCurrentMatchRestartDelay(float delay) { } } -static void DemoRequestCallback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, - DataPack pack) { +static void DemoRequest_Callback(Handle request, bool failure, bool requestSuccessful, EHTTPStatusCode statusCode, + DataPack pack) { char matchId[MATCH_ID_LENGTH]; + char demoFilePath[PLATFORM_MAX_PATH]; char demoFileName[PLATFORM_MAX_PATH]; int mapNumber; char uploadUrl[1024]; @@ -264,45 +239,27 @@ static void DemoRequestCallback(Handle request, bool failure, bool requestSucces char uploadUrlHeaderValue[1024]; bool deleteAfterUpload; ReadDemoDataPack(pack, matchId, sizeof(matchId), mapNumber, uploadUrl, sizeof(uploadUrl), uploadUrlHeaderKey, - sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFileName, - sizeof(demoFileName), deleteAfterUpload); + sizeof(uploadUrlHeaderKey), uploadUrlHeaderValue, sizeof(uploadUrlHeaderValue), demoFilePath, + sizeof(demoFilePath), demoFileName, sizeof(demoFileName), deleteAfterUpload); delete pack; - + bool success = false; if (failure || !requestSuccessful) { - LogError("Failed to upload demo '%s' to '%s'.", demoFileName, uploadUrl); - delete request; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - - int status = view_as(statusCode); - if (status >= 300 || status < 200) { - LogError("Demo request failed with HTTP status code: %d.", statusCode); - int responseSize; - SteamWorks_GetHTTPResponseBodySize(request, responseSize); - char[] response = new char[responseSize]; - if (SteamWorks_GetHTTPResponseBodyData(request, response, responseSize)) { - LogError("Response body: %s", response); - } else { - LogError("Failed to read response body."); - } - delete request; - CallUploadEvent(matchId, mapNumber, demoFileName, false); - return; - } - - LogDebug("Demo request succeeded. HTTP status code: %d.", statusCode); - if (deleteAfterUpload) { - LogDebug( - "get5_demo_delete_after_upload set to true when demo request started; deleting the file from the game server."); - if (FileExists(demoFileName)) { - if (!DeleteFile(demoFileName)) { + LogError("Failed to upload demo '%s' to '%s'. Make sure your URL is enclosed in quotes.", demoFilePath, uploadUrl); + } else if (!CheckForSuccessfulResponse(request, statusCode)) { + LogError("Failed to upload demo '%s' to '%s'. HTTP status code: %d.", demoFilePath, uploadUrl, statusCode); + } else { + success = true; + LogDebug("Demo request succeeded. HTTP status code: %d.", statusCode); + if (deleteAfterUpload) { + LogDebug( + "get5_demo_delete_after_upload set to true when demo request started; deleting the file from the game server."); + if (FileExists(demoFileName) && !DeleteFile(demoFileName)) { LogError("Unable to delete demo file %s.", demoFileName); } } } + CallUploadEvent(matchId, mapNumber, demoFilePath, success); delete request; - CallUploadEvent(matchId, mapNumber, demoFileName, true); } static void CallUploadEvent(const char[] matchId, const int mapNumber, const char[] demoFileName, const bool success) { diff --git a/scripting/get5/tests.sp b/scripting/get5/tests.sp index 1192d7f76..d981dd8fb 100644 --- a/scripting/get5/tests.sp +++ b/scripting/get5/tests.sp @@ -54,13 +54,16 @@ static void MissingPropertiesTest() { SetTestContext("MissingPropertiesTest"); char error[PLATFORM_MAX_PATH]; - AssertFalse("Load missing team1 JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team1.json", error)); + AssertFalse("Load missing team1 JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team1.json", error)); AssertStrEq("Load missing team1 JSON error", error, "Missing \"team1\" section in match config JSON."); - AssertFalse("Load missing team2 JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team2.json", error)); + AssertFalse("Load missing team2 JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team2.json", error)); AssertStrEq("Load missing team2 JSON error", error, "Missing \"team2\" section in match config JSON."); - AssertFalse("Load missing maplist JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.json", error)); + AssertFalse("Load missing maplist JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.json", error)); AssertStrEq("Load missing maplist JSON error", error, "Missing \"maplist\" section in match config JSON."); AssertFalse("Load missing team1 KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team1.cfg", error)); @@ -69,14 +72,16 @@ static void MissingPropertiesTest() { AssertFalse("Load missing team2 KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_team2.cfg", error)); AssertStrEq("Load missing team2 KV error", error, "Missing \"team2\" section in match config KeyValues."); - AssertFalse("Load missing maplist KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.cfg", error)); + AssertFalse("Load missing maplist KV", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/missing_maplist.cfg", error)); AssertStrEq("Load missing maplist KV error", error, "Missing \"maplist\" section in match config KeyValues."); } static void MatchConfigNotFoundTest() { SetTestContext("MatchConfigNotFoundTest"); char error[PLATFORM_MAX_PATH]; - AssertFalse("Load match config does not exist", LoadMatchConfig("addons/sourcemod/configs/get5/tests/file_not_found.cfg", error)); + AssertFalse("Load match config does not exist", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/file_not_found.cfg", error)); AssertTrue("Match config does not exist error", StrContains(error, "Match config file doesn't exist") != -1); } @@ -87,24 +92,34 @@ static void MapListFromFileTest() { // JSON MapListValid("addons/sourcemod/configs/get5/tests/fromfile_maplist_valid.json"); - AssertFalse("Load empty maplist config JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty.json", error)); + AssertFalse("Load empty maplist config JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty.json", error)); AssertStrEq("Load empty maplist config JSON", error, "\"maplist\" is empty array."); - AssertFalse("Load maplist fromfile file not found config", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_found.json", error)); - AssertEq("Load maplist fromfile file not found config", StrContains(error, "Maplist fromfile file does not exist"), 0); + AssertFalse("Load maplist fromfile file not found config", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_found.json", error)); + AssertEq("Load maplist fromfile file not found config", StrContains(error, "Maplist fromfile file does not exist"), + 0); - AssertFalse("Load maplist fromfile config not array JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_array.json", error)); - AssertStrEq("Load maplist fromfile config not array JSON", error, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + AssertFalse("Load maplist fromfile config not array JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_not_array.json", error)); + AssertStrEq( + "Load maplist fromfile config not array JSON", error, + "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); - AssertFalse("Load maplist fromfile config empty string JSON", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty_string.json", error)); - AssertStrEq("Load maplist fromfile config empty string JSON", error, "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); + AssertFalse("Load maplist fromfile config empty string JSON", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_empty_string.json", error)); + AssertStrEq( + "Load maplist fromfile config empty string JSON", error, + "\"maplist\" object in match configuration file must have a non-empty \"fromfile\" property or be an array."); // KeyValues MapListValid("addons/sourcemod/configs/get5/tests/fromfile_maplist_valid.cfg"); - AssertFalse("Load maplist fromfile config invalid KV", LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_invalid.cfg", error)); - AssertStrEq("Load maplist fromfile config invalid KV", error, "\"maplist\" has no valid subkeys in match config KV file."); - + AssertFalse("Load maplist fromfile config invalid KV", + LoadMatchConfig("addons/sourcemod/configs/get5/tests/fromfile_maplist_invalid.cfg", error)); + AssertStrEq("Load maplist fromfile config invalid KV", error, + "\"maplist\" has no valid subkeys in match config KV file."); } static void InvalidMatchConfigFile(const char[] matchConfig) { @@ -148,7 +163,8 @@ static void LoadTeamFromFileTest() { SetTestContext("LoadTeamFromFileTest"); char err[255]; AssertTrue("load config", LoadMatchConfig("addons/sourcemod/configs/get5/tests/default_valid.json", err)); - AssertTrue("load team", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/team2_array.json", Get5Team_2, err)); + AssertTrue("load team", + LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/team2_array.json", Get5Team_2, err)); char playerId[32]; char playerName[32]; @@ -176,13 +192,16 @@ static void LoadTeamFromFileTest() { AssertStrEq("Team B Tag", g_TeamTags[Get5Team_2], "TAG-FA"); AssertStrEq("Team B MatchText", g_TeamMatchTexts[Get5Team_2], ""); - AssertFalse("load team file not found", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/file_not_found.json", Get5Team_2, err)); + AssertFalse("load team file not found", + LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/file_not_found.json", Get5Team_2, err)); AssertEq("load team file not found", StrContains(err, "Team fromfile file does not exist"), 0); - AssertFalse("JSON load team file invalid", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.json", Get5Team_2, err)); + AssertFalse("JSON load team file invalid", + LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.json", Get5Team_2, err)); AssertEq("JSON load team file invalid", StrContains(err, "Cannot read team config from JSON file"), 0); - AssertFalse("KV load team file invalid", LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.cfg", Get5Team_2, err)); + AssertFalse("KV load team file invalid", + LoadTeamDataFromFile("addons/sourcemod/configs/get5/tests/invalid_config.cfg", Get5Team_2, err)); AssertEq("KV load team file invalid", StrContains(err, "Cannot read team config from KV file"), 0); EndSeries(Get5Team_None, false, 0.0); @@ -284,7 +303,9 @@ static void ValidMatchConfigTest(const char[] matchConfig) { AssertEq("Map sides length", g_MapSides.Length, 3); AssertEq("Sides 0", view_as(g_MapSides.Get(0)), view_as(SideChoice_KnifeRound)); AssertEq("Sides 1", view_as(g_MapSides.Get(1)), view_as(SideChoice_Team1T)); - AssertEq("Sides 2", view_as(g_MapSides.Get(2)), view_as(SideChoice_Team1CT)); // only 2 sides present in the file, and side_type: never_knife = team 1 ct + AssertEq( + "Sides 2", view_as(g_MapSides.Get(2)), + view_as(SideChoice_Team1CT)); // only 2 sides present in the file, and side_type: never_knife = team 1 ct AssertStrEq("Match ID", g_MatchID, "test_match_valid"); AssertStrEq("Match Title", g_MatchTitle, "Test {MAPNUMBER} of {MAXMAPS}"); @@ -311,7 +332,7 @@ static void ValidMatchConfigTest(const char[] matchConfig) { AssertConVarEquals("mp_teamname_2", "Team B Default [NOT READY]"); AssertConVarEquals("mp_teamflag_2", "DE"); AssertConVarEquals("mp_teamlogo_2", "fromfile_team"); - AssertConVarEquals("mp_teammatchstat_2", "0"); // blank match text = use map series score + AssertConVarEquals("mp_teammatchstat_2", "0"); // blank match text = use map series score AssertConVarEquals("mp_teamscore_2", ""); AssertConVarEquals("mp_teamprediction_txt", "team percentage text"); @@ -324,7 +345,8 @@ static void ValidMatchConfigTest(const char[] matchConfig) { WriteBackup(); char backupFilePath[PLATFORM_MAX_PATH]; - FormatEx(backupFilePath, sizeof(backupFilePath), "addons/sourcemod/configs/get5/tests/backups/%s/%s", g_MatchID, "get5_backup1234_matchtest_match_valid_map0_prelive.cfg"); + FormatEx(backupFilePath, sizeof(backupFilePath), "addons/sourcemod/configs/get5/tests/backups/%s/%s", g_MatchID, + "get5_backup1234_matchtest_match_valid_map0_prelive.cfg"); AssertTrue("Check backup file exists", FileExists(backupFilePath)); KeyValues backup = new KeyValues("Backup"); @@ -356,7 +378,7 @@ static void ValidMatchConfigTest(const char[] matchConfig) { AssertStrEq("Check map name 1 in backup", mapName, "de_dust2"); AssertEq("Check map side 1 in backup", backup.GetNum(NULL_STRING), view_as(SideChoice_KnifeRound)); } else if (index == 1) { - AssertStrEq("Check map name 2 in backup",mapName, "de_mirage"); + AssertStrEq("Check map name 2 in backup", mapName, "de_mirage"); AssertEq("Check map side 2 in backup", backup.GetNum(NULL_STRING), view_as(SideChoice_Team1T)); } else if (index == 2) { AssertStrEq("Check map name 3 in backup", mapName, "de_inferno"); From 032dcb59a7752883121fa9eaad81db6f0ef66d9e Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 14 Dec 2022 04:11:58 +0100 Subject: [PATCH 19/27] Add mp_endwarmup_player_count to prohibited commands --- documentation/docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md index 535ffef91..1b4d838b9 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -52,6 +52,7 @@ cfg/get5/live.cfg # (3) mp_warmup_start mp_warmuptime mp_warmuptime_all_players_connected + mp_endwarmup_player_count tv_delay tv_delay1 tv_delaymapchange From 2c9a4f305366a01929d9460ce6711cc471b8e9fe Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 16 Dec 2022 20:12:35 +0100 Subject: [PATCH 20/27] Update issue template to use forms --- .github/ISSUE_TEMPLATE | 18 --------- .github/ISSUE_TEMPLATE/bug-report.yml | 44 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature-request.yml | 31 +++++++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml diff --git a/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE deleted file mode 100644 index 08fe01de6..000000000 --- a/.github/ISSUE_TEMPLATE +++ /dev/null @@ -1,18 +0,0 @@ -If making a feature request, you should delete all the pre-filled text here. -If reporting a bug, fill in the following sections: - -### Expected behavior - - - -### Actual behavior - - - -### Steps to reproduce - -Please note that "latest" is **NOT** a version! In order to get the version, please use `get5_version` in the server console, or `get5_debuginfo` and attach the file located at `addons/sourcemod/logs/get5_debuginfo.txt`. - -- Plugin version: -- Sourcemod version: -- Steps to reproduce (please be specific): diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..be0221313 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,44 @@ +name: Bug Report +description: If you've found a bug or have a problem that may be a bug. +title: "[Bug]: " +labels: ["unverified bug"] +body: + - type: checkboxes + id: terms + attributes: + label: Documentation + description: | + Before you submit an issue, make sure you've read [the documentation](https://splewis.github.io/get5). You should also check if an issue already exists. + options: + - label: I have looked in [the documentation](https://splewis.github.io/get5) and cannot find a solution to my problem. + required: true + - label: I have searched [existing issues](https://github.com/splewis/get5/issues) and this bug has not been addressed. + required: true + - type: input + id: version + attributes: + label: Get5 Version + description: | + What version of Get5 are you using? Use [`get5_status`](https://splewis.github.io/get5/latest/commands/#get5_status) in your server console to print it. Note that we only provide support for official builds, so if you compiled Get5 yourself, please run an official build and verify that you still have the problem. You may also download a nightly/prerelease build and verify that you still have the bug then. You can download the latest versions [here](https://github.com/splewis/get5/releases). + validations: + required: true + - type: textarea + id: issue + attributes: + label: The Issue + description: Explain the problem. Please be detailed. + validations: + required: true + - type: textarea + id: match-config + attributes: + label: Match Configuration + description: Please paste the [match configuration](https://splewis.github.io/get5/latest/match_schema/) you loaded, if any. + render: log + - type: textarea + id: logs + attributes: + label: Debug Info + description: Please copy and paste the output from [`get5_debuginfo`](https://splewis.github.io/get5/latest/commands/#get5_debuginfo). The file is located at `addons/sourcemod/logs/get5_debuginfo.txt` by default. + render: log + diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..0adc524c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: If you want a new feature added to Get5. +title: "[Feature Request]: " +labels: ["feature request"] +body: + - type: checkboxes + id: terms + attributes: + label: Prereleases & Documentation + description: | + Before you submit a feature request, make sure you've read [the documentation](https://splewis.github.io/get5) and that the feature you request has not already been added in a prerelease. + options: + - label: I have looked in [the documentation](https://splewis.github.io/get5) and I don't see the feature anywhere. + required: true + - label: I have looked for [pre-releases](https://github.com/splewis/get5/releases) and the feature has not been added. + required: true + - type: input + id: version + attributes: + label: Get5 Version + description: | + What version of Get5 are you using? Use `get5_status` in your server console to print it. You can download the latest versions [here](https://github.com/splewis/get5/releases). + validations: + required: true + - type: textarea + id: issue + attributes: + label: The Feature + description: Explain the feature you want - and why. Remember that a feature should be widely applicable and make sense for other users. + validations: + required: true From a58d0ea3e45f8a7af6fdbf396403ba9c46b9083c Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 16 Dec 2022 20:33:51 +0100 Subject: [PATCH 21/27] Adjust issue template --- .github/ISSUE_TEMPLATE/bug-report.yml | 6 ++++-- .github/ISSUE_TEMPLATE/feature-request.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index be0221313..ffd0e18ea 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: Bug Report -description: If you've found a bug or have a problem that may be a bug. +description: If you've found a bug or have a problem that you think may be a bug. title: "[Bug]: " labels: ["unverified bug"] body: @@ -20,6 +20,8 @@ body: label: Get5 Version description: | What version of Get5 are you using? Use [`get5_status`](https://splewis.github.io/get5/latest/commands/#get5_status) in your server console to print it. Note that we only provide support for official builds, so if you compiled Get5 yourself, please run an official build and verify that you still have the problem. You may also download a nightly/prerelease build and verify that you still have the bug then. You can download the latest versions [here](https://github.com/splewis/get5/releases). + + Please note that "latest" is **not** a version. validations: required: true - type: textarea @@ -39,6 +41,6 @@ body: id: logs attributes: label: Debug Info - description: Please copy and paste the output from [`get5_debuginfo`](https://splewis.github.io/get5/latest/commands/#get5_debuginfo). The file is located at `addons/sourcemod/logs/get5_debuginfo.txt` by default. + description: Please copy and paste the output from [`get5_debuginfo`](https://splewis.github.io/get5/latest/commands/#get5_debuginfo). The file is located at `addons/sourcemod/logs/get5_debuginfo.txt` by default. You should preferably run this command as you encounter the issue. render: log diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 0adc524c7..c03e7dd3f 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,5 +1,5 @@ name: Feature Request -description: If you want a new feature added to Get5. +description: If you want to request a new feature or a change to an existing feature. title: "[Feature Request]: " labels: ["feature request"] body: @@ -19,7 +19,9 @@ body: attributes: label: Get5 Version description: | - What version of Get5 are you using? Use `get5_status` in your server console to print it. You can download the latest versions [here](https://github.com/splewis/get5/releases). + What version of Get5 are you using? Use [`get5_status`](https://splewis.github.io/get5/latest/commands/#get5_status) in your server console to print it. You can download the latest versions [here](https://github.com/splewis/get5/releases). + + Please note that "latest" is **not** a version. validations: required: true - type: textarea From 3a582f5628da183aecb2a6c8bcef0fa7dc046e78 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Sat, 17 Dec 2022 12:59:41 +0100 Subject: [PATCH 22/27] Disable blank issues --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..1a074e736 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discord Community Support + url: https://discord.gg/zmqEa4keCk + about: For support that does not fit the GitHub issue format, please use Discord. From 3d0b84b0454fcff1e09a3dbb6018a7a00a874624 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 21 Dec 2022 01:50:33 +0100 Subject: [PATCH 23/27] Add system for custom chat aliases (#883) --- configs/get5/commands.cfg | 6 ++ documentation/docs/commands.md | 34 +++++++++ scripting/get5.sp | 55 +++++++------- scripting/get5/chatcommands.sp | 133 +++++++++++++++++++++++++++++++++ scripting/get5/kniferounds.sp | 4 +- scripting/get5/mapveto.sp | 2 +- scripting/get5/pausing.sp | 4 +- scripting/get5/readysystem.sp | 4 +- scripting/get5/surrender.sp | 7 +- scripting/get5/util.sp | 107 ++++++++++++++++++++++++++ scripting/include/get5.inc | 19 +++++ 11 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 configs/get5/commands.cfg diff --git a/configs/get5/commands.cfg b/configs/get5/commands.cfg new file mode 100644 index 000000000..ad9bb0914 --- /dev/null +++ b/configs/get5/commands.cfg @@ -0,0 +1,6 @@ +"Commands" +{ + // See the documentation for a list of all the available commands you can map. + // The syntax is, as a key-value: + // "customalias" "command" +} diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index 2314879e9..8655cd479 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -88,6 +88,40 @@ server, this stops that timer. menu buttons for starting a scrim, force-starting, force-ending, adding a ringer, and loading the most recent backup file. +## Customizing Chat Commands {: #custom-chat-commands } + +Get5 allows you to customize the chat commands used by players. By default, all of the above commands can be used, +but you can define your own set of commands by adding aliases to the file at +`addons/sourcemod/configs/get5/commands.cfg`. This file is empty by default. When you add a new alias for a command, +that alias will be the one Get5 uses when it references the command in chat. + +If you provide an invalid command (on the *right-hand side* in the config file), an error will be thrown. Avoid mapping +already used commands to other functionality, as it will likely be confusing to players. You may add multiple aliases +for a single command, but note that the **last** alias to be assigned to the command will be the one Get5 uses in chat. + +The chat alias file is only loaded once per plugin boot. If you want to reload it, you must reload Get5. + +!!! note "Valid Chat Commands" + + The follwing strings are valid commands, and are all explained in the list of commands above: + + [`ready`](#ready), [`unready`](#unready), [`forceready`](#forceready), [`tech`](#tech), [`pause`](#pause), + [`unpause`](#unpause), [`coach`](#coach), [`stay`](#stay), [`swap`](#swap), [`t`](#stay), [`ct`](#stay), + [`stop`](#stop), [`surrender`](#surrender), [`ffw`](#ffw), [`cancelffw`](#cancelffw) + +!!! example "Example: `addons/sourcemod/configs/get5/commands.cfg`" + + This maps the French word *abandon* to the surrender command. Get5 will also print `!abandon` when it references the + surrender command in chat messages. The original commands ([`!surrender`](#surrender) and [`!gg`](#surrender)) will + still work. + + ``` + "Commands" + { + "abandon" "surrender" + } + ``` + ## Server/Admin Commands Please note that these are meant to be used by *admins* in console. The definition is: diff --git a/scripting/get5.sp b/scripting/get5.sp index c2a790902..1befbbe25 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -153,6 +153,7 @@ ArrayList g_MapPoolList; ArrayList g_TeamPlayers[MATCHTEAM_COUNT]; ArrayList g_TeamCoaches[MATCHTEAM_COUNT]; StringMap g_PlayerNames; +StringMap g_ChatCommands; char g_TeamNames[MATCHTEAM_COUNT][MAX_CVAR_LENGTH]; char g_TeamTags[MATCHTEAM_COUNT][MAX_CVAR_LENGTH]; char g_FormattedTeamNames[MATCHTEAM_COUNT][MAX_CVAR_LENGTH]; @@ -483,26 +484,30 @@ public void OnPluginStart() { /** Client commands **/ g_ChatAliases = new ArrayList(ByteCountToCells(ALIAS_LENGTH)); g_ChatAliasesCommands = new ArrayList(ByteCountToCells(COMMAND_LENGTH)); - AddAliasedCommand("r", Command_Ready, "Marks the client as ready"); - AddAliasedCommand("ready", Command_Ready, "Marks the client as ready"); - AddAliasedCommand("unready", Command_NotReady, "Marks the client as not ready"); - AddAliasedCommand("notready", Command_NotReady, "Marks the client as not ready"); - AddAliasedCommand("forceready", Command_ForceReadyClient, "Force marks clients team as ready"); - AddAliasedCommand("tech", Command_TechPause, "Calls for a tech pause"); - AddAliasedCommand("pause", Command_Pause, "Calls for a tactical pause"); - AddAliasedCommand("tac", Command_Pause, "Alias of pause"); - AddAliasedCommand("unpause", Command_Unpause, "Unpauses the game"); - AddAliasedCommand("coach", Command_SmCoach, "Marks a client as a coach for their team"); - AddAliasedCommand("stay", Command_Stay, "Elects to stay on the current team after winning a knife round"); - AddAliasedCommand("swap", Command_Swap, "Elects to swap the current teams after winning a knife round"); - AddAliasedCommand("switch", Command_Swap, "Elects to swap the current teams after winning a knife round"); - AddAliasedCommand("t", Command_T, "Elects to start on T side after winning a knife round"); - AddAliasedCommand("ct", Command_Ct, "Elects to start on CT side after winning a knife round"); - AddAliasedCommand("stop", Command_Stop, "Elects to stop the game to reload a backup file"); - AddAliasedCommand("surrender", Command_Surrender, "Starts a vote for surrendering for your team."); - AddAliasedCommand("gg", Command_Surrender, "Alias for surrender."); - AddAliasedCommand("ffw", Command_FFW, "Starts a countdown to win if a full team disconnects from the server."); - AddAliasedCommand("cancelffw", Command_CancelFFW, "Cancels a request to win by forfeit initiated with !ffw."); + g_ChatCommands = new StringMap(); + + // Default chat mappings. + MapChatCommand(Get5ChatCommand_Ready, "r"); + MapChatCommand(Get5ChatCommand_Ready, "ready"); + MapChatCommand(Get5ChatCommand_Unready, "notready"); + MapChatCommand(Get5ChatCommand_Unready, "unready"); + MapChatCommand(Get5ChatCommand_ForceReady, "forceready"); + MapChatCommand(Get5ChatCommand_Pause, "tac"); + MapChatCommand(Get5ChatCommand_Pause, "pause"); + MapChatCommand(Get5ChatCommand_Unpause, "unpause"); + MapChatCommand(Get5ChatCommand_Coach, "coach"); + MapChatCommand(Get5ChatCommand_Stay, "stay"); + MapChatCommand(Get5ChatCommand_Swap, "switch"); + MapChatCommand(Get5ChatCommand_Swap, "swap"); + MapChatCommand(Get5ChatCommand_T, "t"); + MapChatCommand(Get5ChatCommand_CT, "ct"); + MapChatCommand(Get5ChatCommand_Stop, "stop"); + MapChatCommand(Get5ChatCommand_Surrender, "gg"); + MapChatCommand(Get5ChatCommand_Surrender, "surrender"); + MapChatCommand(Get5ChatCommand_FFW, "ffw"); + MapChatCommand(Get5ChatCommand_CancelFFW, "cancelffw"); + + LoadCustomChatAliases("addons/sourcemod/configs/get5/commands.cfg"); /** Admin/server commands **/ RegAdminCmd("get5_loadmatch", Command_LoadMatch, ADMFLAG_CHANGEMAP, @@ -640,11 +645,11 @@ static Action Timer_InfoMessages(Handle timer) { } char readyCommandFormatted[64]; - FormatChatCommand(readyCommandFormatted, sizeof(readyCommandFormatted), "!ready"); + GetChatAliasForCommand(Get5ChatCommand_Ready, readyCommandFormatted, sizeof(readyCommandFormatted), true); char unreadyCommandFormatted[64]; - FormatChatCommand(unreadyCommandFormatted, sizeof(unreadyCommandFormatted), "!unready"); + GetChatAliasForCommand(Get5ChatCommand_Unready, unreadyCommandFormatted, sizeof(unreadyCommandFormatted), true); char coachCommandFormatted[64]; - FormatChatCommand(coachCommandFormatted, sizeof(coachCommandFormatted), "!coach"); + GetChatAliasForCommand(Get5ChatCommand_Coach, coachCommandFormatted, sizeof(coachCommandFormatted), true); if (g_GameState == Get5State_PendingRestore) { if (!IsTeamsReady() && !IsDoingRestoreOrMapChange()) { @@ -1129,7 +1134,7 @@ static Action Command_DumpStats(int client, int args) { } } -static Action Command_Stop(int client, int args) { +Action Command_Stop(int client, int args) { if (!g_StopCommandEnabledCvar.BoolValue) { Get5_MessageToAll("%t", "StopCommandNotEnabled"); return Plugin_Handled; @@ -1175,7 +1180,7 @@ static Action Command_Stop(int client, int args) { g_TeamGivenStopCommand[team] = true; char stopCommandFormatted[64]; - FormatChatCommand(stopCommandFormatted, sizeof(stopCommandFormatted), "!stop"); + GetChatAliasForCommand(Get5ChatCommand_Stop, stopCommandFormatted, sizeof(stopCommandFormatted), true); if (g_TeamGivenStopCommand[Get5Team_1] && !g_TeamGivenStopCommand[Get5Team_2]) { Get5_MessageToAll("%t", "TeamWantsToReloadCurrentRound", g_FormattedTeamNames[Get5Team_1], g_FormattedTeamNames[Get5Team_2], stopCommandFormatted); diff --git a/scripting/get5/chatcommands.sp b/scripting/get5/chatcommands.sp index edd682364..088f7834f 100644 --- a/scripting/get5/chatcommands.sp +++ b/scripting/get5/chatcommands.sp @@ -16,6 +16,139 @@ static void AddChatAlias(const char[] alias, const char[] command) { } } +void MapChatCommand(const Get5ChatCommand command, const char[] alias) { + switch (command) + { + case Get5ChatCommand_Ready: + { + AddAliasedCommand(alias, Command_Ready, "Marks the client as ready."); + } + case Get5ChatCommand_Unready: + { + AddAliasedCommand(alias, Command_NotReady, "Marks the client as not ready."); + } + case Get5ChatCommand_ForceReady: + { + AddAliasedCommand(alias, Command_ForceReadyClient, "Marks the client's entire team as ready."); + } + case Get5ChatCommand_Tech: + { + AddAliasedCommand(alias, Command_TechPause, "Calls for a technical pause."); + } + case Get5ChatCommand_Pause: + { + AddAliasedCommand(alias, Command_Pause, "Calls for a tactical pause."); + } + case Get5ChatCommand_Unpause: + { + AddAliasedCommand(alias, Command_Unpause, "Unpauses the game."); + } + case Get5ChatCommand_Coach: + { + AddAliasedCommand(alias, Command_SmCoach, "Requests to become a coach."); + } + case Get5ChatCommand_Stay: + { + AddAliasedCommand(alias, Command_Stay, "Elects to stay on the current side after winning a knife round."); + } + case Get5ChatCommand_Swap: + { + AddAliasedCommand(alias, Command_Swap, "Elects to swap to the other side after winning a knife round."); + } + case Get5ChatCommand_T: + { + AddAliasedCommand(alias, Command_T, "Elects to start on T side after winning a knife round."); + } + case Get5ChatCommand_CT: + { + AddAliasedCommand(alias, Command_Ct, "Elects to start on CT side after winning a knife round."); + } + case Get5ChatCommand_Stop: + { + AddAliasedCommand(alias, Command_Stop, "Elects to stop the game to reload a backup file for the current round."); + } + case Get5ChatCommand_Surrender: + { + AddAliasedCommand(alias, Command_Surrender, "Starts a vote for surrendering for your team."); + } + case Get5ChatCommand_FFW: + { + AddAliasedCommand(alias, Command_FFW, "Starts a countdown to win if a full team disconnects from the server."); + } + case Get5ChatCommand_CancelFFW: + { + AddAliasedCommand(alias, Command_CancelFFW, "Cancels a pending request to win by forfeit."); + } + default: + { + LogError("Failed to map Get5ChatCommand with value %d to a command. It is missing from MapChatCommand.", command); + return; + } + } + + char commandAsString[64]; // "ready"; base command + char commandAliasFormatted[64]; // "!readyalias"; the alias to use, with ! in front + ChatCommandToString(command, commandAsString, sizeof(commandAsString)); + FormatEx(commandAliasFormatted, sizeof(commandAliasFormatted), "!%s", alias); + g_ChatCommands.SetString(commandAsString, commandAliasFormatted); // maps ready -> !readyalias +} + +void GetChatAliasForCommand(const Get5ChatCommand command, char[] buffer, int bufferSize, bool format) { + char commandAsString[64]; + ChatCommandToString(command, commandAsString, sizeof(commandAsString)); + g_ChatCommands.GetString(commandAsString, buffer, bufferSize); + if (format) { + FormatChatCommand(buffer, bufferSize, buffer); + } +} + +int LoadCustomChatAliases(const char[] file) { + int loadedAliases = 0; + if (!FileExists(file)) { + LogDebug("Custom chat commands file not found at '%s'. Skipping.", file); + return loadedAliases; + } + char error[PLATFORM_MAX_PATH]; + if (!CheckKeyValuesFile(file, error, sizeof(error))) { + LogError("Failed to parse custom chat command file. Error: %s", error); + return loadedAliases; + } + + KeyValues chatAliases = new KeyValues("Commands"); + if (!chatAliases.ImportFromFile(file)) { + LogError("Failed to read chat command aliases file at '%s'.", file); + delete chatAliases; + return loadedAliases; + } + + if (chatAliases.GotoFirstSubKey(false)) + { + char alias[255]; + char command[255]; + do + { + chatAliases.GetSectionName(alias, sizeof(alias)); + chatAliases.GetString(NULL_STRING, command, sizeof(command)); + + Get5ChatCommand chatCommand = StringToChatCommand(command); + if (chatCommand == Get5ChatCommand_Unknown) { + LogError("Failed to alias unknown chat command '%s' to '%s'.", command, alias); + continue; + } + MapChatCommand(chatCommand, alias); + loadedAliases++; + } while (chatAliases.GotoNextKey(false)); + if (loadedAliases > 0) { + LogMessage("Loaded %d custom chat alias(es).", loadedAliases); + } + } else { + // file is empty. + LogDebug("Custom alias file was empty."); + } + delete chatAliases; + return loadedAliases; +} + void CheckForChatAlias(int client, const char[] sArgs) { // No chat aliases are needed if the game isn't setup at all. if (g_GameState == Get5State_None) { diff --git a/scripting/get5/kniferounds.sp b/scripting/get5/kniferounds.sp index 94782b5b6..98025d311 100644 --- a/scripting/get5/kniferounds.sp +++ b/scripting/get5/kniferounds.sp @@ -50,9 +50,9 @@ void PromptForKnifeDecision() { return; } char formattedStayCommand[64]; - FormatChatCommand(formattedStayCommand, sizeof(formattedStayCommand), "!stay"); + GetChatAliasForCommand(Get5ChatCommand_Stay, formattedStayCommand, sizeof(formattedStayCommand), true); char formattedSwapCommand[64]; - FormatChatCommand(formattedSwapCommand, sizeof(formattedSwapCommand), "!swap"); + GetChatAliasForCommand(Get5ChatCommand_Swap, formattedSwapCommand, sizeof(formattedSwapCommand), true); Get5_MessageToAll("%t", "WaitingForEnemySwapInfoMessage", g_FormattedTeamNames[g_KnifeWinnerTeam], formattedStayCommand, formattedSwapCommand); } diff --git a/scripting/get5/mapveto.sp b/scripting/get5/mapveto.sp index 9ebbef592..9535c3e86 100644 --- a/scripting/get5/mapveto.sp +++ b/scripting/get5/mapveto.sp @@ -43,7 +43,7 @@ static Action Timer_VetoCountdown(Handle timer) { static void AbortVeto() { Get5_MessageToAll("%t", "CaptainLeftOnVetoInfoMessage"); char readyCommandFormatted[64]; - FormatChatCommand(readyCommandFormatted, sizeof(readyCommandFormatted), "!ready"); + GetChatAliasForCommand(Get5ChatCommand_Ready, readyCommandFormatted, sizeof(readyCommandFormatted), true); Get5_MessageToAll("%t", "ReadyToResumeVetoInfoMessage", readyCommandFormatted); ChangeState(Get5State_PreVeto); if (g_ActiveVetoMenu != null) { diff --git a/scripting/get5/pausing.sp b/scripting/get5/pausing.sp index 17947ebcf..0539ef682 100644 --- a/scripting/get5/pausing.sp +++ b/scripting/get5/pausing.sp @@ -247,7 +247,7 @@ Action Command_Unpause(int client, int args) { } char formattedUnpauseCommand[64]; - FormatChatCommand(formattedUnpauseCommand, sizeof(formattedUnpauseCommand), "!unpause"); + GetChatAliasForCommand(Get5ChatCommand_Unpause, formattedUnpauseCommand, sizeof(formattedUnpauseCommand), true); if (g_TeamReadyForUnpause[Get5Team_1] && g_TeamReadyForUnpause[Get5Team_2]) { UnpauseGame(team); if (IsPlayer(client)) { @@ -410,7 +410,7 @@ static Action Timer_PauseTimeCheck(Handle timer) { // unpause on their own. The PrintHintText below will inform users that they can now // unpause. char formattedUnpauseCommand[64]; - FormatChatCommand(formattedUnpauseCommand, sizeof(formattedUnpauseCommand), "!unpause"); + GetChatAliasForCommand(Get5ChatCommand_Unpause, formattedUnpauseCommand, sizeof(formattedUnpauseCommand), true); Get5_MessageToAll("%t", "TechPauseRunoutInfoMessage", formattedUnpauseCommand); } } diff --git a/scripting/get5/readysystem.sp b/scripting/get5/readysystem.sp index 29f5b53c1..f9b89cd7f 100644 --- a/scripting/get5/readysystem.sp +++ b/scripting/get5/readysystem.sp @@ -227,7 +227,7 @@ Action Command_ForceReadyClient(int client, int args) { g_AllowForceReadyCvar.GetName(cVarName, sizeof(cVarName)); FormatCvarName(cVarName, sizeof(cVarName), cVarName); char forceReadyCommand[64]; - FormatChatCommand(forceReadyCommand, sizeof(forceReadyCommand), "!forceready"); + GetChatAliasForCommand(Get5ChatCommand_ForceReady, forceReadyCommand, sizeof(forceReadyCommand), true); Get5_Message(client, "%t", "ForceReadyDisabled", forceReadyCommand, cVarName); return; } @@ -300,7 +300,7 @@ static void MissingPlayerInfoMessageTeam(Get5Team team) { if (playerCount == readyCount && playerCount < playersPerTeam && readyCount >= minimumPlayersForForceReady) { char forceReadyFormatted[64]; - FormatChatCommand(forceReadyFormatted, sizeof(forceReadyFormatted), "!forceready"); + GetChatAliasForCommand(Get5ChatCommand_ForceReady, forceReadyFormatted, sizeof(forceReadyFormatted), true); Get5_MessageToTeam(team, "%t", "ForceReadyInfoMessage", forceReadyFormatted); } } diff --git a/scripting/get5/surrender.sp b/scripting/get5/surrender.sp index 345ad623a..7621a2727 100644 --- a/scripting/get5/surrender.sp +++ b/scripting/get5/surrender.sp @@ -176,8 +176,8 @@ Action Timer_DisconnectCheck(Handle timer) { } // One team is full, the other team left; announce that they can request to !ffw - char winCommandFormatted[32]; - FormatChatCommand(winCommandFormatted, sizeof(winCommandFormatted), "!ffw"); + char winCommandFormatted[64]; + GetChatAliasForCommand(Get5ChatCommand_FFW, winCommandFormatted, sizeof(winCommandFormatted), true); Get5_MessageToAll("%t", "WinByForfeitAvailable", g_FormattedTeamNames[forfeitingTeam], g_FormattedTeamNames[OtherMatchTeam(forfeitingTeam)], winCommandFormatted); return Plugin_Handled; @@ -190,8 +190,7 @@ static void AnnounceRemainingForfeitTime(const int remainingSeconds, const Get5T if (forfeitingTeam != Get5Team_None) { char formattedCancelFFWCommand[64]; - FormatChatCommand(formattedCancelFFWCommand, sizeof(formattedCancelFFWCommand), "!cancelffw"); - + GetChatAliasForCommand(Get5ChatCommand_CancelFFW, formattedCancelFFWCommand, sizeof(formattedCancelFFWCommand), true); Get5_MessageToAll("%t", "WinByForfeitCountdownStarted", g_FormattedTeamNames[OtherMatchTeam(forfeitingTeam)], formattedTimeRemaining, g_FormattedTeamNames[forfeitingTeam], formattedCancelFFWCommand); } else { diff --git a/scripting/get5/util.sp b/scripting/get5/util.sp index d4be3a516..7f96d0cdc 100644 --- a/scripting/get5/util.sp +++ b/scripting/get5/util.sp @@ -775,3 +775,110 @@ stock void ConvertSecondsToMinutesAndSeconds(int timeAsSeconds, char[] buffer, c stock bool IsDoingRestoreOrMapChange() { return g_DoingBackupRestoreNow || g_MapChangePending; } + +stock void ChatCommandToString(const Get5ChatCommand command, char[] buffer, const int bufferSize) { + switch (command) + { + case Get5ChatCommand_Ready: + { + FormatEx(buffer, bufferSize, "ready"); + } + case Get5ChatCommand_Unready: + { + FormatEx(buffer, bufferSize, "unready"); + } + case Get5ChatCommand_ForceReady: + { + FormatEx(buffer, bufferSize, "forceready"); + } + case Get5ChatCommand_Tech: + { + FormatEx(buffer, bufferSize, "tech"); + } + case Get5ChatCommand_Pause: + { + FormatEx(buffer, bufferSize, "pause"); + } + case Get5ChatCommand_Unpause: + { + FormatEx(buffer, bufferSize, "unpause"); + } + case Get5ChatCommand_Coach: + { + FormatEx(buffer, bufferSize, "coach"); + } + case Get5ChatCommand_Stay: + { + FormatEx(buffer, bufferSize, "stay"); + } + case Get5ChatCommand_Swap: + { + FormatEx(buffer, bufferSize, "swap"); + } + case Get5ChatCommand_T: + { + FormatEx(buffer, bufferSize, "t"); + } + case Get5ChatCommand_CT: + { + FormatEx(buffer, bufferSize, "ct"); + } + case Get5ChatCommand_Stop: + { + FormatEx(buffer, bufferSize, "stop"); + } + case Get5ChatCommand_Surrender: + { + FormatEx(buffer, bufferSize, "surrender"); + } + case Get5ChatCommand_FFW: + { + FormatEx(buffer, bufferSize, "ffw"); + } + case Get5ChatCommand_CancelFFW: + { + FormatEx(buffer, bufferSize, "cancelffw"); + } + default: + { + LogError("Failed to map Get5ChatCommand with value %d to a string. It is missing from ChatCommandToString.", command); + } + } +} + +stock Get5ChatCommand StringToChatCommand(const char[] string) { + if (strcmp(string, "ready") == 0) { + return Get5ChatCommand_Ready; + } else if (strcmp(string, "unready") == 0) { + return Get5ChatCommand_Unready; + } else if (strcmp(string, "forceready") == 0) { + return Get5ChatCommand_ForceReady; + } else if (strcmp(string, "tech") == 0) { + return Get5ChatCommand_Tech; + } else if (strcmp(string, "pause") == 0) { + return Get5ChatCommand_Pause; + } else if (strcmp(string, "unpause") == 0) { + return Get5ChatCommand_Unpause; + } else if (strcmp(string, "coach") == 0) { + return Get5ChatCommand_Coach; + } else if (strcmp(string, "stay") == 0) { + return Get5ChatCommand_Stay; + } else if (strcmp(string, "swap") == 0) { + return Get5ChatCommand_Swap; + } else if (strcmp(string, "t") == 0) { + return Get5ChatCommand_T; + } else if (strcmp(string, "ct") == 0) { + return Get5ChatCommand_CT; + } else if (strcmp(string, "stop") == 0) { + return Get5ChatCommand_Stop; + } else if (strcmp(string, "surrender") == 0) { + return Get5ChatCommand_Surrender; + } else if (strcmp(string, "ffw") == 0) { + return Get5ChatCommand_FFW; + } else if (strcmp(string, "cancelffw") == 0) { + return Get5ChatCommand_CancelFFW; + } else { + return Get5ChatCommand_Unknown; + } +} + diff --git a/scripting/include/get5.inc b/scripting/include/get5.inc index 62ff3a463..ab05b1b8a 100644 --- a/scripting/include/get5.inc +++ b/scripting/include/get5.inc @@ -50,6 +50,25 @@ enum Get5PauseType { Get5PauseType_Backup // Special type for match pausing during backups. }; +enum Get5ChatCommand { + Get5ChatCommand_Unknown, + Get5ChatCommand_Ready, + Get5ChatCommand_Unready, + Get5ChatCommand_ForceReady, + Get5ChatCommand_Tech, + Get5ChatCommand_Pause, + Get5ChatCommand_Unpause, + Get5ChatCommand_Coach, + Get5ChatCommand_Stay, + Get5ChatCommand_Swap, + Get5ChatCommand_T, + Get5ChatCommand_CT, + Get5ChatCommand_Stop, + Get5ChatCommand_Surrender, + Get5ChatCommand_FFW, + Get5ChatCommand_CancelFFW, +}; + enum MatchSideType { MatchSideType_Standard, // Team that doesn't pick map gets side choice, leftovers go to knife rounds MatchSideType_AlwaysKnife, // All maps use a knife round to pick sides From f8f27884731b749114e19cfc9bff43166673a206 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 21 Dec 2022 22:54:14 +0100 Subject: [PATCH 24/27] Always stop demo recording if untracked (#960) Properly go into ready-up when loading backup for different match/map/nonlive Remove redundant ready status reset Only load pauses from backup if loading a different match/nonlive --- scripting/get5/backups.sp | 27 +++++++++++++-------------- scripting/get5/recording.sp | 3 ++- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/scripting/get5/backups.sp b/scripting/get5/backups.sp index 9e7627363..e93de7d6a 100644 --- a/scripting/get5/backups.sp +++ b/scripting/get5/backups.sp @@ -455,11 +455,10 @@ bool RestoreFromBackup(const char[] path, char[] error) { } bool backupIsForDifferentMap = !StrEqual(currentMap, loadedMapName, false); - - bool shouldRestartRecording = g_GameState != Get5State_Live || g_MapNumber != loadedMapNumber || + bool backupIsForDifferentMatch = g_GameState != Get5State_Live || g_MapNumber != loadedMapNumber || backupIsForDifferentMap || !StrEqual(loadedMatchId, g_MatchID); - if (shouldRestartRecording) { + if (backupIsForDifferentMatch) { // We must stop recording to fire the Get5_OnDemoFinished event when loading a backup to another match or map, and // we must do it before we load the match config, or the g_MatchID, g_MapNumber and g_DemoFilePath variables will be // incorrect. This is suppressed if we load to the same match and map ID during a live match, either via @@ -482,7 +481,7 @@ bool RestoreFromBackup(const char[] path, char[] error) { kv.GoBack(); } - if (g_GameState != Get5State_Live) { + if (backupIsForDifferentMatch) { // This isn't perfect, but it's better than resetting all pauses used to zero in cases of // restore on a new server or a different map. If restoring while live, we just retain the // current pauses used, as they should be the "most correct". @@ -572,23 +571,23 @@ bool RestoreFromBackup(const char[] path, char[] error) { ChangeState(valveBackup ? Get5State_PendingRestore : Get5State_Warmup); ChangeMap(loadedMapName, 3.0); } else { - if (valveBackup) { - // Same map, but round restore with a Valve backup; do normal restore immediately with no + if (valveBackup && !backupIsForDifferentMatch) { + // Same map/match, but round restore with a Valve backup; do normal restore immediately with no // ready-up and no game-state change. Players' teams are checked after the backup file is loaded. - RestoreGet5Backup(shouldRestartRecording); + RestoreGet5Backup(false); } else { - // We are restarting to the same map for prelive; just go back into warmup and let players - // ready-up again. - ResetReadyStatus(); + // We are restarting to the same map for prelive or loading from a none-live state; just go back into + // warmup and let players ready-up again, either for a restore or for knife/live. + // Ready status is reset when loading a match config. UnpauseGame(Get5Team_None); - ChangeState(Get5State_Warmup); + // If we load a valve backup in non-live, we have to go to ready-up, otherwise it's a prelive and we go to warmup. + ChangeState(valveBackup ? Get5State_PendingRestore : Get5State_Warmup); ExecCfg(g_WarmupCfgCvar); StartWarmup(); // We must assign players to their teams. This is normally done inside LoadMatchConfig, but // since we need the team sides to be applied from the backup, we skip it then and do it here. - // We *do not* do this before loading from a valve backup, as it will kill every player on the wrong - // team and cause various events to misbehave. This is also why it comes after the Get5State_Warmup - // state change above, to suppress all live events. + // We do this *after* putting the game into warmup, as it may otherwise kill people if they are + // moved the other team, which will trigger various events and cause the game to misbehave. if (g_CheckAuthsCvar.BoolValue) { LOOP_CLIENTS(i) { if (IsPlayer(i)) { diff --git a/scripting/get5/recording.sp b/scripting/get5/recording.sp index af007f454..4792b45ce 100644 --- a/scripting/get5/recording.sp +++ b/scripting/get5/recording.sp @@ -42,7 +42,8 @@ bool StartRecording() { void StopRecording(float delay = 0.0) { if (StrEqual("", g_DemoFilePath)) { - LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished() or stopping recording."); + LogDebug("Demo was not recorded by Get5; not firing Get5_OnDemoFinished()."); + ServerCommand("tv_stoprecord"); return; } char uploadUrl[1024]; From dfe0b2cd5bd919c404f1bd8091745d105d696153 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Wed, 21 Dec 2022 23:08:22 +0100 Subject: [PATCH 25/27] Add missing es translation --- translations/es/get5.phrases.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/translations/es/get5.phrases.txt b/translations/es/get5.phrases.txt index 2146bed77..eae028b0c 100644 --- a/translations/es/get5.phrases.txt +++ b/translations/es/get5.phrases.txt @@ -176,6 +176,14 @@ { "es" "La partida está terminada" } + "StopCommandRequiresNoDamage" + { + "es" "Una solicitud para reiniciar una ronda no se puede dar si el jugador ha causado daño a un jugador del equipo opuesto." + } + "StopCommandTimeLimitExceeded" + { + "es" "Una solicitud para reiniciar la ronda se debe dar dentro de {1} despues del comienzo de la ronda." + } "BackupLoadedInfoMessage" { "es" "Backup cargado exitosamente {1}." From dba1db562dd85462bcec5943f1a17d947a630d90 Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 23 Dec 2022 01:17:35 +0100 Subject: [PATCH 26/27] Add missing "tech" chat command Minor adjustments --- documentation/docs/commands.md | 2 +- scripting/get5.sp | 1 + scripting/get5/matchconfig.sp | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/documentation/docs/commands.md b/documentation/docs/commands.md index 8655cd479..bfc53dcbb 100644 --- a/documentation/docs/commands.md +++ b/documentation/docs/commands.md @@ -113,7 +113,7 @@ The chat alias file is only loaded once per plugin boot. If you want to reload i This maps the French word *abandon* to the surrender command. Get5 will also print `!abandon` when it references the surrender command in chat messages. The original commands ([`!surrender`](#surrender) and [`!gg`](#surrender)) will - still work. + still work. **Do not** prefix your alias with `!` or `.` - this is done automatically. ``` "Commands" diff --git a/scripting/get5.sp b/scripting/get5.sp index 1befbbe25..804a6a93b 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -492,6 +492,7 @@ public void OnPluginStart() { MapChatCommand(Get5ChatCommand_Unready, "notready"); MapChatCommand(Get5ChatCommand_Unready, "unready"); MapChatCommand(Get5ChatCommand_ForceReady, "forceready"); + MapChatCommand(Get5ChatCommand_Tech, "tech"); MapChatCommand(Get5ChatCommand_Pause, "tac"); MapChatCommand(Get5ChatCommand_Pause, "pause"); MapChatCommand(Get5ChatCommand_Unpause, "unpause"); diff --git a/scripting/get5/matchconfig.sp b/scripting/get5/matchconfig.sp index 2600c1651..d61e79b1f 100644 --- a/scripting/get5/matchconfig.sp +++ b/scripting/get5/matchconfig.sp @@ -446,9 +446,9 @@ static bool LoadMatchFromKeyValue(KeyValues kv, char[] error) { } if (kv.JumpToKey("cvars")) { - char name[MAX_CVAR_LENGTH]; - char value[MAX_CVAR_LENGTH]; if (kv.GotoFirstSubKey(false)) { + char name[MAX_CVAR_LENGTH]; + char value[MAX_CVAR_LENGTH]; do { kv.GetSectionName(name, sizeof(name)); ReadEmptyStringInsteadOfPlaceholder(kv, value, sizeof(value)); @@ -1233,8 +1233,8 @@ Action Command_CreateScrim(int client, int args) { // Also ensure empty string values in cvars get printed to the match config. if (kv.JumpToKey("cvars")) { - char cVarValue[MAX_CVAR_LENGTH]; if (kv.GotoFirstSubKey(false)) { + char cVarValue[MAX_CVAR_LENGTH]; do { WritePlaceholderInsteadOfEmptyString(kv, cVarValue, sizeof(cVarValue)); } while (kv.GotoNextKey(false)); From c54cd78a66c8b04a210ed90279d0f3a5dea3373e Mon Sep 17 00:00:00 2001 From: Nicolai Cornelis Date: Fri, 23 Dec 2022 01:19:18 +0100 Subject: [PATCH 27/27] Run formatter --- scripting/get5.sp | 6 ++- scripting/get5/backups.sp | 2 +- scripting/get5/chatcommands.sp | 69 ++++++++++++---------------------- scripting/get5/pausing.sp | 3 +- scripting/get5/surrender.sp | 3 +- scripting/get5/util.sp | 55 ++++++++++----------------- 6 files changed, 53 insertions(+), 85 deletions(-) diff --git a/scripting/get5.sp b/scripting/get5.sp index 804a6a93b..cd0157459 100644 --- a/scripting/get5.sp +++ b/scripting/get5.sp @@ -542,8 +542,10 @@ public void OnPluginStart() { RegAdminCmd("get5_dumpstats", Command_DumpStats, ADMFLAG_CHANGEMAP, "Dumps match stats to a file"); RegAdminCmd("get5_listbackups", Command_ListBackups, ADMFLAG_CHANGEMAP, "Lists get5 match backups for the current matchid or a given one"); - RegAdminCmd("get5_loadbackup", Command_LoadBackup, ADMFLAG_CHANGEMAP, "Loads a Get5 match backup from a file relative to the csgo directory."); - RegAdminCmd("get5_loadbackup_url", Command_LoadBackupUrl, ADMFLAG_CHANGEMAP, "Downloads and loads a Get5 match backup from a URL."); + RegAdminCmd("get5_loadbackup", Command_LoadBackup, ADMFLAG_CHANGEMAP, + "Loads a Get5 match backup from a file relative to the csgo directory."); + RegAdminCmd("get5_loadbackup_url", Command_LoadBackupUrl, ADMFLAG_CHANGEMAP, + "Downloads and loads a Get5 match backup from a URL."); RegAdminCmd("get5_debuginfo", Command_DebugInfo, ADMFLAG_CHANGEMAP, "Dumps debug info to a file (addons/sourcemod/logs/get5_debuginfo.txt by default)"); diff --git a/scripting/get5/backups.sp b/scripting/get5/backups.sp index e93de7d6a..100ab07d2 100644 --- a/scripting/get5/backups.sp +++ b/scripting/get5/backups.sp @@ -456,7 +456,7 @@ bool RestoreFromBackup(const char[] path, char[] error) { bool backupIsForDifferentMap = !StrEqual(currentMap, loadedMapName, false); bool backupIsForDifferentMatch = g_GameState != Get5State_Live || g_MapNumber != loadedMapNumber || - backupIsForDifferentMap || !StrEqual(loadedMatchId, g_MatchID); + backupIsForDifferentMap || !StrEqual(loadedMatchId, g_MatchID); if (backupIsForDifferentMatch) { // We must stop recording to fire the Get5_OnDemoFinished event when loading a backup to another match or map, and diff --git a/scripting/get5/chatcommands.sp b/scripting/get5/chatcommands.sp index 088f7834f..56aec7343 100644 --- a/scripting/get5/chatcommands.sp +++ b/scripting/get5/chatcommands.sp @@ -17,80 +17,63 @@ static void AddChatAlias(const char[] alias, const char[] command) { } void MapChatCommand(const Get5ChatCommand command, const char[] alias) { - switch (command) - { - case Get5ChatCommand_Ready: - { + switch (command) { + case Get5ChatCommand_Ready: { AddAliasedCommand(alias, Command_Ready, "Marks the client as ready."); } - case Get5ChatCommand_Unready: - { + case Get5ChatCommand_Unready: { AddAliasedCommand(alias, Command_NotReady, "Marks the client as not ready."); } - case Get5ChatCommand_ForceReady: - { + case Get5ChatCommand_ForceReady: { AddAliasedCommand(alias, Command_ForceReadyClient, "Marks the client's entire team as ready."); } - case Get5ChatCommand_Tech: - { + case Get5ChatCommand_Tech: { AddAliasedCommand(alias, Command_TechPause, "Calls for a technical pause."); } - case Get5ChatCommand_Pause: - { + case Get5ChatCommand_Pause: { AddAliasedCommand(alias, Command_Pause, "Calls for a tactical pause."); } - case Get5ChatCommand_Unpause: - { + case Get5ChatCommand_Unpause: { AddAliasedCommand(alias, Command_Unpause, "Unpauses the game."); } - case Get5ChatCommand_Coach: - { + case Get5ChatCommand_Coach: { AddAliasedCommand(alias, Command_SmCoach, "Requests to become a coach."); } - case Get5ChatCommand_Stay: - { + case Get5ChatCommand_Stay: { AddAliasedCommand(alias, Command_Stay, "Elects to stay on the current side after winning a knife round."); } - case Get5ChatCommand_Swap: - { + case Get5ChatCommand_Swap: { AddAliasedCommand(alias, Command_Swap, "Elects to swap to the other side after winning a knife round."); } - case Get5ChatCommand_T: - { + case Get5ChatCommand_T: { AddAliasedCommand(alias, Command_T, "Elects to start on T side after winning a knife round."); } - case Get5ChatCommand_CT: - { + case Get5ChatCommand_CT: { AddAliasedCommand(alias, Command_Ct, "Elects to start on CT side after winning a knife round."); } - case Get5ChatCommand_Stop: - { + case Get5ChatCommand_Stop: { AddAliasedCommand(alias, Command_Stop, "Elects to stop the game to reload a backup file for the current round."); } - case Get5ChatCommand_Surrender: - { + case Get5ChatCommand_Surrender: { AddAliasedCommand(alias, Command_Surrender, "Starts a vote for surrendering for your team."); } - case Get5ChatCommand_FFW: - { + case Get5ChatCommand_FFW: { AddAliasedCommand(alias, Command_FFW, "Starts a countdown to win if a full team disconnects from the server."); } - case Get5ChatCommand_CancelFFW: - { + case Get5ChatCommand_CancelFFW: { AddAliasedCommand(alias, Command_CancelFFW, "Cancels a pending request to win by forfeit."); } - default: - { + default: { LogError("Failed to map Get5ChatCommand with value %d to a command. It is missing from MapChatCommand.", command); return; } } - char commandAsString[64]; // "ready"; base command - char commandAliasFormatted[64]; // "!readyalias"; the alias to use, with ! in front + char commandAsString[64]; // "ready"; base command + char commandAliasFormatted[64]; // "!readyalias"; the alias to use, with ! in front ChatCommandToString(command, commandAsString, sizeof(commandAsString)); FormatEx(commandAliasFormatted, sizeof(commandAliasFormatted), "!%s", alias); - g_ChatCommands.SetString(commandAsString, commandAliasFormatted); // maps ready -> !readyalias + g_ChatCommands.SetString(commandAsString, commandAliasFormatted); // maps ready -> !readyalias } void GetChatAliasForCommand(const Get5ChatCommand command, char[] buffer, int bufferSize, bool format) { @@ -116,17 +99,15 @@ int LoadCustomChatAliases(const char[] file) { KeyValues chatAliases = new KeyValues("Commands"); if (!chatAliases.ImportFromFile(file)) { - LogError("Failed to read chat command aliases file at '%s'.", file); - delete chatAliases; - return loadedAliases; + LogError("Failed to read chat command aliases file at '%s'.", file); + delete chatAliases; + return loadedAliases; } - if (chatAliases.GotoFirstSubKey(false)) - { + if (chatAliases.GotoFirstSubKey(false)) { char alias[255]; char command[255]; - do - { + do { chatAliases.GetSectionName(alias, sizeof(alias)); chatAliases.GetString(NULL_STRING, command, sizeof(command)); diff --git a/scripting/get5/pausing.sp b/scripting/get5/pausing.sp index 0539ef682..f477bca2c 100644 --- a/scripting/get5/pausing.sp +++ b/scripting/get5/pausing.sp @@ -410,7 +410,8 @@ static Action Timer_PauseTimeCheck(Handle timer) { // unpause on their own. The PrintHintText below will inform users that they can now // unpause. char formattedUnpauseCommand[64]; - GetChatAliasForCommand(Get5ChatCommand_Unpause, formattedUnpauseCommand, sizeof(formattedUnpauseCommand), true); + GetChatAliasForCommand(Get5ChatCommand_Unpause, formattedUnpauseCommand, sizeof(formattedUnpauseCommand), + true); Get5_MessageToAll("%t", "TechPauseRunoutInfoMessage", formattedUnpauseCommand); } } diff --git a/scripting/get5/surrender.sp b/scripting/get5/surrender.sp index 7621a2727..facc29c08 100644 --- a/scripting/get5/surrender.sp +++ b/scripting/get5/surrender.sp @@ -190,7 +190,8 @@ static void AnnounceRemainingForfeitTime(const int remainingSeconds, const Get5T if (forfeitingTeam != Get5Team_None) { char formattedCancelFFWCommand[64]; - GetChatAliasForCommand(Get5ChatCommand_CancelFFW, formattedCancelFFWCommand, sizeof(formattedCancelFFWCommand), true); + GetChatAliasForCommand(Get5ChatCommand_CancelFFW, formattedCancelFFWCommand, sizeof(formattedCancelFFWCommand), + true); Get5_MessageToAll("%t", "WinByForfeitCountdownStarted", g_FormattedTeamNames[OtherMatchTeam(forfeitingTeam)], formattedTimeRemaining, g_FormattedTeamNames[forfeitingTeam], formattedCancelFFWCommand); } else { diff --git a/scripting/get5/util.sp b/scripting/get5/util.sp index 7f96d0cdc..4bbdf5977 100644 --- a/scripting/get5/util.sp +++ b/scripting/get5/util.sp @@ -777,71 +777,55 @@ stock bool IsDoingRestoreOrMapChange() { } stock void ChatCommandToString(const Get5ChatCommand command, char[] buffer, const int bufferSize) { - switch (command) - { - case Get5ChatCommand_Ready: - { + switch (command) { + case Get5ChatCommand_Ready: { FormatEx(buffer, bufferSize, "ready"); } - case Get5ChatCommand_Unready: - { + case Get5ChatCommand_Unready: { FormatEx(buffer, bufferSize, "unready"); } - case Get5ChatCommand_ForceReady: - { + case Get5ChatCommand_ForceReady: { FormatEx(buffer, bufferSize, "forceready"); } - case Get5ChatCommand_Tech: - { + case Get5ChatCommand_Tech: { FormatEx(buffer, bufferSize, "tech"); } - case Get5ChatCommand_Pause: - { + case Get5ChatCommand_Pause: { FormatEx(buffer, bufferSize, "pause"); } - case Get5ChatCommand_Unpause: - { + case Get5ChatCommand_Unpause: { FormatEx(buffer, bufferSize, "unpause"); } - case Get5ChatCommand_Coach: - { + case Get5ChatCommand_Coach: { FormatEx(buffer, bufferSize, "coach"); } - case Get5ChatCommand_Stay: - { + case Get5ChatCommand_Stay: { FormatEx(buffer, bufferSize, "stay"); } - case Get5ChatCommand_Swap: - { + case Get5ChatCommand_Swap: { FormatEx(buffer, bufferSize, "swap"); } - case Get5ChatCommand_T: - { + case Get5ChatCommand_T: { FormatEx(buffer, bufferSize, "t"); } - case Get5ChatCommand_CT: - { + case Get5ChatCommand_CT: { FormatEx(buffer, bufferSize, "ct"); } - case Get5ChatCommand_Stop: - { + case Get5ChatCommand_Stop: { FormatEx(buffer, bufferSize, "stop"); } - case Get5ChatCommand_Surrender: - { + case Get5ChatCommand_Surrender: { FormatEx(buffer, bufferSize, "surrender"); } - case Get5ChatCommand_FFW: - { + case Get5ChatCommand_FFW: { FormatEx(buffer, bufferSize, "ffw"); } - case Get5ChatCommand_CancelFFW: - { + case Get5ChatCommand_CancelFFW: { FormatEx(buffer, bufferSize, "cancelffw"); } - default: - { - LogError("Failed to map Get5ChatCommand with value %d to a string. It is missing from ChatCommandToString.", command); + default: { + LogError("Failed to map Get5ChatCommand with value %d to a string. It is missing from ChatCommandToString.", + command); } } } @@ -881,4 +865,3 @@ stock Get5ChatCommand StringToChatCommand(const char[] string) { return Get5ChatCommand_Unknown; } } -