diff --git a/.github/workflows/unreal-tests.yml b/.github/workflows/unreal-tests.yml new file mode 100644 index 00000000..1dc56c18 --- /dev/null +++ b/.github/workflows/unreal-tests.yml @@ -0,0 +1,25 @@ +name: Unreal GSDK Tests + +on: + pull_request: + paths: + - 'UnrealPlugin/**' + - '.github/workflows/unreal-tests.yml' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure CMake + run: cmake -S UnrealPlugin/Tests -B UnrealPlugin/Tests/build + + - name: Build + run: cmake --build UnrealPlugin/Tests/build + + - name: Test + run: ctest --test-dir UnrealPlugin/Tests/build --output-on-failure diff --git a/UnrealPlugin/README.md b/UnrealPlugin/README.md index b70fe0c5..efe4ee62 100644 --- a/UnrealPlugin/README.md +++ b/UnrealPlugin/README.md @@ -9,6 +9,16 @@ This plugin offers both a Blueprint API and a C++ API. The Blueprint API still r The sample game these instructions were created with was called ThirdPersonMP, so replace anywhere you see that with your game name. +## Plugin files + +When integrating the GSDK plugin into your Unreal project, you only need to copy the following into your project's `Plugins/PlayFabGSDK` folder: + +* `Source/` — Plugin source code +* `Resources/` — Plugin resources +* `PlayFabGSDK.uplugin` — Plugin descriptor + +> **Note:** The `Tests/`, `TestingProject/`, and `Documentation/` directories, along with the markdown and license files in this folder, are used for GSDK development and CI only. They are **not needed** when using the plugin in your game project and should not be copied. + ## Requirements * Download Visual Studio. The [community version](https://visualstudio.microsoft.com/vs/community/) is free. diff --git a/UnrealPlugin/Tests/CMakeLists.txt b/UnrealPlugin/Tests/CMakeLists.txt new file mode 100644 index 00000000..76636e0c --- /dev/null +++ b/UnrealPlugin/Tests/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.14) +project(UnrealGSDKTests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) + +FetchContent_Declare( + json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) + +# For Windows: Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(googletest json) + +enable_testing() + +add_executable(unreal_gsdk_tests + GSDKConfigTests.cpp + GSDKHeartbeatTests.cpp +) + +target_link_libraries(unreal_gsdk_tests + GTest::gtest_main + nlohmann_json::nlohmann_json +) + +include(GoogleTest) +gtest_discover_tests(unreal_gsdk_tests) diff --git a/UnrealPlugin/Tests/GSDKConfigTests.cpp b/UnrealPlugin/Tests/GSDKConfigTests.cpp new file mode 100644 index 00000000..6b58d9a0 --- /dev/null +++ b/UnrealPlugin/Tests/GSDKConfigTests.cpp @@ -0,0 +1,339 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +// Standalone unit tests for the Unreal GSDK configuration JSON format. +// These tests validate the JSON contracts used by FJsonFileConfiguration +// and TestConfiguration without requiring Unreal Engine. + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + +// Mirrors TestConfiguration::SerializeToFile +// Creates a JSON config in the format expected by FJsonFileConfiguration +json SerializeConfig( + const std::string& heartbeatEndpoint, + const std::string& serverId, + const std::string& logFolder, + const std::string& sharedContentFolder, + const std::string& certFolder = "", + const std::map& gameCerts = {}, + const std::map& metadata = {}, + const std::map& ports = {}, + const std::string& publicIpV4Address = "", + const std::string& fullyQualifiedDomainName = "", + const std::string& vmId = "") +{ + json config; + config["heartbeatEndpoint"] = heartbeatEndpoint; + config["sessionHostId"] = serverId; + config["logFolder"] = logFolder; + config["sharedContentFolder"] = sharedContentFolder; + config["certificateFolder"] = certFolder; + config["publicIpV4Address"] = publicIpV4Address; + config["fullyQualifiedDomainName"] = fullyQualifiedDomainName; + config["vmId"] = vmId; + + config["gameCertificates"] = json::object(); + for (const auto& [key, value] : gameCerts) + { + config["gameCertificates"][key] = value; + } + + config["buildMetadata"] = json::object(); + for (const auto& [key, value] : metadata) + { + config["buildMetadata"][key] = value; + } + + config["gamePorts"] = json::object(); + for (const auto& [key, value] : ports) + { + config["gamePorts"][key] = value; + } + + config["gameServerConnectionInfo"] = { + {"publicIpV4Adress", publicIpV4Address}, // Note: matches typo in UE code + {"gamePortsConfiguration", json::array()} + }; + + return config; +} + +// Mirrors FJsonFileConfiguration constructor +// Parses configuration from JSON in the same way the Unreal GSDK does +struct ParsedConfig +{ + std::string heartbeatEndpoint; + std::string serverId; + std::string logFolder; + std::string sharedContentFolder; + std::string certFolder; + std::map gameCerts; + std::map metadata; + std::map ports; + std::string publicIpV4Address; + std::string fullyQualifiedDomainName; + std::string vmId; + + struct GamePort + { + std::string name; + int serverListeningPort = 0; + int clientConnectionPort = 0; + }; + + struct ConnectionInfo + { + std::string publicIpV4Address; + std::vector gamePortsConfiguration; + }; + + ConnectionInfo connectionInfo; +}; + +ParsedConfig ParseConfig(const json& config) +{ + ParsedConfig parsed; + + parsed.heartbeatEndpoint = config.value("heartbeatEndpoint", ""); + parsed.serverId = config.value("sessionHostId", ""); + parsed.logFolder = config.value("logFolder", ""); + parsed.sharedContentFolder = config.value("sharedContentFolder", ""); + + if (config.contains("certificateFolder")) + { + parsed.certFolder = config["certificateFolder"].get(); + } + + if (config.contains("gameCertificates")) + { + for (const auto& [key, value] : config["gameCertificates"].items()) + { + parsed.gameCerts[key] = value.get(); + } + } + + if (config.contains("buildMetadata")) + { + for (const auto& [key, value] : config["buildMetadata"].items()) + { + parsed.metadata[key] = value.get(); + } + } + + if (config.contains("gamePorts")) + { + for (const auto& [key, value] : config["gamePorts"].items()) + { + parsed.ports[key] = value.get(); + } + } + + if (config.contains("publicIpV4Address")) + { + parsed.publicIpV4Address = config["publicIpV4Address"].get(); + } + + if (config.contains("fullyQualifiedDomainName")) + { + parsed.fullyQualifiedDomainName = config["fullyQualifiedDomainName"].get(); + } + + if (config.contains("vmId")) + { + parsed.vmId = config["vmId"].get(); + } + + if (config.contains("gameServerConnectionInfo")) + { + auto connInfoJson = config["gameServerConnectionInfo"]; + parsed.connectionInfo.publicIpV4Address = connInfoJson.value("publicIpV4Adress", ""); // Note: matches typo + + if (connInfoJson.contains("gamePortsConfiguration")) + { + for (const auto& portJson : connInfoJson["gamePortsConfiguration"]) + { + ParsedConfig::GamePort port; + port.name = portJson.value("name", ""); + port.serverListeningPort = portJson.value("serverListeningPort", 0); + port.clientConnectionPort = portJson.value("clientConnectionPort", 0); + parsed.connectionInfo.gamePortsConfiguration.push_back(port); + } + } + } + + return parsed; +} + +} // anonymous namespace + +class ConfigurationTest : public ::testing::Test +{ +protected: + static constexpr const char* TEST_DIR = "/tmp/gsdk_config_tests"; + std::string configFilePath; + + void SetUp() override + { + std::filesystem::create_directories(TEST_DIR); + configFilePath = std::string(TEST_DIR) + "/testConfig.json"; + } + + void TearDown() override + { + if (std::filesystem::exists(configFilePath)) + { + std::filesystem::remove(configFilePath); + } + } + + void WriteConfigToFile(const json& config) + { + std::ofstream file(configFilePath); + file << config.dump(4); + } + + json ReadConfigFromFile() + { + std::ifstream file(configFilePath); + return json::parse(file); + } +}; + +// Mirrors: ConfigAllSetInitializesFine +TEST_F(ConfigurationTest, ConfigAllSetParsesCorrectly) +{ + std::map gameCerts = { + {"cert1", "thumbprint1"}, + {"cert2", "thumbprint2"} + }; + std::map metadata = { + {"key1", "value1"}, + {"key2", "value2"} + }; + std::map ports = { + {"port1", "1111"}, + {"port2", "2222"} + }; + + json config = SerializeConfig( + "testEndpoint", "testServerId", "testLogFolder", "testSharedContentFolder", + "testCertFolder", gameCerts, metadata, ports + ); + + WriteConfigToFile(config); + json readback = ReadConfigFromFile(); + ParsedConfig parsed = ParseConfig(readback); + + EXPECT_EQ(parsed.heartbeatEndpoint, "testEndpoint"); + EXPECT_EQ(parsed.serverId, "testServerId"); + EXPECT_EQ(parsed.logFolder, "testLogFolder"); + EXPECT_EQ(parsed.sharedContentFolder, "testSharedContentFolder"); + EXPECT_EQ(parsed.certFolder, "testCertFolder"); + EXPECT_EQ(parsed.gameCerts["cert1"], "thumbprint1"); + EXPECT_EQ(parsed.gameCerts["cert2"], "thumbprint2"); + EXPECT_EQ(parsed.metadata["key1"], "value1"); + EXPECT_EQ(parsed.metadata["key2"], "value2"); + EXPECT_EQ(parsed.ports["port1"], "1111"); + EXPECT_EQ(parsed.ports["port2"], "2222"); +} + +// Mirrors: LogFolderNotSetInitializesFine +TEST_F(ConfigurationTest, EmptyLogFolderParsesCorrectly) +{ + json config = SerializeConfig( + "testEndpoint", "testServerId", "", "testSharedContentFolder" + ); + + ParsedConfig parsed = ParseConfig(config); + + EXPECT_EQ(parsed.logFolder, ""); +} + +// Mirrors: SharedContentFolderNotSetInitializesFine +TEST_F(ConfigurationTest, EmptySharedContentFolderParsesCorrectly) +{ + json config = SerializeConfig( + "testEndpoint", "testServerId", "testLogFolder", "" + ); + + ParsedConfig parsed = ParseConfig(config); + + EXPECT_EQ(parsed.sharedContentFolder, ""); +} + +TEST_F(ConfigurationTest, MissingOptionalFieldsDefaultToEmpty) +{ + json config; + config["heartbeatEndpoint"] = "testEndpoint"; + config["sessionHostId"] = "testServerId"; + config["logFolder"] = "testLogFolder"; + config["sharedContentFolder"] = "testSharedContentFolder"; + + ParsedConfig parsed = ParseConfig(config); + + EXPECT_EQ(parsed.heartbeatEndpoint, "testEndpoint"); + EXPECT_EQ(parsed.serverId, "testServerId"); + EXPECT_TRUE(parsed.certFolder.empty()); + EXPECT_TRUE(parsed.gameCerts.empty()); + EXPECT_TRUE(parsed.metadata.empty()); + EXPECT_TRUE(parsed.ports.empty()); + EXPECT_TRUE(parsed.publicIpV4Address.empty()); + EXPECT_TRUE(parsed.fullyQualifiedDomainName.empty()); + EXPECT_TRUE(parsed.vmId.empty()); +} + +TEST_F(ConfigurationTest, ConnectionInfoParsesCorrectly) +{ + json config = SerializeConfig( + "testEndpoint", "testServerId", "testLogFolder", "testSharedContentFolder" + ); + + config["gameServerConnectionInfo"] = { + {"publicIpV4Adress", "1.2.3.4"}, // Note: matches typo in UE code + {"gamePortsConfiguration", { + {{"name", "gameport"}, {"serverListeningPort", 7777}, {"clientConnectionPort", 30000}}, + {{"name", "debugport"}, {"serverListeningPort", 8888}, {"clientConnectionPort", 30001}} + }} + }; + + ParsedConfig parsed = ParseConfig(config); + + EXPECT_EQ(parsed.connectionInfo.publicIpV4Address, "1.2.3.4"); + ASSERT_EQ(parsed.connectionInfo.gamePortsConfiguration.size(), 2u); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[0].name, "gameport"); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[0].serverListeningPort, 7777); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[0].clientConnectionPort, 30000); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[1].name, "debugport"); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[1].serverListeningPort, 8888); + EXPECT_EQ(parsed.connectionInfo.gamePortsConfiguration[1].clientConnectionPort, 30001); +} + +TEST_F(ConfigurationTest, ConfigRoundTripsThroughFile) +{ + json config = SerializeConfig( + "testEndpoint", "testServerId", "testLogFolder", "testSharedContentFolder", + "testCertFolder", {{"cert1", "thumbprint1"}}, {{"key1", "value1"}}, {{"port1", "1111"}} + ); + + WriteConfigToFile(config); + json readback = ReadConfigFromFile(); + ParsedConfig parsed = ParseConfig(readback); + + EXPECT_EQ(parsed.heartbeatEndpoint, "testEndpoint"); + EXPECT_EQ(parsed.serverId, "testServerId"); + EXPECT_EQ(parsed.logFolder, "testLogFolder"); + EXPECT_EQ(parsed.sharedContentFolder, "testSharedContentFolder"); + EXPECT_EQ(parsed.certFolder, "testCertFolder"); + EXPECT_EQ(parsed.gameCerts["cert1"], "thumbprint1"); + EXPECT_EQ(parsed.metadata["key1"], "value1"); + EXPECT_EQ(parsed.ports["port1"], "1111"); +} diff --git a/UnrealPlugin/Tests/GSDKHeartbeatTests.cpp b/UnrealPlugin/Tests/GSDKHeartbeatTests.cpp new file mode 100644 index 00000000..2464deaa --- /dev/null +++ b/UnrealPlugin/Tests/GSDKHeartbeatTests.cpp @@ -0,0 +1,638 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +// Standalone unit tests for the Unreal GSDK heartbeat request/response JSON format. +// These tests validate the JSON contracts used by FGSDKInternal::EncodeHeartbeatRequest +// and FGSDKInternal::DecodeHeartbeatResponse without requiring Unreal Engine. + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + +// Game states matching GSDKInternal.h +enum class GameState +{ + Invalid, + Initializing, + StandingBy, + Active, + Terminating, + Terminated, + Quarantined +}; + +const char* GameStateNames[] = { + "Invalid", + "Initializing", + "StandingBy", + "Active", + "Terminating", + "Terminated", + "Quarantined" +}; + +// Operations matching GSDKInternal.h +enum class Operation +{ + Invalid, + Continue, + GetManifest, + Quarantine, + Active, + Terminate, + Operation_Count +}; + +// Initialize operation map matching the Unreal GSDK logic +std::map InitializeOperationMap() +{ + std::map map; + map["Invalid"] = Operation::Invalid; + map["Continue"] = Operation::Continue; + map["GetManifest"] = Operation::GetManifest; + map["Quarantine"] = Operation::Quarantine; + map["Active"] = Operation::Active; + map["Terminate"] = Operation::Terminate; + map["Operation_Count"] = Operation::Operation_Count; + return map; +} + +// Heartbeat request matching FHeartbeatRequest +struct HeartbeatRequest +{ + GameState currentGameState = GameState::Initializing; + bool isGameHealthy = true; + std::vector connectedPlayerIds; +}; + +// Mirrors FGSDKInternal::EncodeHeartbeatRequest +json EncodeHeartbeatRequest(const HeartbeatRequest& request, + std::function healthCheck = nullptr) +{ + json j; + + j["CurrentGameState"] = GameStateNames[static_cast(request.currentGameState)]; + + bool isHealthy = request.isGameHealthy; + if (healthCheck) + { + isHealthy = healthCheck(); + } + j["CurrentGameHealth"] = isHealthy ? "Healthy" : "Unhealthy"; + + j["CurrentPlayers"] = json::array(); + for (const auto& playerId : request.connectedPlayerIds) + { + json player; + player["PlayerId"] = playerId; + j["CurrentPlayers"].push_back(player); + } + + return j; +} + +// Maintenance event matching FMaintenanceEvent +struct MaintenanceEvent +{ + std::string eventId; + std::string eventType; + std::string resourceType; + std::vector resources; + std::string eventStatus; + std::string notBefore; + std::string description; + std::string eventSource; + int durationInSeconds = 0; +}; + +// Maintenance schedule matching FMaintenanceSchedule +struct MaintenanceSchedule +{ + std::string documentIncarnation; + std::vector events; +}; + +// Parsed heartbeat response +struct ParsedHeartbeatResponse +{ + std::string operation; + std::string sessionId; + std::string sessionCookie; + std::vector initialPlayers; + std::map configSettings; + std::string maintenanceUtc; + MaintenanceSchedule maintenanceSchedule; + int nextHeartbeatIntervalMs = 0; + bool hasMaintenanceV1 = false; + bool hasMaintenanceV2 = false; + bool operationParseError = false; +}; + +// Mirrors FGSDKInternal::DecodeHeartbeatResponse +ParsedHeartbeatResponse DecodeHeartbeatResponse(const std::string& responseJson) +{ + ParsedHeartbeatResponse result; + + json j; + try + { + j = json::parse(responseJson); + } + catch (...) + { + result.operationParseError = true; + return result; + } + + // Parse session config + if (j.contains("sessionConfig")) + { + auto sessionConfig = j["sessionConfig"]; + + // Parse initial players (only first time) + if (result.initialPlayers.empty() && sessionConfig.contains("initialPlayers")) + { + for (const auto& player : sessionConfig["initialPlayers"]) + { + result.initialPlayers.push_back(player.get()); + } + } + + // Parse string values from session config into config settings + for (const auto& [key, value] : sessionConfig.items()) + { + if (value.is_string()) + { + result.configSettings[key] = value.get(); + } + } + + // Parse metadata + if (sessionConfig.contains("metadata")) + { + for (const auto& [key, value] : sessionConfig["metadata"].items()) + { + if (value.is_string()) + { + result.configSettings[key] = value.get(); + } + } + } + } + + // Extract common session config values + if (result.configSettings.count("sessionId")) + { + result.sessionId = result.configSettings["sessionId"]; + } + if (result.configSettings.count("sessionCookie")) + { + result.sessionCookie = result.configSettings["sessionCookie"]; + } + + // Parse maintenance V1 + if (j.contains("nextScheduledMaintenanceUtc")) + { + result.hasMaintenanceV1 = true; + result.maintenanceUtc = j["nextScheduledMaintenanceUtc"].get(); + } + + // Parse maintenance V2 + if (j.contains("maintenanceSchedule")) + { + result.hasMaintenanceV2 = true; + auto scheduleJson = j["maintenanceSchedule"]; + result.maintenanceSchedule.documentIncarnation = + scheduleJson["DocumentIncarnation"].get(); + + for (const auto& eventJson : scheduleJson["Events"]) + { + MaintenanceEvent event; + event.eventId = eventJson["EventId"].get(); + event.eventType = eventJson["EventType"].get(); + event.resourceType = eventJson["ResourceType"].get(); + for (const auto& resource : eventJson["Resources"]) + { + event.resources.push_back(resource.get()); + } + event.eventStatus = eventJson["EventStatus"].get(); + event.notBefore = eventJson["NotBefore"].get(); + event.description = eventJson["Description"].get(); + event.eventSource = eventJson["EventSource"].get(); + event.durationInSeconds = eventJson["DurationInSeconds"].get(); + result.maintenanceSchedule.events.push_back(event); + } + } + + // Parse operation + if (j.contains("operation")) + { + try + { + result.operation = j["operation"].get(); + } + catch (...) + { + result.operationParseError = true; + } + } + + // Parse heartbeat interval + if (j.contains("nextHeartbeatIntervalMs")) + { + result.nextHeartbeatIntervalMs = j["nextHeartbeatIntervalMs"].get(); + } + + return result; +} + +// Operation resolver matching the Unreal GSDK logic +struct OperationResult +{ + GameState newState; + bool shutdownCalled = false; + bool serverActiveCalled = false; + bool wasValid = true; + bool wasError = false; +}; + +OperationResult ResolveOperation( + const std::string& operationStr, + GameState currentState, + const std::map& operationMap) +{ + OperationResult result; + result.newState = currentState; + + if (operationMap.find(operationStr) == operationMap.end()) + { + result.wasError = true; + return result; + } + + Operation nextOperation = operationMap.at(operationStr); + + switch (nextOperation) + { + case Operation::Continue: + break; + case Operation::Active: + if (currentState != GameState::Active) + { + result.newState = GameState::Active; + result.serverActiveCalled = true; + } + break; + case Operation::Terminate: + if (currentState != GameState::Terminating) + { + result.newState = GameState::Terminating; + result.shutdownCalled = true; + } + break; + default: + result.wasValid = false; + break; + } + + return result; +} + +} // anonymous namespace + +// Heartbeat Request Tests + +class HeartbeatRequestTest : public ::testing::Test {}; + +// Mirrors: EncodeGameStateAsValidJson (initial state) +TEST_F(HeartbeatRequestTest, EncodeInitialState) +{ + HeartbeatRequest request; // Default: Initializing, healthy, no players + + json encoded = EncodeHeartbeatRequest(request); + + EXPECT_EQ(encoded["CurrentGameState"], "Initializing"); + EXPECT_EQ(encoded["CurrentGameHealth"], "Healthy"); + EXPECT_TRUE(encoded["CurrentPlayers"].empty()); +} + +// Mirrors: EncodeGameStateAsValidJson (with health callback and players) +TEST_F(HeartbeatRequestTest, EncodeWithStateChangeAndHealthCallback) +{ + HeartbeatRequest request; + request.currentGameState = GameState::StandingBy; + request.connectedPlayerIds = {"player1", "player2"}; + + auto healthCheck = []() -> bool { return false; }; + + json encoded = EncodeHeartbeatRequest(request, healthCheck); + + EXPECT_EQ(encoded["CurrentGameState"], "StandingBy"); + EXPECT_EQ(encoded["CurrentGameHealth"], "Unhealthy"); + ASSERT_EQ(encoded["CurrentPlayers"].size(), 2u); + EXPECT_EQ(encoded["CurrentPlayers"][0]["PlayerId"], "player1"); + EXPECT_EQ(encoded["CurrentPlayers"][1]["PlayerId"], "player2"); +} + +TEST_F(HeartbeatRequestTest, EncodeDefaultHealthWithoutCallback) +{ + HeartbeatRequest request; + + json encoded = EncodeHeartbeatRequest(request); + + EXPECT_EQ(encoded["CurrentGameHealth"], "Healthy"); +} + +TEST_F(HeartbeatRequestTest, EncodeAllGameStates) +{ + const std::vector> states = { + {GameState::Invalid, "Invalid"}, + {GameState::Initializing, "Initializing"}, + {GameState::StandingBy, "StandingBy"}, + {GameState::Active, "Active"}, + {GameState::Terminating, "Terminating"}, + {GameState::Terminated, "Terminated"}, + {GameState::Quarantined, "Quarantined"} + }; + + for (const auto& [state, name] : states) + { + HeartbeatRequest request; + request.currentGameState = state; + + json encoded = EncodeHeartbeatRequest(request); + + EXPECT_EQ(encoded["CurrentGameState"], name) << "Failed for state: " << name; + } +} + +// Heartbeat Response Tests + +class HeartbeatResponseTest : public ::testing::Test +{ +protected: + const std::map operationMap = InitializeOperationMap(); +}; + +// Mirrors: DecodeAgentResponseJsonCorrectly +TEST_F(HeartbeatResponseTest, DecodeActiveWithSessionConfig) +{ + std::string responseJson = R"({ + "operation":"Active", + "sessionConfig": + { + "sessionId":"eca7e870-da2e-45f9-bb66-30d89064313a", + "sessionCookie":"OreoCookie" + }, + "nextScheduledMaintenanceUtc":"2018-04-12T16:58:30.1458776Z", + "nextHeartbeatIntervalMs":30000 + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + EXPECT_EQ(response.sessionId, "eca7e870-da2e-45f9-bb66-30d89064313a"); + EXPECT_EQ(response.sessionCookie, "OreoCookie"); + EXPECT_TRUE(response.hasMaintenanceV1); + EXPECT_EQ(response.maintenanceUtc, "2018-04-12T16:58:30.1458776Z"); + EXPECT_EQ(response.operation, "Active"); + EXPECT_EQ(response.nextHeartbeatIntervalMs, 30000); + + OperationResult opResult = ResolveOperation( + response.operation, GameState::StandingBy, operationMap); + EXPECT_EQ(opResult.newState, GameState::Active); + EXPECT_TRUE(opResult.serverActiveCalled); +} + +// Mirrors: GameState_MaintV2_CallbackInvoked +TEST_F(HeartbeatResponseTest, DecodeMaintenanceV2Schedule) +{ + std::string responseJson = R"({ + "operation":"Active", + "sessionConfig": + { + "sessionId":"eca7e870-da2e-45f9-bb66-30d89064313a", + "sessionCookie":"OreoCookie" + }, + "maintenanceSchedule": + { + "DocumentIncarnation": "IncarnationID", + "Events": + [ + { + "EventId": "eventID", + "EventType": "Reboot", + "ResourceType": "VirtualMachine", + "Resources": + [ + "resourceName" + ], + "EventStatus": "Scheduled", + "NotBefore": "2018-04-12T16:58:30.1458776Z", + "Description": "eventDescription", + "EventSource": "Platform", + "DurationInSeconds": 3600 + } + ] + }, + "nextHeartbeatIntervalMs":30000 + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + EXPECT_TRUE(response.hasMaintenanceV2); + EXPECT_EQ(response.maintenanceSchedule.documentIncarnation, "IncarnationID"); + ASSERT_EQ(response.maintenanceSchedule.events.size(), 1u); + + const auto& event = response.maintenanceSchedule.events[0]; + EXPECT_EQ(event.eventId, "eventID"); + EXPECT_EQ(event.eventType, "Reboot"); + EXPECT_EQ(event.resourceType, "VirtualMachine"); + ASSERT_EQ(event.resources.size(), 1u); + EXPECT_EQ(event.resources[0], "resourceName"); + EXPECT_EQ(event.eventStatus, "Scheduled"); + EXPECT_EQ(event.notBefore, "2018-04-12T16:58:30.1458776Z"); + EXPECT_EQ(event.description, "eventDescription"); + EXPECT_EQ(event.eventSource, "Platform"); + EXPECT_EQ(event.durationInSeconds, 3600); +} + +// Mirrors: JsonDoesntCrashWhenInvalidJson +TEST_F(HeartbeatResponseTest, InvalidOperationFormatHandledGracefully) +{ + std::string responseJson = R"({ + "operation": { + "status": "Active" + }, + "sessionConfig": + { + "sessionId":"eca7e870-da2e-45f9-bb66-30d89064313a", + "sessionCookie":"OreoCookie" + } + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + EXPECT_TRUE(response.operationParseError); + EXPECT_TRUE(response.operation.empty()); +} + +// Mirrors: ReturnInitialPlayerListFromJson +TEST_F(HeartbeatResponseTest, DecodeInitialPlayers) +{ + std::string responseJson = R"({ + "operation":"Active", + "sessionConfig": + { + "sessionId":"eca7e870-da2e-45f9-bb66-30d89064313a", + "sessionCookie":"OreoCookie", + "initialPlayers": + [ + "player0", + "player1", + "player2" + ], + "randomList": + [ + "item1", + "item2" + ] + } + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + ASSERT_EQ(response.initialPlayers.size(), 3u); + EXPECT_EQ(response.initialPlayers[0], "player0"); + EXPECT_EQ(response.initialPlayers[1], "player1"); + EXPECT_EQ(response.initialPlayers[2], "player2"); +} + +// Mirrors: ReturnSessionMetadataFromJson +TEST_F(HeartbeatResponseTest, DecodeSessionMetadata) +{ + std::string responseJson = R"({ + "operation":"Active", + "sessionConfig": + { + "sessionId":"eca7e870-da2e-45f9-bb66-30d89064313a", + "sessionCookie":"OreoCookie", + "metadata": + { + "testKey": "testValue" + } + } + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + EXPECT_EQ(response.configSettings["testKey"], "testValue"); +} + +// Mirrors: AgentOperationStateChangesHandledCorrectly +TEST_F(HeartbeatResponseTest, OperationStateChangesHandledCorrectly) +{ + // Test Active operation + OperationResult result = ResolveOperation("Active", GameState::StandingBy, operationMap); + EXPECT_EQ(result.newState, GameState::Active); + EXPECT_TRUE(result.serverActiveCalled); + EXPECT_FALSE(result.shutdownCalled); + EXPECT_TRUE(result.wasValid); + + // Test unknown operation (not in map) + result = ResolveOperation("TakeOverTheWorld", GameState::Active, operationMap); + EXPECT_TRUE(result.wasError); + EXPECT_EQ(result.newState, GameState::Active); // State unchanged + + // Test known but unhandled operation (GetManifest) + result = ResolveOperation("GetManifest", GameState::Active, operationMap); + EXPECT_FALSE(result.wasValid); + EXPECT_EQ(result.newState, GameState::Active); // State unchanged + + // Test Terminate operation + result = ResolveOperation("Terminate", GameState::Active, operationMap); + EXPECT_EQ(result.newState, GameState::Terminating); + EXPECT_TRUE(result.shutdownCalled); + EXPECT_TRUE(result.wasValid); + + // Test Continue operation (no state change) + result = ResolveOperation("Continue", GameState::Active, operationMap); + EXPECT_EQ(result.newState, GameState::Active); + EXPECT_FALSE(result.shutdownCalled); + EXPECT_FALSE(result.serverActiveCalled); + EXPECT_TRUE(result.wasValid); +} + +TEST_F(HeartbeatResponseTest, ActiveOperationDoesNotRetriggerWhenAlreadyActive) +{ + OperationResult result = ResolveOperation("Active", GameState::Active, operationMap); + EXPECT_EQ(result.newState, GameState::Active); + EXPECT_FALSE(result.serverActiveCalled); +} + +TEST_F(HeartbeatResponseTest, TerminateOperationDoesNotRetriggerWhenAlreadyTerminating) +{ + OperationResult result = ResolveOperation( + "Terminate", GameState::Terminating, operationMap); + EXPECT_EQ(result.newState, GameState::Terminating); + EXPECT_FALSE(result.shutdownCalled); +} + +TEST_F(HeartbeatResponseTest, HeartbeatIntervalParsedCorrectly) +{ + std::string responseJson = R"({ + "operation":"Continue", + "nextHeartbeatIntervalMs":30000 + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + EXPECT_EQ(response.nextHeartbeatIntervalMs, 30000); +} + +TEST_F(HeartbeatResponseTest, HeartbeatIntervalEnforcesMinimum) +{ + // Matches FConfigurationBase::GetMinimumHeartbeatInterval + const int minimumHeartbeatInterval = 1000; + + std::string responseJson = R"({ + "operation":"Continue", + "nextHeartbeatIntervalMs":500 + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + int effectiveInterval = std::max(minimumHeartbeatInterval, + response.nextHeartbeatIntervalMs); + EXPECT_EQ(effectiveInterval, minimumHeartbeatInterval); +} + +TEST_F(HeartbeatResponseTest, MissingHeartbeatIntervalDefaultsToZero) +{ + std::string responseJson = R"({ + "operation":"Continue" + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + EXPECT_EQ(response.nextHeartbeatIntervalMs, 0); +} + +TEST_F(HeartbeatResponseTest, EmptySessionConfigParsesWithoutError) +{ + std::string responseJson = R"({ + "operation":"Active", + "sessionConfig":{} + })"; + + auto response = DecodeHeartbeatResponse(responseJson); + + EXPECT_FALSE(response.operationParseError); + EXPECT_TRUE(response.initialPlayers.empty()); + EXPECT_TRUE(response.sessionId.empty()); + EXPECT_TRUE(response.sessionCookie.empty()); +} diff --git a/UnrealPlugin/ThirdPersonMPGSDKSetup.md b/UnrealPlugin/ThirdPersonMPGSDKSetup.md index 8b3662c8..d116fa91 100644 --- a/UnrealPlugin/ThirdPersonMPGSDKSetup.md +++ b/UnrealPlugin/ThirdPersonMPGSDKSetup.md @@ -29,7 +29,7 @@ Follow these steps to add the Unreal GSDK to your project: * Go to your Unreal game project * Open File Explorer and create a **Plugins** folder in your games' root directory. In the Plugins folder, create a folder called **PlayFabGSDK.** -* Go to **{depot}\\GSDK\\gsdk\\UnrealPlugin**. Drag all the files from the **UnrealPlugin** folder into the **Plugins/PlayFabGSDK** folder. +* Go to **{depot}\\GSDK\\gsdk\\UnrealPlugin**. Copy the **Source** folder, the **Resources** folder, and the **PlayFabGSDK.uplugin** file into the **Plugins/PlayFabGSDK** folder. You do not need the `Tests/`, `TestingProject/`, or `Documentation/` directories. * Lastly, open your game project's **.uproject** file in a text editor of your choice. In the plugins array, add the "PlayFabGSDK" plugin. See the example below: