diff --git a/.clang-format b/.clang-format index 6130797fb..c6d66404e 100644 --- a/.clang-format +++ b/.clang-format @@ -9,18 +9,39 @@ AllowShortCaseLabelsOnASingleLine: false AllowShortEnumsOnASingleLine: false AllowShortFunctionsOnASingleLine: Empty AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: None AllowShortLoopsOnASingleLine: false +AlwaysBreakTemplateDeclarations: true BitFieldColonSpacing: After + +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: true + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: false + BeforeWhile: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true + BreakArrays: true BreakBeforeBinaryOperators: NonAssignment -BreakBeforeBraces: Allman +BreakBeforeBraces: Custom BreakBeforeTernaryOperators: true -AlwaysBreakTemplateDeclarations: true ColumnLimit: 150 CompactNamespaces: false EmptyLineBeforeAccessModifier: Always IncludeBlocks: Preserve IndentCaseLabels: true +IndentWidth: 4 InsertBraces: true InsertNewlineAtEOF: true KeepEmptyLinesAtTheStartOfBlocks: false @@ -45,6 +66,5 @@ SpaceBeforeSquareBrackets: false SpaceInEmptyBlock: false SpacesInAngles: Never SpacesInSquareBrackets: false -UseTab: ForContinuationAndIndentation -IndentWidth: 4 TabWidth: 4 +UseTab: ForContinuationAndIndentation diff --git a/.gitmodules b/.gitmodules index c941e1d44..209950764 100644 --- a/.gitmodules +++ b/.gitmodules @@ -11,3 +11,6 @@ [submodule "vendor/ClientCvarValue"] path = vendor/ClientCvarValue url = https://github.com/komashchenko/ClientCvarValue.git +[submodule "vendor/json"] + path = vendor/json + url = https://github.com/nlohmann/json diff --git a/AMBuilder b/AMBuilder index 7bd55a211..1e4d131af 100644 --- a/AMBuilder +++ b/AMBuilder @@ -59,6 +59,7 @@ for sdk_target in MMSPlugin.sdk_targets: os.path.join(builder.sourcePath, 'vendor', 'funchook', 'lib', 'funchook.lib'), os.path.join(builder.sourcePath, 'vendor', 'funchook', 'lib', 'distorm.lib'), os.path.join(sdk['path'], 'lib', 'public', 'win64', 'mathlib.lib'), + os.path.join(sdk['path'], 'lib', 'public', 'win64', 'steam_api64.lib') ] binary.sources += [ 'src/utils/plat_win.cpp' @@ -76,6 +77,7 @@ for sdk_target in MMSPlugin.sdk_targets: os.path.join(builder.sourcePath, 'src', 'utils', 'utils_print.cpp'), os.path.join(builder.sourcePath, 'src', 'utils', 'gameconfig.cpp'), os.path.join(builder.sourcePath, 'src', 'utils', 'hooks.cpp'), + os.path.join(builder.sourcePath, 'src', 'utils', 'http.cpp'), os.path.join(builder.sourcePath, 'src', 'utils', 'detours.cpp'), os.path.join(builder.sourcePath, 'src', 'utils', 'schema.cpp'), os.path.join(builder.sourcePath, 'src', 'utils', 'simplecmds.cpp'), @@ -97,6 +99,11 @@ for sdk_target in MMSPlugin.sdk_targets: os.path.join(builder.sourcePath, 'src', 'kz', 'checkpoint', 'kz_checkpoint.cpp'), os.path.join(builder.sourcePath, 'src', 'kz', 'checkpoint', 'commands.cpp'), os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'kz_global.cpp'), + os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'commands.cpp'), + os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'error.cpp'), + os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'modes.cpp'), + os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'players.cpp'), + os.path.join(builder.sourcePath, 'src', 'kz', 'global', 'maps.cpp'), os.path.join(builder.sourcePath, 'src', 'kz', 'hud', 'kz_hud.cpp'), os.path.join(builder.sourcePath, 'src', 'kz', 'jumpstats', 'kz_jumpstats.cpp'), diff --git a/cfg/cs2kz-server-config.txt b/cfg/cs2kz-server-config.txt index f30b8332e..2db148eee 100644 --- a/cfg/cs2kz-server-config.txt +++ b/cfg/cs2kz-server-config.txt @@ -1,10 +1,11 @@ "ServerCfg" { - "defaultMode" "Classic" - "defaultStyle" "Normal" - "defaultLanguage" "en" - "tipInterval" "75" - "defaultJSBroadcastMinTier" "4" - "defaultJSSoundMinTier" "4" - "chatPrefix" "{lime}KZ {grey}|{default}" + "defaultMode" "Classic" + "defaultStyle" "Normal" + "defaultLanguage" "en" + "tipInterval" "75" + "defaultJSBroadcastMinTier" "4" + "defaultJSSoundMinTier" "4" + "chatPrefix" "{lime}KZ {grey}|{default}" + "apiUrl" "http://localhost:42069" } diff --git a/src/cs2kz.cpp b/src/cs2kz.cpp index b9b13359b..d0952a303 100644 --- a/src/cs2kz.cpp +++ b/src/cs2kz.cpp @@ -1,3 +1,4 @@ +#include "kz/global/kz_global.h" #include "cs2kz.h" #include "entity2/entitysystem.h" @@ -58,6 +59,7 @@ bool KZPlugin::Load(PluginId id, ISmmAPI *ismm, char *error, size_t maxlen, bool KZOptionService::InitOptions(); KZTipService::InitTips(); + KZGlobalService::Init(); return true; } diff --git a/src/kz/global/commands.cpp b/src/kz/global/commands.cpp new file mode 100644 index 000000000..8d6c3fbee --- /dev/null +++ b/src/kz/global/commands.cpp @@ -0,0 +1,81 @@ +#include "kz_global.h" +#include "kz/language/kz_language.h" +#include "utils/simplecmds.h" + +static_function SCMD_CALLBACK(Command_KzProfile) +{ + KZPlayer *player = g_pKZPlayerManager->ToPlayer(controller); + + auto onSuccess = [player](std::optional info) { + if (!info) + { + player->languageService->PrintChat(true, false, "Player not found"); + return; + } + + const char *name = info->name.c_str(); + const char *steamID = info->steamID.c_str(); + const char *isBanned = info->isBanned ? info->isBanned.value() ? "" : "not " : "maybe "; + + player->languageService->PrintChat(true, false, "Display PlayerInfo", name, steamID, isBanned); + }; + + auto onError = [player](KZ::API::Error error) { + player->languageService->PrintError(error); + }; + + const char *playerIdentifier = args->Arg(1); + + if (playerIdentifier[0] == '\0') + { + KZGlobalService::FetchPlayer(player->GetSteamId64(), onSuccess, onError); + } + else + { + KZGlobalService::FetchPlayer(playerIdentifier, onSuccess, onError); + } + + return MRES_SUPERCEDE; +} + +static_function SCMD_CALLBACK(Command_KzMapInfo) +{ + KZPlayer *player = g_pKZPlayerManager->ToPlayer(controller); + const char *mapIdentifier = args->Arg(1); + + if (mapIdentifier[0] != '\0') + { + auto onSuccess = [player](std::optional map) { + if (!map) + { + player->languageService->PrintChat(true, false, "MapNotGlobal"); + return; + } + + player->languageService->PrintMap(map.value()); + }; + + auto onError = [player](KZ::API::Error error) { + player->languageService->PrintError(error); + }; + + KZGlobalService::FetchMap(mapIdentifier, onSuccess, onError); + } + else if (KZGlobalService::currentMap) + { + player->languageService->PrintMap(KZGlobalService::currentMap.value()); + } + else + { + player->languageService->PrintChat(true, false, "MapNotGlobal"); + } + + return MRES_SUPERCEDE; +} + +void KZGlobalService::RegisterCommands() +{ + scmd::RegisterCmd("kz_profile", Command_KzProfile); + scmd::RegisterCmd("kz_mapinfo", Command_KzMapInfo); + scmd::RegisterCmd("kz_minfo", Command_KzMapInfo); +} diff --git a/src/kz/global/error.cpp b/src/kz/global/error.cpp new file mode 100644 index 000000000..41bc6b276 --- /dev/null +++ b/src/kz/global/error.cpp @@ -0,0 +1,41 @@ +#include "error.h" +#include "kz/language/kz_language.h" + +namespace KZ::API +{ + Error::Error(u16 status, std::string message) : status(status) + { + if (!json::accept(message)) + { + this->message = message; + return; + } + + const json error = json::parse(message); + + if (!error.is_object()) + { + META_CONPRINTF("[KZ::Global] API error is not an object: `%s`\n", error.dump().c_str()); + return; + } + + if (!error.contains("message")) + { + META_CONPRINTF("[KZ::Global] API error does not contain a message: `%s`\n", error.dump().c_str()); + return; + } + + if (!error["message"].is_string()) + { + META_CONPRINTF("[KZ::Global] API error message is not a string: `%s`\n", error.dump().c_str()); + return; + } + + this->message = error["message"]; + + if (error.contains("details")) + { + this->details = error["details"]; + } + } +} // namespace KZ::API diff --git a/src/kz/global/error.h b/src/kz/global/error.h new file mode 100644 index 000000000..01a635c92 --- /dev/null +++ b/src/kz/global/error.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include "common.h" +#include "utils/json.h" + +namespace KZ::API +{ + /// An error object returned by the API. + struct Error + { + /// The HTTP status code returned alongside the error. + u16 status; + + /// The error message. + std::string message; + + /// Additional details that may or may not exist. + json details; + + /// Parses an error from a response. + /// + /// If `message` is JSON, it will be deserialized. + Error(u16 status, std::string message); + }; + + /// An error that occurred while parsing JSON. + struct ParseError + { + /// The reason we failed. + std::string reason; + + ParseError(std::string reason) : reason(reason) {} + }; +} // namespace KZ::API diff --git a/src/kz/global/kz_global.cpp b/src/kz/global/kz_global.cpp index 6a3689044..a054101be 100644 --- a/src/kz/global/kz_global.cpp +++ b/src/kz/global/kz_global.cpp @@ -1,2 +1,440 @@ +#include +#include + +#include "utils/json.h" + +#include "kz/kz.h" +#include "kz/language/kz_language.h" +#include "error.h" +#include "kz/option/kz_option.h" #include "kz_global.h" -#include "../kz.h" +#include "players.h" +#include "utils/ctimer.h" +#include "utils/http.h" +#include "version.h" + +std::optional KZGlobalService::currentMap = std::nullopt; +const char *KZGlobalService::apiURL = "https://api.cs2kz.org"; +std::atomic KZGlobalService::isHealthy = false; +std::atomic KZGlobalService::authTimerInitialized = false; +std::optional KZGlobalService::apiKey = std::nullopt; +std::optional KZGlobalService::authPayload = std::nullopt; +std::optional KZGlobalService::apiToken = std::nullopt; + +extern ISteamHTTP *g_pHTTP; + +void KZGlobalService::Init() +{ + META_CONPRINTF("[KZ::Global] Initializing API connection...\n"); + + KZGlobalService::apiURL = KZOptionService::GetOptionStr("apiURL", "https://api.cs2kz.org"); + META_CONPRINTF("[KZ::Global] Registered API URL: `%s`\n", KZGlobalService::apiURL); + + const char *apiKey = KZOptionService::GetOptionStr("apiKey", ""); + + if (apiKey[0] == '\0') + { + META_CONPRINTF("[KZ::Global] No API key found! Will not attempt to authenticate.\n"); + } + else + { + META_CONPRINTF("[KZ::Global] Loaded API key from config. Starting heartbeat...\n"); + KZGlobalService::apiKey = apiKey; + } + + StartTimer(Heartbeat, true, true); +} + +f64 KZGlobalService::Heartbeat() +{ + HTTP::Request request(HTTP::Method::GET, apiURL); + + request.Send([](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] API is unreachable.\n"); + isHealthy = false; + return; + } + + case 200: + { + auto body = response.Body(); + + if (!body) + { + META_CONPRINTF("[KZ::Global] API healthcheck did not contain a body\n"); + isHealthy = false; + return; + } + + META_CONPRINTF("[KZ::Global] API is healthy %s\n", body->c_str()); + isHealthy = true; + + if (!authTimerInitialized) + { + META_CONPRINTF("[KZ::Global] Initializing auth flow...\n"); + StartTimer(Auth, true, true); + authTimerInitialized = true; + } + + break; + } + + default: + { + META_CONPRINTF("[KZ::Global] API healthcheck failed with status %d: `%s`\n", response.status, response.Body().value_or("").c_str()); + isHealthy = false; + return; + } + } + }); + + return heartbeatInterval; +} + +f64 KZGlobalService::Auth() +{ + if (!apiKey.has_value()) + { + META_CONPRINTF("[KZ::Global] No API key found, can't authenticate.\n"); + return -1.0; + } + + if (!authPayload) + { + json requestBody; + requestBody["refresh_key"] = apiKey.value(); + requestBody["plugin_version"] = VERSION_STRING; + + authPayload = requestBody.dump(); + } + + const std::string requestURL = std::string(apiURL) + "/servers/key"; + + HTTP::Request request(HTTP::Method::POST, requestURL); + + request.SetBody(authPayload.value()); + + request.Send([](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] Failed to request access token.\n"); + return; + } + + case 201: + { + auto rawBody = response.Body(); + + if (!rawBody) + { + META_CONPRINTF("[KZ::Global] Acces token response has no body\n"); + return; + } + + const json responseBody = json::parse(rawBody.value()); + + if (!responseBody.is_object() || !responseBody.contains("access_key")) + { + META_CONPRINTF("[KZ::Global] Access token response has unexpected shape: `%s`\n", rawBody->c_str()); + ; + return; + } + + apiToken = responseBody["access_key"]; + META_CONPRINTF("[KZ::Global] Fetched access key `%s`\n", apiToken->c_str()); + break; + } + + default: + { + KZ::API::Error error(response.status, response.Body().value_or("")); + + META_CONPRINTF("[KZ::Global] Fetching access key failed with status %d: %s\n", error.status, error.message.c_str()); + + if (!error.details.is_null()) + { + META_CONPRINTF(" Details: `%s`\n", error.details.dump().c_str()); + } + + return; + } + } + }); + + return authInterval; +} + +bool FetchPlayerImpl(const char *url, KZGlobalService::Callback> onSuccess, + KZGlobalService::Callback onError) +{ + if (!KZGlobalService::IsHealthy()) + { + META_CONPRINTF("[KZ::Global] Cannot fetch player (API is currently not healthy).\n", url); + return false; + } + + HTTP::Request request(HTTP::Method::GET, url); + + request.Send([onSuccess, onError](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] Failed to make HTTP request.\n"); + return; + } + + case 200: + { + auto body = response.Body(); + + if (!body) + { + META_CONPRINTF("[KZ::Global] Player response has no body\n"); + return; + } + + auto json = json::parse(body.value()); + KZ::API::Player player; + + if (auto error = KZ::API::Player::Deserialize(json, player)) + { + META_CONPRINTF("[KZ::Global] Failed to parse player: %s\n", error->reason.c_str()); + } + else + { + onSuccess(player); + } + + break; + } + + case 404: + { + onSuccess(std::nullopt); + break; + } + + default: + { + KZ::API::Error error(response.status, response.Body().value_or("")); + onError(error); + return; + } + } + }); + + return true; +} + +bool KZGlobalService::FetchPlayer(const char *name, Callback> onSuccess, Callback onError) +{ + CUtlString url; + url.Format("%s/players/%s", apiURL, name); + return FetchPlayerImpl(url.Get(), onSuccess, onError); +} + +bool KZGlobalService::FetchPlayer(u64 steamID, Callback> onSuccess, Callback onError) +{ + CUtlString url; + url.Format("%s/players/%llu", apiURL, steamID); + return FetchPlayerImpl(url.Get(), onSuccess, onError); +} + +bool KZGlobalService::RegisterPlayer(KZPlayer *player, Callback> onError) +{ + if (!IsHealthy()) + { + META_CONPRINTF("[KZ::Global] Cannot register player (API is currently not healthy).\n"); + return false; + } + + if (!IsAuthenticated()) + { + META_CONPRINTF("[KZ::Global] Cannot register player (not authenticated with API).\n"); + return false; + } + + CUtlString url; + url.Format("%s/players", apiURL); + + auto newPlayer = KZ::API::NewPlayer {player->GetName(), player->GetSteamId64(), player->GetIpAddress()}; + json requestBody = newPlayer.Serialize(); + + HTTP::Request request(HTTP::Method::POST, url.Get()); + + request.SetHeader("Authorization", (std::string("Bearer ") + apiToken.value())); + request.SetBody(requestBody.dump()); + + request.Send([player, onError](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] Failed to make HTTP request.\n"); + break; + } + + case 201: + { + auto onSuccess = [player](std::optional info) { + if (!info) + { + player->languageService->PrintChat(true, false, "Player not found after registration"); + return; + } + + player->languageService->PrintChat(true, false, "Display Hello", info->name.c_str()); + player->info = info.value(); + }; + + auto onError = [player](KZ::API::Error error) { + player->languageService->PrintError(error); + }; + + KZGlobalService::FetchPlayer(player->GetSteamId64(), onSuccess, onError); + + break; + } + + default: + { + KZ::API::Error error(response.status, response.Body().value_or("")); + onError(error); + return; + } + } + }); + + return true; +} + +bool KZGlobalService::UpdatePlayer(KZPlayer *player, Callback> onError) +{ + if (!KZGlobalService::IsHealthy()) + { + META_CONPRINTF("[KZ::Global] Cannot fetch map (API is currently not healthy).\n"); + return false; + } + + CUtlString url; + url.Format("%s/players/%llu", apiURL, player->GetSteamId64()); + + const KZ::API::PlayerUpdate playerUpdate = {player->GetName(), player->GetIpAddress(), json::object(), player->session}; + const json requestBody = playerUpdate.Serialize(); + + HTTP::Request request(HTTP::Method::PATCH, url.Get()); + + request.SetHeader("Authorization", (std::string("Bearer ") + apiToken.value())); + request.SetBody(requestBody.dump()); + + META_CONPRINTF("[KZ::Global] updating player at `%s` with `%s`\n", url.Get(), requestBody.dump().c_str()); + request.Send([player, onError](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] Failed to make HTTP request.\n"); + return; + } + + case 204: + { + onError(std::nullopt); + break; + } + + default: + { + KZ::API::Error error(response.status, response.Body().value_or("")); + onError(error); + return; + } + } + }); + + return true; +} + +bool FetchMapImpl(const char *url, KZGlobalService::Callback> onSuccess, + KZGlobalService::Callback onError) +{ + if (!KZGlobalService::IsHealthy()) + { + META_CONPRINTF("[KZ::Global] Cannot fetch map (API is currently not healthy).\n"); + return false; + } + + HTTP::Request request(HTTP::Method::GET, url); + + request.Send([onSuccess, onError](HTTP::Response response) { + switch (response.status) + { + case 0: + { + META_CONPRINTF("[KZ::Global] Failed to make HTTP request.\n"); + return; + } + + case 200: + { + auto body = response.Body(); + + if (!body) + { + META_CONPRINTF("[KZ::Global] Map response has no body\n"); + return; + } + + auto json = json::parse(body.value()); + KZ::API::Map map; + + if (auto error = KZ::API::Map::Deserialize(json, map)) + { + META_CONPRINTF("[KZ::Global] Failed to deserialize map: %s\n", error->reason.c_str()); + } + else + { + onSuccess(map); + } + + break; + } + + case 404: + { + onSuccess(std::nullopt); + break; + } + + default: + { + KZ::API::Error error(response.status, response.Body().value_or("")); + onError(error); + return; + } + } + }); + + return true; +} + +bool KZGlobalService::FetchMap(const char *name, Callback> onSuccess, Callback onError) +{ + CUtlString url; + url.Format("%s/maps/%s", apiURL, name); + return FetchMapImpl(url.Get(), onSuccess, onError); +} + +bool KZGlobalService::FetchMap(u16 id, Callback> onSuccess, Callback onError) +{ + CUtlString url; + url.Format("%s/maps/%d", apiURL, id); + return FetchMapImpl(url.Get(), onSuccess, onError); +} diff --git a/src/kz/global/kz_global.h b/src/kz/global/kz_global.h index 0176ff6fa..7d844c9c3 100644 --- a/src/kz/global/kz_global.h +++ b/src/kz/global/kz_global.h @@ -1,7 +1,107 @@ #pragma once -#include "../kz.h" + +#include +#include +#include +#include + +#include "kz/kz.h" +#include "common.h" +#include "error.h" +#include "players.h" +#include "maps.h" +#include "utils/http.h" class KZGlobalService : public KZBaseService { using KZBaseService::KZBaseService; + +public: + template + using Callback = std::function; + + static std::optional currentMap; + + /// Initializes a global instance of this service. + static void Init(); + + /// Registers commands. + static void RegisterCommands(); + + /// Checks whether the API is healthy. + static bool IsHealthy() + { + return isHealthy; + } + + /// Checks whether we are authenticated with the API. + static bool IsAuthenticated() + { + return apiKey.has_value() && apiToken.has_value(); + } + + /// Fetches a player from the API using their name. + /// + /// Returns whether an HTTP request was made. + static bool FetchPlayer(const char *name, Callback> onSuccess, Callback onError); + + /// Fetches a player from the API using their SteamID. + /// + /// Returns whether an HTTP request was made. + static bool FetchPlayer(u64 steamID, Callback> onSuccess, Callback onError); + + /// Registers a player with the API. + static bool RegisterPlayer(KZPlayer *player, Callback> onError); + + /// Sends a player update to the API. + static bool UpdatePlayer(KZPlayer *player, Callback> onError); + + /// Fetches a map from the API using its name. + static bool FetchMap(const char *name, Callback> onSuccess, Callback onError); + + /// Fetches a map from the API using its ID. + static bool FetchMap(u16 id, Callback> onSuccess, Callback onError); + +private: + /// The API URL used for making requests. + /// + /// This is read from the server configuration on startup, or falls back to the official URL. + static const char *apiURL; + + /// Whether the API responded since the last heartbeat. + /// + /// This is private with a public getter so other classes can check the API status but not tamper with it. + static std::atomic isHealthy; + + /// Whether the timer for the authentication flow has already been started. + /// + /// This is initialized as `false` and later set to `true` after the first successful heartbeat. + static std::atomic authTimerInitialized; + + /// The server's API key used for generating access tokens. + /// + /// This is read from the server configuration on startup. + static std::optional apiKey; + + /// JSON payload for generating a new access token. + /// + /// Neither the API key nor the plugin version will change at runtime, so we can cache this. + static std::optional authPayload; + + /// The server's current access token. + /// + /// This gets updated every ~10 minutes. + static std::optional apiToken; + + /// Interval in seconds for making heartbeat requests. + static constexpr f64 heartbeatInterval = 30.0; + + /// Interval in seconds for fetching new access tokens. + static constexpr f64 authInterval = 60.0 * 10; + + /// Timer callback for pinging the API regularly. + static f64 Heartbeat(); + + /// Timer callback for fetching access tokens every `authInterval` seconds. + static f64 Auth(); }; diff --git a/src/kz/global/maps.cpp b/src/kz/global/maps.cpp new file mode 100644 index 000000000..85acd8fa2 --- /dev/null +++ b/src/kz/global/maps.cpp @@ -0,0 +1,388 @@ +#include "maps.h" + +namespace KZ::API +{ + std::optional Map::Deserialize(const json &json, Map &map) + { + if (!json.is_object()) + { + return KZ::API::ParseError("map is not an object"); + } + + if (!json.contains("id")) + { + return KZ::API::ParseError("map object is missing `id` property"); + } + + if (!json["id"].is_number_unsigned()) + { + return KZ::API::ParseError("map object `id` property is not an integer"); + } + + map.id = json["id"]; + + if (!json.contains("name")) + { + return KZ::API::ParseError("map object is missing `name` property"); + } + + if (!json["name"].is_string()) + { + return KZ::API::ParseError("map object `name` property is not a string"); + } + + map.name = json["name"]; + + if (json.contains("description")) + { + if (!json["description"].is_string()) + { + return KZ::API::ParseError("map object `description` property is not a string"); + } + + map.description = json["description"]; + } + + if (!json.contains("global_status")) + { + return KZ::API::ParseError("map object is missing `global_status` property"); + } + + if (auto error = Map::DeserializeGlobalStatus(json["global_status"], map.globalStatus)) + { + return error; + } + + if (!json.contains("workshop_id")) + { + return KZ::API::ParseError("map object is missing `workshop_id` property"); + } + + if (!json["workshop_id"].is_number_unsigned()) + { + return KZ::API::ParseError("map object `workshop_id` property is not an integer"); + } + + map.workshopID = json["workshop_id"]; + + if (!json.contains("checksum")) + { + return KZ::API::ParseError("map object is missing `checksum` property"); + } + + if (!json["checksum"].is_number_unsigned()) + { + return KZ::API::ParseError("map object `checksum` property is not an integer"); + } + + map.checksum = json["checksum"]; + + if (json.contains("mappers")) + { + if (!json["mappers"].is_array()) + { + return KZ::API::ParseError("map object `mappers` property is not an array"); + } + + for (const auto &rawMapper : json["mappers"]) + { + KZ::API::Player mapper; + + if (auto error = KZ::API::Player::Deserialize(rawMapper, mapper)) + { + return error; + } + + map.mappers.push_back(mapper); + } + } + + if (json.contains("courses")) + { + if (!json["courses"].is_array()) + { + return KZ::API::ParseError("map object `courses` property is not an array"); + } + + for (const auto &rawCourse : json["courses"]) + { + Course course; + + if (auto error = Course::Deserialize(rawCourse, course)) + { + return error; + } + + map.courses.push_back(course); + } + } + + if (!json.contains("created_on")) + { + return KZ::API::ParseError("map object is missing `created_on` property"); + } + + map.createdOn = json["created_on"]; + + return std::nullopt; + } + + std::optional Map::DeserializeGlobalStatus(const json &json, Map::GlobalStatus &globalStatus) + { + if (!json.is_string()) + { + return KZ::API::ParseError("global status is not a string"); + } + + std::string value = json; + + if (value == "not_global") + { + globalStatus = Map::GlobalStatus::NOT_GLOBAL; + } + else if (value == "in_testing") + { + globalStatus = Map::GlobalStatus::IN_TESTING; + } + else if (value == "global") + { + globalStatus = Map::GlobalStatus::GLOBAL; + } + else + { + return KZ::API::ParseError("global status has unknown value"); + } + + return std::nullopt; + } + + std::optional Map::Course::Deserialize(const json &json, Map::Course &course) + { + if (!json.is_object()) + { + return KZ::API::ParseError("map course is not an object"); + } + + if (!json.contains("id")) + { + return KZ::API::ParseError("course object is missing `id` property"); + } + + if (!json["id"].is_number_unsigned()) + { + return KZ::API::ParseError("course object `id` property is not an integer"); + } + + course.id = json["id"]; + + if (json.contains("name")) + { + if (!json["name"].is_string()) + { + return KZ::API::ParseError("course object `name` property is not a string"); + } + + course.name = json["name"]; + } + + if (json.contains("description")) + { + if (!json["description"].is_string()) + { + return KZ::API::ParseError("course object `description` property is not a string"); + } + + course.description = json["description"]; + } + + if (json.contains("mappers")) + { + if (!json["mappers"].is_array()) + { + return KZ::API::ParseError("map object `mappers` property is not an array"); + } + + for (const auto &rawMapper : json["mappers"]) + { + KZ::API::Player mapper; + + if (auto error = KZ::API::Player::Deserialize(rawMapper, mapper)) + { + return error; + } + + course.mappers.push_back(mapper); + } + } + + if (json.contains("filters")) + { + if (!json["filters"].is_array()) + { + return KZ::API::ParseError("map object `filters` property is not an array"); + } + + for (const auto &rawfilter : json["filters"]) + { + Map::Course::Filter filter; + + if (auto error = Map::Course::Filter::Deserialize(rawfilter, filter)) + { + return error; + } + + course.filters.push_back(filter); + } + } + + return std::nullopt; + } + + std::optional Map::Course::Filter::Deserialize(const json &json, Map::Course::Filter &filter) + { + if (!json.is_object()) + { + return KZ::API::ParseError("course filter is not an object"); + } + + if (!json.contains("id")) + { + return KZ::API::ParseError("filter object is missing `id` property"); + } + + if (!json["id"].is_number_unsigned()) + { + return KZ::API::ParseError("filter object `id` property is not an integer"); + } + + filter.id = json["id"]; + + if (!json.contains("mode")) + { + return KZ::API::ParseError("filter object is missing `mode` property"); + } + + if (auto error = DeserializeMode(json["mode"], filter.mode)) + { + return error; + } + + if (!json.contains("tier")) + { + return KZ::API::ParseError("filter object is missing `tier` property"); + } + + if (auto error = Map::Course::Filter::DeserializeTier(json["tier"], filter.tier)) + { + return error; + } + + if (!json.contains("ranked_status")) + { + return KZ::API::ParseError("filter object is missing `ranked_status` property"); + } + + if (auto error = Map::Course::Filter::DeserializeRankedStatus(json["ranked_status"], filter.rankedStatus)) + { + return error; + } + + if (json.contains("notes")) + { + if (!json["notes"].is_string()) + { + return KZ::API::ParseError("filter object `notes` property is not a string"); + } + + filter.notes = json["notes"]; + } + + return std::nullopt; + } + + std::optional Map::Course::Filter::DeserializeRankedStatus(const json &json, Map::Course::Filter::RankedStatus &rankedStatus) + { + if (!json.is_string()) + { + return KZ::API::ParseError("ranked status is not a string"); + } + + std::string value = json; + + if (value == "never") + { + rankedStatus = Map::Course::Filter::RankedStatus::NEVER; + } + else if (value == "unranked") + { + rankedStatus = Map::Course::Filter::RankedStatus::UNRANKED; + } + else if (value == "ranked") + { + rankedStatus = Map::Course::Filter::RankedStatus::RANKED; + } + else + { + return KZ::API::ParseError("ranked status has unknown value"); + } + + return std::nullopt; + } + + std::optional Map::Course::Filter::DeserializeTier(const json &json, Map::Course::Filter::Tier &tier) + { + if (!json.is_string()) + { + return KZ::API::ParseError("tier is not a string"); + } + + std::string value = json; + + if (value == "very_easy") + { + tier = Tier::VERY_EASY; + } + else if (value == "easy") + { + tier = Tier::EASY; + } + else if (value == "medium") + { + tier = Tier::MEDIUM; + } + else if (value == "advanced") + { + tier = Tier::ADVANCED; + } + else if (value == "hard") + { + tier = Tier::HARD; + } + else if (value == "very_hard") + { + tier = Tier::VERY_HARD; + } + else if (value == "extreme") + { + tier = Tier::EXTREME; + } + else if (value == "death") + { + tier = Tier::DEATH; + } + else if (value == "unfeasible") + { + tier = Tier::UNFEASIBLE; + } + else if (value == "impossible") + { + tier = Tier::IMPOSSIBLE; + } + else + { + return KZ::API::ParseError("tier has unknown value"); + } + + return std::nullopt; + } +} // namespace KZ::API diff --git a/src/kz/global/maps.h b/src/kz/global/maps.h new file mode 100644 index 000000000..14c075aef --- /dev/null +++ b/src/kz/global/maps.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include "error.h" +#include "modes.h" +#include "players.h" +#include "utils/json.h" + +namespace KZ::API +{ + /// A KZ map fetched from the API. + struct Map + { + /// The 3 states a map can be in. + enum class GlobalStatus + { + NOT_GLOBAL, + IN_TESTING, + GLOBAL, + }; + + /// A map course. + struct Course + { + /// A course filter. + struct Filter + { + /// The 10 official KZ tiers. + enum class Tier + { + VERY_EASY, + EASY, + MEDIUM, + ADVANCED, + HARD, + VERY_HARD, + EXTREME, + DEATH, + UNFEASIBLE, + IMPOSSIBLE, + }; + + /// The 3 states a filter can be in. + enum class RankedStatus + { + NEVER, + UNRANKED, + RANKED, + }; + + /// Deserializes a `Filter` from a JSON object. + static std::optional Deserialize(const json &json, Filter &filter); + + /// Deserializes a `Tier` from a JSON value. + static std::optional DeserializeTier(const json &json, Tier &tier); + + /// Deserializes a `RankedStatus` from a JSON value. + static std::optional DeserializeRankedStatus(const json &json, RankedStatus &rankedStatus); + + u16 id; + Mode mode; + bool teleports; + Tier tier; + RankedStatus rankedStatus; + std::optional notes {}; + }; + + /// Deserializes a `Course` from a JSON object. + static std::optional Deserialize(const json &json, Course &course); + + u16 id; + std::optional name {}; + std::optional description {}; + std::vector mappers {}; + std::vector filters {}; + }; + + /// Deserializes a `Map` from a JSON object. + static std::optional Deserialize(const json &json, Map &map); + + /// Deserializes a `GlobalStatus` from a JSON value. + static std::optional DeserializeGlobalStatus(const json &json, GlobalStatus &globalStatus); + + u16 id; + std::string name; + std::optional description {}; + GlobalStatus globalStatus; + u32 workshopID; + u32 checksum; + std::vector mappers {}; + std::vector courses {}; + std::string createdOn; + }; +} // namespace KZ::API diff --git a/src/kz/global/modes.cpp b/src/kz/global/modes.cpp new file mode 100644 index 000000000..9ac0ed68e --- /dev/null +++ b/src/kz/global/modes.cpp @@ -0,0 +1,30 @@ +#include "modes.h" +#include + +namespace KZ::API +{ + std::optional DeserializeMode(const json &json, Mode &mode) + { + if (!json.is_string()) + { + return KZ::API::ParseError("mode is not a string"); + } + + std::string value = json; + + if (value == "vanilla") + { + mode = Mode::VANILLA; + } + else if (value == "classic") + { + mode = Mode::CLASSIC; + } + else + { + return KZ::API::ParseError("mode has unknown value"); + } + + return std::nullopt; + } +} // namespace KZ::API diff --git a/src/kz/global/modes.h b/src/kz/global/modes.h new file mode 100644 index 000000000..5fb23b927 --- /dev/null +++ b/src/kz/global/modes.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include "error.h" +#include "utils/json.h" + +namespace KZ::API +{ + /// The two official KZ modes. + enum class Mode + { + VANILLA, + CLASSIC, + }; + + /// Deserializes a `Mode` from a JSON value. + std::optional DeserializeMode(const json &json, Mode &mode); +} // namespace KZ::API diff --git a/src/kz/global/players.cpp b/src/kz/global/players.cpp new file mode 100644 index 000000000..64157bdeb --- /dev/null +++ b/src/kz/global/players.cpp @@ -0,0 +1,167 @@ +#include "players.h" + +namespace KZ::API +{ + std::optional Player::Deserialize(const json &json, Player &player) + { + if (!json.is_object()) + { + return KZ::API::ParseError("player is not an object"); + } + + if (!json.contains("name")) + { + return KZ::API::ParseError("player object is missing `name` property"); + } + + if (!json["name"].is_string()) + { + return KZ::API::ParseError("player object's `name` property is not a string"); + } + + player.name = json["name"]; + + if (!json.contains("steam_id")) + { + return KZ::API::ParseError("player object is missing `steam_id` property"); + } + + if (!json["steam_id"].is_string()) + { + return KZ::API::ParseError("player object's `steam_id` property is not a string"); + } + + player.steamID = json["steam_id"]; + + if (json.contains("is_banned")) + { + if (!json["is_banned"].is_boolean()) + { + return KZ::API::ParseError("player object's `is_banned` property is not a boolean"); + } + + player.isBanned = json["is_banned"]; + } + + return std::nullopt; + } + + json BhopStats::Serialize() const + { + return {{"bhops", bhops}, {"perfs", perfs}}; + } + + json NewPlayer::Serialize() const + { + return {{"name", name}, {"steam_id", steamID}, {"ip_address", ipAddress}}; + } + + json CourseSession::Data::Serialize() const + { + return {{"playtime", playtime}, {"started_runs", startedRuns}, {"finished_runs", finishedRuns}, {"bhop_stats", bhopStats.Serialize()}}; + } + + void CourseSession::UpdatePlaytime(float additionalPlaytime, Mode mode) + { + switch (mode) + { + case Mode::VANILLA: + vanilla.playtime += additionalPlaytime; + break; + + case Mode::CLASSIC: + classic.playtime += additionalPlaytime; + break; + } + } + + json CourseSession::Serialize() const + { + return {{"vanilla", vanilla.Serialize()}, {"classic", classic.Serialize()}}; + } + + float Session::GoActive() + { + currentState = State::ACTIVE; + float delta = UpdateTime(); + secondsActive += delta; + return delta; + } + + float Session::GoAFK() + { + currentState = State::AFK; + float delta = UpdateTime(); + secondsAFK += delta; + return delta; + } + + float Session::GoSpectating() + { + currentState = State::SPECTATING; + float delta = UpdateTime(); + secondsSpectating += delta; + return delta; + } + + float Session::SwitchCourse(u16 courseID, Mode currentMode) + { + currentCourse = courseID; + auto &courseSession = courseSessions[courseID]; + float delta = 0.0f; + + switch (currentState) + { + case State::ACTIVE: + delta = GoActive(); + break; + + case State::AFK: + delta = GoAFK(); + break; + + case State::SPECTATING: + delta = GoSpectating(); + break; + } + + courseSession.UpdatePlaytime(delta, currentMode); + + return delta; + } + + void Session::Jump(bool perf) + { + bhopStats.bhops++; + bhopStats.perfs += perf; + } + + json Session::Serialize() const + { + json serializedCourseSessions = json::object(); + + for (const auto &[courseID, courseSession] : courseSessions) + { + serializedCourseSessions[courseID] = courseSession.Serialize(); + } + + return {{"active", secondsActive}, + {"spectating", secondsSpectating}, + {"afk", secondsAFK}, + {"bhop_stats", bhopStats.Serialize()}, + {"course_sessions", serializedCourseSessions}}; + } + + float Session::UpdateTime() + { + float now = g_pKZUtils->GetServerGlobals()->realtime; + float delta = now - latestTimestamp; + latestTimestamp = now; + return delta; + } + + json PlayerUpdate::Serialize() const + { + return {{"name", name}, {"ip_address", ipAddress}, {"preferences", preferences}, {"session", session.Serialize()}}; + } +} // namespace KZ::API diff --git a/src/kz/global/players.h b/src/kz/global/players.h new file mode 100644 index 000000000..eff476d0e --- /dev/null +++ b/src/kz/global/players.h @@ -0,0 +1,166 @@ +#pragma once + +#include +#include +#include "error.h" +#include "modes.h" +#include "utils/json.h" +#include "utils/utils.h" + +namespace KZ::API +{ + /// A player object returned by the API. + struct Player + { + /// Deserializes a `Player` from a JSON object. + static std::optional Deserialize(const json &json, Player &player); + + /// The player's name. + std::string name; + + /// The player's SteamID. + std::string steamID; + + /// Whether the player is currently banned. + std::optional isBanned; + }; + + /// Request payload for registering new players to the API. + struct NewPlayer + { + /// The player's name. + std::string name; + + /// The player's SteamID. + u64 steamID; + + /// The player's IP address. + std::string ipAddress; + + /// Serializes this player as JSON. + json Serialize() const; + }; + + struct BhopStats + { + u16 bhops = 0; + u16 perfs = 0; + + /// Serializes these stats as JSON. + json Serialize() const; + }; + + class CourseSession + { + public: + /// Updates the playtime for the given mode. + void UpdatePlaytime(float additionalPlaytime, Mode mode); + + /// Serializes this session as JSON. + json Serialize() const; + + private: + struct Data + { + float playtime; + u16 startedRuns; + u16 finishedRuns; + BhopStats bhopStats {}; + + Data() : playtime(0.0f), startedRuns(0), finishedRuns(0) {} + + /// Serializes this data as JSON. + json Serialize() const; + }; + + Data vanilla {}; + Data classic {}; + }; + + class Session + { + public: + // HACK: this ctor is called before the clock initializes, so we can't set a correct `latestTimestamp` right away. + Session() : currentState(State::ACTIVE), secondsActive(0.0f), secondsAFK(0.0f), secondsSpectating(0.0f), latestTimestamp(0) {} + + /// Constructs a new session with a custom starting point in time. + /// + /// This should only be called when a player joins! + Session(float timestamp) + : currentState(State::ACTIVE), secondsActive(0.0f), secondsAFK(0.0f), secondsSpectating(0.0f), latestTimestamp(timestamp) + { + } + + /// Switches the player's state to "active" and updates playtime. + /// + /// # Return + /// + /// Returns the delta between now and the last time we updated something. + float GoActive(); + + /// Switches the player's state to "AFK" and updates playtime. + /// + /// # Return + /// + /// Returns the delta between now and the last time we updated something. + float GoAFK(); + + /// Switches the player's state to "spectating" and updates playtime. + /// + /// # Return + /// + /// Returns the delta between now and the last time we updated something. + float GoSpectating(); + + /// Switches the current course to the given `courseID` and updates stats for the given mode. + /// + /// # Return + /// + /// Returns the delta between now and the last time we updated something. + float SwitchCourse(u16 courseID, Mode currentMode); + + /// Registers that the player jumped. + void Jump(bool perf); + + /// Serializes this session as JSON. + json Serialize() const; + + private: + enum class State + { + ACTIVE, + AFK, + SPECTATING, + }; + + State currentState; + float secondsActive; + float secondsAFK; + float secondsSpectating; + float latestTimestamp; + BhopStats bhopStats {}; + std::optional currentCourse {}; + std::unordered_map courseSessions {}; + + /// Updates `latestTimestamp` and returns the delta between now and the previous `latestTimestamp`. + float UpdateTime(); + }; + + struct PlayerUpdate + { + /// The player's name. + std::string name; + + /// The player's IP address. + std::string ipAddress; + + /// The player's current preferences. + json preferences; + + /// The player's session data. + Session session; + + /// Serializes this update as JSON. + json Serialize() const; + }; +} // namespace KZ::API diff --git a/src/kz/kz.h b/src/kz/kz.h index f4e1ed6b9..ce339b723 100644 --- a/src/kz/kz.h +++ b/src/kz/kz.h @@ -1,8 +1,12 @@ #pragma once +#include + #include "common.h" #include "movement/movement.h" #include "sdk/datatypes.h" +#include "kz/global/error.h" +#include "kz/global/players.h" #define KZ_COLLISION_GROUP_STANDARD COLLISION_GROUP_DEBRIS #define KZ_COLLISION_GROUP_NOTRIGGER LAST_SHARED_COLLISION_GROUP @@ -126,6 +130,9 @@ class KZPlayer : public MovementPlayer void PlayErrorSound(); + std::optional info {}; + KZ::API::Session session; + private: bool hideLegs {}; f64 lastTeleportTime {}; diff --git a/src/kz/kz_misc.cpp b/src/kz/kz_misc.cpp index 8b7296304..a1eb274e8 100644 --- a/src/kz/kz_misc.cpp +++ b/src/kz/kz_misc.cpp @@ -1,3 +1,5 @@ +#include "global/kz_global.h" + #include "common.h" #include "utils/utils.h" #include "kz.h" @@ -108,6 +110,7 @@ void KZ::misc::RegisterCommands() KZNoclipService::RegisterCommands(); KZHUDService::RegisterCommands(); KZLanguageService::RegisterCommands(); + KZGlobalService::RegisterCommands(); KZ::mode::RegisterCommands(); KZ::style::RegisterCommands(); } diff --git a/src/kz/kz_player.cpp b/src/kz/kz_player.cpp index 591531e67..5d8144eef 100644 --- a/src/kz/kz_player.cpp +++ b/src/kz/kz_player.cpp @@ -13,6 +13,8 @@ #include "style/kz_style.h" #include "timer/kz_timer.h" #include "tip/kz_tip.h" +#include "global/kz_global.h" +#include "global/players.h" #include "tier0/memdbgon.h" @@ -426,6 +428,11 @@ void KZPlayer::OnStopTouchGround() this->timerService->OnStopTouchGround(); this->modeService->OnStopTouchGround(); this->styleService->OnStopTouchGround(); + + if (this->jumped) + { + this->session.Jump(this->inPerf); + } } void KZPlayer::OnChangeMoveType(MoveType_t oldMoveType) diff --git a/src/kz/language/kz_language.h b/src/kz/language/kz_language.h index bef06312a..74321d359 100644 --- a/src/kz/language/kz_language.h +++ b/src/kz/language/kz_language.h @@ -3,6 +3,8 @@ #include "../kz.h" #include "../spec/kz_spec.h" +#include "kz/global/error.h" +#include "kz/global/maps.h" class KZLanguageService : public KZBaseService { @@ -160,6 +162,37 @@ class KZLanguageService : public KZBaseService REGISTER_PRINT_SINGLE_FUNCTION(PrintCentre, MESSAGE_CENTRE) REGISTER_PRINT_SINGLE_FUNCTION(PrintAlert, MESSAGE_ALERT) REGISTER_PRINT_SINGLE_FUNCTION(PrintHTMLCentre, MESSAGE_HTML) + + void PrintError(const KZ::API::Error &error) + { + PrintChat(true, false, "API Error", error.status); + PrintConsole(false, false, "API Error Details", error.message.c_str(), error.details.is_null() ? "" : error.details.dump().c_str()); + } + + void PrintMap(const KZ::API::Map &map) + { + std::string sep; + + if (map.description) + { + sep = " | "; + } + + std::string mappersText; + + for (size_t idx = 0; idx < map.mappers.size(); idx++) + { + mappersText += map.mappers[idx].name; + + if (idx != (map.mappers.size() - 1)) + { + mappersText += ", "; + } + } + + PrintChat(true, false, "CurrentMap", map.id, map.name, sep, map.description.value_or("").c_str(), map.workshopID, mappersText); + } + #undef REGISTER_PRINT_SINGLE_FUNCTION #define REGISTER_PRINT_ALL_FUNCTION(name, type) \ diff --git a/src/kz/mode/kz_mode_ckz.cpp b/src/kz/mode/kz_mode_ckz.cpp index f0211c824..95969fd14 100644 --- a/src/kz/mode/kz_mode_ckz.cpp +++ b/src/kz/mode/kz_mode_ckz.cpp @@ -10,7 +10,9 @@ KZClassicModePlugin g_KZClassicModePlugin; CGameConfig *g_pGameConfig = NULL; KZUtils *g_pKZUtils = NULL; KZModeManager *g_pModeManager = NULL; -ModeServiceFactory g_ModeFactory = [](KZPlayer *player) -> KZModeService * { return new KZClassicModeService(player); }; +ModeServiceFactory g_ModeFactory = [](KZPlayer *player) -> KZModeService * { + return new KZClassicModeService(player); +}; PLUGIN_EXPOSE(KZClassicModePlugin, g_KZClassicModePlugin); bool KZClassicModePlugin::Load(PluginId id, ISmmAPI *ismm, char *error, size_t maxlen, bool late) diff --git a/src/kz/mode/kz_mode_manager.cpp b/src/kz/mode/kz_mode_manager.cpp index b7541aaf5..4104c990c 100644 --- a/src/kz/mode/kz_mode_manager.cpp +++ b/src/kz/mode/kz_mode_manager.cpp @@ -41,7 +41,9 @@ void KZ::mode::InitModeManager() { return; } - ModeServiceFactory vnlFactory = [](KZPlayer *player) -> KZModeService * { return new KZVanillaModeService(player); }; + ModeServiceFactory vnlFactory = [](KZPlayer *player) -> KZModeService * { + return new KZVanillaModeService(player); + }; modeManager.RegisterMode(0, "VNL", "Vanilla", vnlFactory); initialized = true; } diff --git a/src/kz/quiet/kz_quiet.cpp b/src/kz/quiet/kz_quiet.cpp index d9a12403a..3460e709f 100644 --- a/src/kz/quiet/kz_quiet.cpp +++ b/src/kz/quiet/kz_quiet.cpp @@ -2,9 +2,8 @@ #include "gameevents.pb.h" #include "cs_gameevents.pb.h" -#include "sdk/services.h" - #include "kz_quiet.h" +#include "sdk/services.h" #include "utils/utils.h" diff --git a/src/kz/style/kz_style_autobhop.cpp b/src/kz/style/kz_style_autobhop.cpp index 08074bbce..4d8037754 100644 --- a/src/kz/style/kz_style_autobhop.cpp +++ b/src/kz/style/kz_style_autobhop.cpp @@ -10,7 +10,9 @@ KZAutoBhopStylePlugin g_KZAutoBhopStylePlugin; CGameConfig *g_pGameConfig = NULL; KZUtils *g_pKZUtils = NULL; KZStyleManager *g_pStyleManager = NULL; -StyleServiceFactory g_StyleFactory = [](KZPlayer *player) -> KZStyleService * { return new KZAutoBhopStyleService(player); }; +StyleServiceFactory g_StyleFactory = [](KZPlayer *player) -> KZStyleService * { + return new KZAutoBhopStyleService(player); +}; PLUGIN_EXPOSE(KZAutoBhopStylePlugin, g_KZAutoBhopStylePlugin); ConVar *sv_autobunnyhopping; diff --git a/src/kz/style/kz_style_manager.cpp b/src/kz/style/kz_style_manager.cpp index d1ef76ed4..b76e4ce1a 100644 --- a/src/kz/style/kz_style_manager.cpp +++ b/src/kz/style/kz_style_manager.cpp @@ -24,7 +24,9 @@ void KZ::style::InitStyleManager() { return; } - StyleServiceFactory vnlFactory = [](KZPlayer *player) -> KZStyleService * { return new KZNormalStyleService(player); }; + StyleServiceFactory vnlFactory = [](KZPlayer *player) -> KZStyleService * { + return new KZNormalStyleService(player); + }; styleManager.RegisterStyle(0, "NRM", "Normal", vnlFactory); initialized = true; } diff --git a/src/player/player.h b/src/player/player.h index ae4d02e95..adc4f443b 100644 --- a/src/player/player.h +++ b/src/player/player.h @@ -1,3 +1,5 @@ +#pragma once + #include "common.h" #include "sdk/serversideclient.h" #include "sdk/datatypes.h" @@ -112,6 +114,7 @@ class Player public: // General const i32 index; + const char *name; private: CSteamID unauthenticatedSteamID = k_steamIDNil; diff --git a/src/player/player_manager.cpp b/src/player/player_manager.cpp index c0c00087d..a1344c673 100644 --- a/src/player/player_manager.cpp +++ b/src/player/player_manager.cpp @@ -1,3 +1,6 @@ +#include "../kz/kz.h" +#include "../kz/language/kz_language.h" +#include "../kz/global/kz_global.h" #include "player.h" #include "utils/utils.h" @@ -76,6 +79,8 @@ Player *PlayerManager::ToPlayer(CPlayerUserId userID) void PlayerManager::OnClientConnect(CPlayerSlot slot, const char *pszName, uint64 xuid, const char *pszNetworkID, bool unk1, CBufferString *pRejectReason) { + Player *player = ToPlayer(slot); + player->name = pszName; } void PlayerManager::OnClientConnected(CPlayerSlot slot, const char *pszName, uint64 xuid, const char *pszNetworkID, const char *pszAddress, @@ -91,11 +96,52 @@ void PlayerManager::OnClientActive(CPlayerSlot slot, bool bLoadGame, const char { this->ToPlayer(slot)->Reset(); this->ToPlayer(slot)->SetUnauthenticatedSteamID(xuid); + + KZPlayer *player = g_pKZPlayerManager->ToPlayer(slot); + + // N.B. we reset the default-constructed session to a session with a valid timestamp + player->session = KZ::API::Session(g_pKZUtils->GetServerGlobals()->realtime); + + auto onSuccess = [player](std::optional playerInfo) { + if (playerInfo) + { + player->languageService->PrintChat(true, false, "Display Hello", playerInfo->name.c_str()); + player->info = playerInfo.value(); + return; + } + + KZGlobalService::RegisterPlayer(player, [player](std::optional error) { + if (error) + { + player->languageService->PrintError(error.value()); + } + }); + }; + + auto onError = [player](KZ::API::Error error) { + player->languageService->PrintError(error); + }; + + KZGlobalService::FetchPlayer(player->GetSteamId64(), onSuccess, onError); } void PlayerManager::OnClientDisconnect(CPlayerSlot slot, ENetworkDisconnectionReason reason, const char *pszName, uint64 xuid, const char *pszNetworkID) { + KZPlayer *player = g_pKZPlayerManager->ToPlayer(slot); + + // flush timestamp + player->session.GoActive(); + + KZGlobalService::UpdatePlayer(player, [player](std::optional error) { + if (error) + { + META_CONPRINTF("[KZ::Global] Failed to send player update: %s\n", error->message.c_str()); + return; + } + + META_CONPRINTF("[KZ::Global] Updated `%s`.\n", player->GetName()); + }); } void PlayerManager::OnClientVoice(CPlayerSlot slot) {} diff --git a/src/utils/hooks.cpp b/src/utils/hooks.cpp index 8c6c7d2a5..dd23951bd 100644 --- a/src/utils/hooks.cpp +++ b/src/utils/hooks.cpp @@ -1,3 +1,5 @@ +#include "kz/global/kz_global.h" + #include "hooks.h" #include "addresses.h" #include "bufferstring.h" @@ -8,10 +10,10 @@ #include "cs2kz.h" #include "ctimer.h" #include "kz/jumpstats/kz_jumpstats.h" +#include "entityclass.h" #include "kz/quiet/kz_quiet.h" #include "kz/timer/kz_timer.h" #include "utils/utils.h" -#include "entityclass.h" class GameSessionConfiguration_t { @@ -751,6 +753,30 @@ static_function bool Hook_ActivateServer() } } + CNetworkGameServerBase *networkGameServer = (CNetworkGameServerBase *)g_pNetworkServerService->GetIGameServer(); + + if (networkGameServer != nullptr) + { + auto onSuccess = [](std::optional map) { + if (!map) + { + META_CONPRINTF("[KZ::Global] Current map is not global.\n"); + } + else + { + META_CONPRINTF("[KZ::Global] Fetched %s from the API.\n", map->name.c_str()); + } + + KZGlobalService::currentMap = map; + }; + + auto onError = [](KZ::API::Error error) { + META_CONPRINTF("[KZ::Global] Failed to fetch map from API: %s\n", error.message.c_str()); + }; + + KZGlobalService::FetchMap(networkGameServer->GetMapName(), onSuccess, onError); + } + interfaces::pEngine->ServerCommand("exec cs2kz.cfg"); RETURN_META_VALUE(MRES_IGNORED, 1); } diff --git a/src/utils/http.cpp b/src/utils/http.cpp new file mode 100644 index 000000000..b0b75f75d --- /dev/null +++ b/src/utils/http.cpp @@ -0,0 +1,150 @@ +#include "http.h" + +namespace HTTP +{ + CSteamGameServerAPIContext g_steamAPI; + ISteamHTTP *g_pHTTP = nullptr; + std::vector g_InFlightRequests; + + void Request::SetQuery(std::string key, std::string value) + { + url += hasQueryParams ? "&" : "?"; + url += key; + url += "="; + url += value; + hasQueryParams = true; + } + + void Request::SetHeader(std::string name, std::string value) + { + headers[name] = value; + } + + void Request::SetBody(std::string body) + { + this->body = body; + } + + void Request::Send(ResponseCallback onResponse) const + { + if (!g_pHTTP) + { + META_CONPRINTF("[KZ::HTTP] Initializing HTTP client...\n"); + g_steamAPI.Init(); + g_pHTTP = g_steamAPI.SteamHTTP(); + } + + EHTTPMethod volvoMethod; + + switch (method) + { + case Method::GET: + volvoMethod = k_EHTTPMethodGET; + break; + case Method::POST: + volvoMethod = k_EHTTPMethodPOST; + break; + case Method::PUT: + volvoMethod = k_EHTTPMethodPUT; + break; + case Method::PATCH: + volvoMethod = k_EHTTPMethodPATCH; + break; + } + + auto handle = g_pHTTP->CreateHTTPRequest(volvoMethod, url.c_str()); + + if (method >= Method::POST) + { + if (!g_pHTTP->SetHTTPRequestRawPostBody(handle, "application/json", (u8 *)body.data(), body.size())) + { + META_CONPRINTF("[KZ::HTTP] Failed to set request body.\n"); + return; + } + } + + for (const auto &[name, value] : headers) + { + g_pHTTP->SetHTTPRequestHeaderValue(handle, name.c_str(), value.c_str()); + } + + SteamAPICall_t steamCallHandle; + + if (!g_pHTTP->SendHTTPRequest(handle, &steamCallHandle)) + { + META_CONPRINTF("[KZ::HTTP] Failed to send HTTP request.\n"); + } + + new InFlightRequest(handle, steamCallHandle, url, body, onResponse); + } + + std::optional Response::Header(const char *name) const + { + u32 headerValueSize; + + if (!g_pHTTP->GetHTTPResponseHeaderSize(requestHandle, name, &headerValueSize)) + { + return std::nullopt; + } + + u8 *rawHeaderValue = new u8[headerValueSize + 1]; + + if (!g_pHTTP->GetHTTPResponseHeaderValue(requestHandle, name, rawHeaderValue, headerValueSize)) + { + delete[] rawHeaderValue; + return std::nullopt; + } + + rawHeaderValue[headerValueSize] = '\0'; + + std::string headerValue = std::string((char *)rawHeaderValue); + delete[] rawHeaderValue; + + return headerValue; + } + + std::optional Response::Body() const + { + u32 responseBodySize; + + if (!g_pHTTP->GetHTTPResponseBodySize(requestHandle, &responseBodySize)) + { + return std::nullopt; + } + + u8 *rawResponseBody = new u8[responseBodySize + 1]; + + if (!g_pHTTP->GetHTTPResponseBodyData(requestHandle, rawResponseBody, responseBodySize)) + { + delete[] rawResponseBody; + return std::nullopt; + } + + rawResponseBody[responseBodySize] = '\0'; + + std::string responseBody = std::string((char *)rawResponseBody); + delete[] rawResponseBody; + + return responseBody; + } + + void InFlightRequest::OnRequestCompleted(HTTPRequestCompleted_t *completedRequest, bool failed) + { + if (failed) + { + META_CONPRINTF("[KZ::HTTP] request to `%s` failed with code %d\n", url.c_str(), completedRequest->m_eStatusCode); + delete this; + return; + } + + Response response(completedRequest->m_eStatusCode, completedRequest->m_hRequest); + onResponse(response); + + if (g_pHTTP) + { + g_pHTTP->ReleaseHTTPRequest(completedRequest->m_hRequest); + } + + delete this; + } +} // namespace HTTP diff --git a/src/utils/http.h b/src/utils/http.h new file mode 100644 index 000000000..90602d050 --- /dev/null +++ b/src/utils/http.h @@ -0,0 +1,114 @@ +#pragma once + +#undef snprintf +#include + +#include +#include +#include +#include +#include + +#include "common.h" + +namespace HTTP +{ + enum class Method; + class Request; + class Response; + class InFlightRequest; + + typedef std::unordered_map HeaderMap; + typedef std::unordered_map QueryParameters; + typedef std::function ResponseCallback; + + extern std::vector g_InFlightRequests; + + /// HTTP methods we care about. + enum class Method + { + GET, + POST, + PUT, + PATCH, + }; + + /// An HTTP request. + class Request + { + public: + Request(Method method, std::string url) : method(method), url(url) {} + + /// Set a query parameter. + void SetQuery(std::string key, std::string value); + + /// Set a header. + void SetHeader(std::string name, std::string value); + + /// Set the request body. + void SetBody(std::string body); + + /// Send the request. + void Send(ResponseCallback onResponse) const; + + private: + Method method; + std::string url {}; + bool hasQueryParams {}; + HeaderMap headers {}; + std::string body {}; + }; + + /// An HTTP response. + class Response + { + public: + u16 status; + + Response(u16 status, HTTPRequestHandle requestHandle) : status(status), requestHandle(requestHandle) {} + + /// Retrieves a header if it exists. + std::optional Header(const char *name) const; + + /// Extracts the body if it exists. + std::optional Body() const; + + private: + HTTPRequestHandle requestHandle; + }; + + class InFlightRequest + { + public: + InFlightRequest(const InFlightRequest &req) = delete; + + InFlightRequest(HTTPRequestHandle handle, SteamAPICall_t steamCallHandle, std::string url, std::string body, ResponseCallback onResponse) + : url(url), body(body), handle(handle), onResponse(onResponse) + { + callResult.SetGameserverFlag(); + callResult.Set(steamCallHandle, this, &InFlightRequest::OnRequestCompleted); + g_InFlightRequests.push_back(this); + } + + ~InFlightRequest() + { + for (auto req = g_InFlightRequests.begin(); req != g_InFlightRequests.end(); req++) + { + if (*req == this) + { + g_InFlightRequests.erase(req); + break; + } + } + } + + private: + std::string url; + std::string body; + HTTPRequestHandle handle; + CCallResult callResult; + ResponseCallback onResponse; + + void OnRequestCompleted(HTTPRequestCompleted_t *completedRequest, bool failed); + }; +} // namespace HTTP diff --git a/src/utils/json.h b/src/utils/json.h new file mode 100644 index 000000000..d1cbebf56 --- /dev/null +++ b/src/utils/json.h @@ -0,0 +1,4 @@ +#include "vendor/json/single_include/nlohmann/json.hpp" +#include "vendor/json/single_include/nlohmann/json_fwd.hpp" + +using json = nlohmann::json; diff --git a/src/utils/simplecmds.cpp b/src/utils/simplecmds.cpp index 49112ddc5..9eec7797f 100644 --- a/src/utils/simplecmds.cpp +++ b/src/utils/simplecmds.cpp @@ -1,8 +1,8 @@ +#include "../kz/kz.h" #include "common.h" #include "utils/utils.h" #include "simplecmds.h" -#include "../kz/kz.h" #include "../kz/language/kz_language.h" #include "../kz/option/kz_option.h" diff --git a/src/version.h b/src/version.h index 38a6c04e7..34fa21962 100644 --- a/src/version.h +++ b/src/version.h @@ -1 +1 @@ -#define VERSION_STRING "dev" +#define VERSION_STRING "v0.0.1" diff --git a/translations/cs2kz-commands.phrases.txt b/translations/cs2kz-commands.phrases.txt index e36ce19a7..46c056710 100644 --- a/translations/cs2kz-commands.phrases.txt +++ b/translations/cs2kz-commands.phrases.txt @@ -369,4 +369,16 @@ "sv" "Byt till Classic mode." "ua" "Переключитися на режим Classic." } + "Command Description - kz_profile" + { + "en" "Display player stats." + } + "Command Description - kz_mapinfo" + { + "en" "Display information about the current map." + } + "Command Description - kz_minfo" + { + "en" "Alias for kz_mapinfo" + } } diff --git a/translations/cs2kz-global.phrases.txt b/translations/cs2kz-global.phrases.txt new file mode 100644 index 000000000..756eaa451 --- /dev/null +++ b/translations/cs2kz-global.phrases.txt @@ -0,0 +1,49 @@ +"Phrases" +{ + "API Error" + { + "#format" "status:d" + "en" "{red}API request failed with code {default}{status}{red}. Please check your console for details." + } + "API Error Details" + { + "#format" "message:s,details:s" + "en" "API Error: {message}{details}" + } + "Display Hello" + { + "#format" "name:s" + "en" "{grey}Hello, {default}{name}{grey}! You have been authenticated with the API." + } + "Display PlayerInfo" + { + "#format" "name:s,steamID:s,banned:s" + "en" "{default}{name}{grey} is currently {default}{banned}{grey}banned. Their SteamID is {default}{steamID}{grey}." + } + "Player not found" + { + "en" "{grey}Player not found." + } + "Player not found after registration" + { + "en" "{red}Failed to register you with the API. This is a bug! Please report it." + } + "View Preferences Response" + { + "en" "{grey}Open your console." + } + "Display Raw Preferences" + { + "#format" "preferences:s" + "en" "Preferences:\n{preferences}" + } + "MapNotGlobal" + { + "en" "{grey}Map is not global." + } + "CurrentMap" + { + "#format" "id:d,name:s,sep:s,description:s,workshopID:d,mappers:s" + "en" "{name} {grey}{default}{sep}{description}\n{grey}⠀⠀• ID: {default}{id}\n{grey}⠀⠀• Workshop ID: {default}{workshopID}\n{grey}⠀⠀• Mapper(s): {default}{mappers}" + } +} diff --git a/vendor/json b/vendor/json new file mode 160000 index 000000000..8c391e04f --- /dev/null +++ b/vendor/json @@ -0,0 +1 @@ +Subproject commit 8c391e04fe4195d8be862c97f38cfe10e2a3472e