diff --git a/.github/workflows/godot-tests.yml b/.github/workflows/godot-tests.yml new file mode 100644 index 00000000..2abc00fe --- /dev/null +++ b/.github/workflows/godot-tests.yml @@ -0,0 +1,40 @@ +name: Godot GSDK Tests + +on: + pull_request: + paths: + - 'experimental/godot/**' + - '.github/workflows/godot-tests.yml' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v2 + with: + version: 4.4.1 + use-dotnet: false + include-templates: false + + - name: Install GUT + working-directory: experimental/godot + run: | + GUT_VERSION="9.4.0" + curl -sL "https://github.com/bitwes/Gut/archive/refs/tags/v${GUT_VERSION}.tar.gz" -o gut.tar.gz + tar -xzf gut.tar.gz + cp -r "Gut-${GUT_VERSION}/addons/gut" addons/gut + rm -rf gut.tar.gz "Gut-${GUT_VERSION}" + + - name: Import Godot project + working-directory: experimental/godot + run: godot --headless --import --quit || true + + - name: Run tests + working-directory: experimental/godot + run: godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests/ -ginclude_subdirs -gexit diff --git a/README.md b/README.md index 299e3382..a61539fd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ PlayFab Game Server SDK for C#, C++, and Java environments. The GSDK is used to integrate with PlayFab Multiplayer Servers and modify the game server lifecycle (check [here](https://docs.microsoft.com/en-us/gaming/playfab/features/multiplayer/servers/multiplayer-game-server-lifecycle) for more info). +## Experimental GSDKs + +The [`experimental/`](experimental/) directory contains community-driven GSDK implementations for additional platforms (e.g., Go, Godot). These are **not officially supported** by PlayFab. They are provided on a best-effort basis — for help, please use [GitHub Issues](https://github.com/PlayFab/gsdk/issues) or the [Microsoft Game Dev Discord](https://aka.ms/msftgamedevdiscord). + ## Prerequisites [getting started guide](https://docs.microsoft.com/en-us/gaming/playfab/features/multiplayer/servers/integrating-game-servers-with-gsdk) diff --git a/experimental/godot/.gutconfig.json b/experimental/godot/.gutconfig.json new file mode 100644 index 00000000..b3221308 --- /dev/null +++ b/experimental/godot/.gutconfig.json @@ -0,0 +1,8 @@ +{ + "dirs": ["res://tests/"], + "double_strategy": "partial", + "include_subdirs": true, + "log_level": 1, + "prefix": "test_", + "suffix": ".gd" +} diff --git a/experimental/godot/README.md b/experimental/godot/README.md new file mode 100644 index 00000000..3a3d51f0 --- /dev/null +++ b/experimental/godot/README.md @@ -0,0 +1,125 @@ +# Godot GSDK + +This is an implementation of the PlayFab Game Server SDK (GSDK) for the [Godot Engine](https://godotengine.org/) (4.x) using GDScript. It's considered experimental and is not yet ready for production use or supported. Expect bugs and breaking changes :) + +## Requirements + +- Godot Engine 4.x + +## Installation + +1. Copy the `addons/playfab_gsdk/` directory into your Godot project's `addons/` folder. +2. In the Godot Editor, go to **Project > Project Settings > Plugins** and enable the **PlayFab GSDK** plugin. This will automatically register the `PlayFabGSDK` autoload singleton. + +Alternatively, you can manually add the autoload: +1. Go to **Project > Project Settings > Globals > AutoLoad**. +2. Add `addons/playfab_gsdk/gsdk.gd` with the name `PlayFabGSDK`. + +## Files + +| File | Description | +|------|-------------| +| `gsdk.gd` | Public API — autoload singleton with methods like `start()`, `ready_for_players()`, `register_health_callback()`, etc. | +| `internal_gsdk.gd` | Internal implementation — handles heartbeat loop, state transitions, configuration management, and callbacks. | +| `types.gd` | Type definitions — game state and operation enums, configuration key constants, and serialization helpers. | +| `gsdk_logger.gd` | Logging — writes log messages to both the Godot console and a log file. | +| `playfab_gsdk_plugin.gd` | Editor plugin — registers the `PlayFabGSDK` autoload when the plugin is enabled. | +| `plugin.cfg` | Plugin descriptor for the Godot editor. | + +## Things to remember + +- `ready_for_players()` is async. It blocks using `await` until the game server transitions to Active. Use `await PlayFabGSDK.ready_for_players()`. + +## Configuration + +The GSDK reads its configuration from a JSON file whose path is specified by the `GSDK_CONFIG_FILE` environment variable. This file is automatically provided by the PlayFab VM agent when running on PlayFab Multiplayer Servers. + +Additionally, the following environment variables are read: +- `PF_TITLE_ID` — PlayFab Title ID +- `PF_BUILD_ID` — PlayFab Build ID +- `PF_REGION` — Region where the build is deployed + +## Usage + +Here is a sample of calling the GSDK from your game server's main script: + +```gdscript +extends Node + +func _ready() -> void: + # Register callbacks + PlayFabGSDK.register_health_callback(_on_health_check) + PlayFabGSDK.register_shutdown_callback(_on_shutdown) + PlayFabGSDK.register_maintenance_callback(_on_maintenance) + + # Start the GSDK and wait for allocation + _start_gsdk() + +func _start_gsdk() -> void: + PlayFabGSDK.log_message("Before ReadyForPlayers") + var is_active := await PlayFabGSDK.ready_for_players() + if is_active: + PlayFabGSDK.log_message("Server is now active and ready for players!") + else: + PlayFabGSDK.log_message("Server failed to transition to active state") + +func _on_health_check() -> bool: + # Return true if the server is healthy, false otherwise + return true + +func _on_shutdown() -> void: + PlayFabGSDK.log_message("Server is shutting down") + get_tree().quit() + +func _on_maintenance(maintenance_time: String) -> void: + PlayFabGSDK.log_message("Maintenance scheduled at: %s" % maintenance_time) +``` + +### Updating Connected Players + +```gdscript +# Tell PlayFab about connected players +PlayFabGSDK.update_connected_players([ + {"PlayerId": "player-1"}, + {"PlayerId": "player-2"}, +]) +``` + +## Running Tests + +Unit tests use the [GUT (Godot Unit Testing)](https://github.com/bitwes/Gut) framework. + +### Setup + +1. Install GUT in your Godot project. The easiest way is via the [Godot Asset Library](https://godotengine.org/asset-library/asset/1709): + - In the Godot Editor: **AssetLib > Search "GUT" > Download and Install** + - Or manually: clone [bitwes/Gut](https://github.com/bitwes/Gut) into `addons/gut/` +2. Enable the GUT plugin: **Project > Project Settings > Plugins > Gut > Enable** + +### Running from the Editor + +1. Open the GUT panel: **Project > Tools > GUT** +2. Set the test directory to `res://tests/` +3. Click **Run All** + +### Running from the Command Line + +```bash +godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests/ -gexit +``` + +### Getting Configuration + +```gdscript +# Get all configuration settings +var config := PlayFabGSDK.get_config_settings() +print("Server ID: ", config.get("serverId", "")) +print("Region: ", config.get("region", "")) + +# Get specific directories +var logs_dir := PlayFabGSDK.get_logs_directory() +var shared_dir := PlayFabGSDK.get_shared_content_directory() + +# Get connection info +var conn_info := PlayFabGSDK.get_game_server_connection_info() +``` diff --git a/experimental/godot/addons/playfab_gsdk/gsdk.gd b/experimental/godot/addons/playfab_gsdk/gsdk.gd new file mode 100644 index 00000000..f17625ed --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/gsdk.gd @@ -0,0 +1,108 @@ +extends Node +## PlayFab Game Server SDK (GSDK) for Godot Engine. +## +## Add this script as an Autoload singleton named "PlayFabGSDK" in your Godot project, +## either manually or by enabling the PlayFab GSDK plugin. +## This provides the public API for integrating your Godot game server with +## PlayFab Multiplayer Servers. + +var _internal: Node = null + + +func _ready() -> void: + var InternalGsdk := preload("res://addons/playfab_gsdk/internal_gsdk.gd") + _internal = InternalGsdk.new() + _internal.name = "InternalGSDK" + add_child(_internal) + + +## Starts communication with the PlayFab Multiplayer Servers agent. +## Kicks off the heartbeat loop. This is called automatically by other methods +## if not called explicitly. +func start() -> void: + _internal.start_internal() + + +## Starts communication with debug logging enabled. +func start_with_debug_logs() -> void: + _internal.debug_logs = true + _internal.start_internal() + + +## Transitions the game server state to StandingBy, telling the PlayFab service +## that the game server is ready to accept players.[br] +## [b]This is an async method[/b] — use: [code]await PlayFabGSDK.ready_for_players()[/code][br] +## Returns [code]true[/code] if the game server successfully transitioned to Active state. +func ready_for_players() -> bool: + _internal.start_internal() + if _internal.current_game_state != PlayFabGsdkTypes.GameState.ACTIVE: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + await _internal.transitioned_to_active + return _internal.current_game_state == PlayFabGsdkTypes.GameState.ACTIVE + + +## Logs a message to the GSDK log output. +func log_message(message: String) -> void: + _internal.start_internal() + _internal._logger.log_info(message) + + +## Returns connection information (IP address and ports) for the game server. +func get_game_server_connection_info() -> Dictionary: + _internal.start_internal() + return _internal.configuration.get("gameServerConnectionInfo", {}) + + +## Registers a health check callback. The callback should return [code]true[/code] +## for healthy, [code]false[/code] for unhealthy. It is called on each heartbeat. +func register_health_callback(callback: Callable) -> void: + _internal.start_internal() + _internal.health_callback = callback + + +## Registers a shutdown callback. Called when the server is being terminated +## by the PlayFab agent. +func register_shutdown_callback(callback: Callable) -> void: + _internal.start_internal() + _internal.shutdown_callback = callback + + +## Registers a maintenance callback. Called when a scheduled maintenance event +## is approaching. The callback receives the maintenance datetime as a [String] +## in ISO 8601 / RFC 3339 format. +func register_maintenance_callback(callback: Callable) -> void: + _internal.start_internal() + _internal.maintenance_callback = callback + + +## Returns the directory path for log files that will be uploaded to PlayFab. +func get_logs_directory() -> String: + _internal.start_internal() + return _internal.config_map.get(PlayFabGsdkTypes.LOG_FOLDER_KEY, "") + + +## Returns the shared content directory path shared among all game servers on the VM. +func get_shared_content_directory() -> String: + _internal.start_internal() + return _internal.config_map.get(PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY, "") + + +## Returns the list of initial players that have access to this game server. +## Only available after game server allocation. +func get_initial_players() -> PackedStringArray: + _internal.start_internal() + return _internal.initial_players + + +## Updates the list of connected players. Each element should be a [Dictionary] +## with a [code]"PlayerId"[/code] key.[br] +## Example: [code][{"PlayerId": "player1"}, {"PlayerId": "player2"}][/code] +func update_connected_players(players: Array) -> void: + _internal.start_internal() + _internal.connected_players = players + + +## Returns all configuration settings as a [Dictionary]. +func get_config_settings() -> Dictionary: + _internal.start_internal() + return _internal.config_map diff --git a/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd new file mode 100644 index 00000000..f5adb352 --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/gsdk_logger.gd @@ -0,0 +1,53 @@ +## Logger for the PlayFab GSDK. +## +## Writes log messages to both the Godot console (stdout) and a log file +## in the configured log directory. + +var _log_file: FileAccess = null + + +## Initializes the logger by opening a log file in the specified directory. +func initialize(directory: String) -> void: + if not DirAccess.dir_exists_absolute(directory): + DirAccess.make_dir_recursive_absolute(directory) + + var timestamp := int(Time.get_unix_time_from_system()) + var pid := OS.get_process_id() + var filename := "%s/GSDK_output_%d_%d.txt" % [directory, timestamp, pid] + _log_file = FileAccess.open(filename, FileAccess.WRITE) + if _log_file == null: + push_error("GSDK: Failed to open log file: %s" % filename) + + +## Logs an informational message. +func log_info(message: String) -> void: + var formatted := "[INFO] %s" % message + print(formatted) + _write_to_file(formatted) + + +## Logs a warning message. +func log_warn(message: String) -> void: + var formatted := "[WARN] %s" % message + push_warning(formatted) + _write_to_file(formatted) + + +## Logs an error message. +func log_error(message: String) -> void: + var formatted := "[ERROR] %s" % message + push_error(formatted) + _write_to_file(formatted) + + +## Logs a debug message. +func log_debug(message: String) -> void: + var formatted := "[DEBUG] %s" % message + print(formatted) + _write_to_file(formatted) + + +func _write_to_file(message: String) -> void: + if _log_file != null: + _log_file.store_line(message) + _log_file.flush() diff --git a/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd new file mode 100644 index 00000000..fac880dd --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/internal_gsdk.gd @@ -0,0 +1,285 @@ +extends Node +## Internal GSDK implementation. +## +## Handles heartbeat communication with the PlayFab Multiplayer Servers agent, +## game server state management, and configuration loading. This script is used +## internally by [code]gsdk.gd[/code] and should not be used directly. + +## Emitted when the game server transitions to the Active state. +signal transitioned_to_active + +var current_game_state: PlayFabGsdkTypes.GameState = PlayFabGsdkTypes.GameState.INVALID +var configuration: Dictionary = {} +var config_map: Dictionary = {} +var health_callback: Callable = Callable() +var maintenance_callback: Callable = Callable() +var shutdown_callback: Callable = Callable() +var connected_players: Array = [] +var initial_players: PackedStringArray = [] +var cached_scheduled_maintenance_utc: String = "" +var debug_logs: bool = false + +var _started: bool = false +var _logger := preload("gsdk_logger.gd").new() +var _heartbeat_timer: Timer = null +var _http_request: HTTPRequest = null +var _heartbeat_endpoint: String = "" +var _pending_heartbeat: bool = false +var _gsdkinfo_http_request: HTTPRequest = null + + +## Initializes the GSDK: loads configuration, sets up the heartbeat timer and +## HTTP client, and begins the heartbeat loop. Safe to call multiple times; +## initialization only runs once. +func start_internal() -> void: + if _started: + return + + current_game_state = PlayFabGsdkTypes.GameState.INITIALIZING + + var err := _load_configuration() + if err != "": + push_error("GSDK: Failed to load configuration: %s" % err) + return + + config_map = _create_config_map() + + var log_folder := str(config_map.get(PlayFabGsdkTypes.LOG_FOLDER_KEY, "")) + if log_folder != "": + _logger.initialize(log_folder) + + var heartbeat_endpoint := str(config_map.get(PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY, "")) + var server_id := str(config_map.get(PlayFabGsdkTypes.SERVER_ID_KEY, "")) + + _logger.log_info("VM Agent Endpoint %s" % heartbeat_endpoint) + _logger.log_info("Instance Id %s" % server_id) + + _heartbeat_endpoint = "http://%s/v1/sessionHosts/%s/heartbeats" % [heartbeat_endpoint, server_id] + + # Set up heartbeat timer (fires every 1 second) + _heartbeat_timer = Timer.new() + _heartbeat_timer.wait_time = 1.0 + _heartbeat_timer.autostart = true + _heartbeat_timer.timeout.connect(_on_heartbeat_timer) + add_child(_heartbeat_timer) + + # Set up HTTP request node for heartbeat communication + _http_request = HTTPRequest.new() + _http_request.request_completed.connect(_on_heartbeat_response) + add_child(_http_request) + + # Report GSDK version info (best-effort, matches C#/Java SDK behavior) + _gsdkinfo_http_request = HTTPRequest.new() + _gsdkinfo_http_request.request_completed.connect(_on_gsdkinfo_response) + add_child(_gsdkinfo_http_request) + _send_gsdkinfo(heartbeat_endpoint, server_id) + + _started = true + + +func _send_gsdkinfo(heartbeat_endpoint: String, server_id: String) -> void: + var url := "http://%s/v1/metrics/%s/gsdkinfo" % [heartbeat_endpoint, server_id] + var body := JSON.stringify({ "Flavor": "Godot", "Version": "1.0.0" }) + var headers := PackedStringArray(["Content-Type: application/json"]) + var error := _gsdkinfo_http_request.request(url, headers, HTTPClient.METHOD_POST, body) + if error != OK: + _logger.log_warn("Failed to send GSDK info: %s" % str(error)) + + +func _on_gsdkinfo_response( + result: int, response_code: int, + _headers: PackedStringArray, _body: PackedByteArray +) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + _logger.log_warn("GSDK info report failed (non-fatal): result=%d code=%d" % [result, response_code]) + + +func _on_heartbeat_timer() -> void: + _send_heartbeat() + + +func _send_heartbeat() -> void: + if _pending_heartbeat: + return + + var game_health := false + if health_callback.is_valid(): + game_health = health_callback.call() + + var current_game_health := "Healthy" if game_health else "Unhealthy" + + var players_array: Array = [] + for player in connected_players: + if player is Dictionary: + players_array.append({ "PlayerId": player.get("PlayerId", "") }) + + var heartbeat_request := { + "CurrentGameState": PlayFabGsdkTypes.game_state_to_string(current_game_state), + "CurrentGameHealth": current_game_health, + "CurrentPlayers": players_array, + } + + var json_body := JSON.stringify(heartbeat_request) + var headers := PackedStringArray(["Content-Type: application/json"]) + + _pending_heartbeat = true + var error := _http_request.request( + _heartbeat_endpoint, + headers, + HTTPClient.METHOD_POST, + json_body + ) + + if error != OK: + _logger.log_warn("Error sending heartbeat: %s" % str(error)) + _pending_heartbeat = false + + +func _on_heartbeat_response( + result: int, response_code: int, + _headers: PackedStringArray, body: PackedByteArray +) -> void: + _pending_heartbeat = false + + if result != HTTPRequest.RESULT_SUCCESS: + _logger.log_warn("Error receiving heartbeat response: %d" % result) + return + + if response_code < 200 or response_code >= 300: + _logger.log_error("Error in heartbeat response: %d" % response_code) + return + + var json_string := body.get_string_from_utf8() + var json := JSON.new() + var parse_result := json.parse(json_string) + if parse_result != OK: + _logger.log_warn("Error parsing heartbeat response: %s" % json.get_error_message()) + return + + var response: Dictionary = json.data + _update_state_from_heartbeat(response) + + if debug_logs: + _logger.log_debug("Heartbeat response: %s" % json_string) + + +func _update_state_from_heartbeat(response: Dictionary) -> void: + # Process session config + if response.has("sessionConfig") and response["sessionConfig"] is Dictionary: + var session_config: Dictionary = response["sessionConfig"] + + if session_config.has("sessionCookie"): + config_map[PlayFabGsdkTypes.SESSION_COOKIE_KEY] = str(session_config["sessionCookie"]) + if session_config.has("sessionId"): + config_map[PlayFabGsdkTypes.SESSION_ID_KEY] = str(session_config["sessionId"]) + + if session_config.has("initialPlayers") and session_config["initialPlayers"] is Array: + var players: Array = session_config["initialPlayers"] + if players.size() > 0: + var string_players: PackedStringArray = [] + for p in players: + string_players.append(str(p)) + initial_players = string_players + + if session_config.has("metadata") and session_config["metadata"] is Dictionary: + var metadata: Dictionary = session_config["metadata"] + for key in metadata: + config_map[key] = str(metadata[key]) + + # Process scheduled maintenance (new format first, legacy fallback) + var maintenance_time := "" + if response.has("maintenanceSchedule") and response["maintenanceSchedule"] is Dictionary: + var schedule: Dictionary = response["maintenanceSchedule"] + # Check both "Events" (JsonProperty override) and "events" (camelCase) + var events_key := "Events" if schedule.has("Events") else "events" + if schedule.has(events_key) and schedule[events_key] is Array: + var events: Array = schedule[events_key] + if events.size() > 0 and events[0] is Dictionary: + var first_event: Dictionary = events[0] + if first_event.has("notBefore"): + maintenance_time = str(first_event["notBefore"]) + if maintenance_time == "" and response.has("nextScheduledMaintenanceUtc"): + maintenance_time = str(response["nextScheduledMaintenanceUtc"]) + if maintenance_time != "" and maintenance_time != cached_scheduled_maintenance_utc: + cached_scheduled_maintenance_utc = maintenance_time + if maintenance_callback.is_valid(): + maintenance_callback.call(maintenance_time) + + # Process operation from agent + if response.has("operation"): + var operation := PlayFabGsdkTypes.string_to_game_operation(str(response["operation"])) + match operation: + PlayFabGsdkTypes.GameOperation.CONTINUE: + pass + PlayFabGsdkTypes.GameOperation.ACTIVE: + if current_game_state != PlayFabGsdkTypes.GameState.ACTIVE: + current_game_state = PlayFabGsdkTypes.GameState.ACTIVE + transitioned_to_active.emit() + _send_heartbeat() + PlayFabGsdkTypes.GameOperation.TERMINATE: + if current_game_state != PlayFabGsdkTypes.GameState.TERMINATING: + current_game_state = PlayFabGsdkTypes.GameState.TERMINATING + _send_heartbeat() + if shutdown_callback.is_valid(): + shutdown_callback.call() + # Unblock ready_for_players() if it is waiting + transitioned_to_active.emit() + + +func _load_configuration() -> String: + var config_file := OS.get_environment("GSDK_CONFIG_FILE") + if config_file == "": + return "GSDK_CONFIG_FILE environment variable not set" + + if not FileAccess.file_exists(config_file): + return "Configuration file not found: %s" % config_file + + var file := FileAccess.open(config_file, FileAccess.READ) + if file == null: + return "Failed to open configuration file: %s" % config_file + + var json_string := file.get_as_text() + file.close() + + var json := JSON.new() + var parse_result := json.parse(json_string) + if parse_result != OK: + return "Failed to parse configuration file: %s" % json.get_error_message() + + if not json.data is Dictionary: + return "Configuration file does not contain a valid JSON object" + + configuration = json.data + return "" + + +func _create_config_map() -> Dictionary: + var cmap: Dictionary = {} + + if configuration.has("gameCertificates") and configuration["gameCertificates"] is Dictionary: + for key in configuration["gameCertificates"]: + cmap[key] = str(configuration["gameCertificates"][key]) + + if configuration.has("buildMetadata") and configuration["buildMetadata"] is Dictionary: + for key in configuration["buildMetadata"]: + cmap[key] = str(configuration["buildMetadata"][key]) + + if configuration.has("gamePorts") and configuration["gamePorts"] is Dictionary: + for key in configuration["gamePorts"]: + cmap[key] = str(configuration["gamePorts"][key]) + + cmap[PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY] = str(configuration.get("heartbeatEndpoint", "")) + cmap[PlayFabGsdkTypes.SERVER_ID_KEY] = str(configuration.get("sessionHostId", "")) + cmap[PlayFabGsdkTypes.VM_ID_KEY] = str(configuration.get("vmId", "")) + cmap[PlayFabGsdkTypes.LOG_FOLDER_KEY] = str(configuration.get("logFolder", "")) + var shared_content_folder := str( + configuration.get("sharedContentFolder", "")) + cmap[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY] = shared_content_folder + cmap[PlayFabGsdkTypes.CERTIFICATE_FOLDER_KEY] = str(configuration.get("certificateFolder", "")) + cmap[PlayFabGsdkTypes.TITLE_ID_KEY] = OS.get_environment("PF_TITLE_ID") + cmap[PlayFabGsdkTypes.BUILD_ID_KEY] = OS.get_environment("PF_BUILD_ID") + cmap[PlayFabGsdkTypes.REGION_KEY] = OS.get_environment("PF_REGION") + cmap[PlayFabGsdkTypes.IPV4_ADDRESS_KEY] = str(configuration.get("publicIpV4Address", "")) + cmap[PlayFabGsdkTypes.FQDN_KEY] = str(configuration.get("fullyQualifiedDomainName", "")) + + return cmap diff --git a/experimental/godot/addons/playfab_gsdk/playfab_gsdk_plugin.gd b/experimental/godot/addons/playfab_gsdk/playfab_gsdk_plugin.gd new file mode 100644 index 00000000..eff072dc --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/playfab_gsdk_plugin.gd @@ -0,0 +1,14 @@ +@tool +extends EditorPlugin +## PlayFab GSDK editor plugin. +## +## Automatically registers the PlayFabGSDK autoload singleton when the plugin +## is enabled, and removes it when disabled. + + +func _enter_tree() -> void: + add_autoload_singleton("PlayFabGSDK", "res://addons/playfab_gsdk/gsdk.gd") + + +func _exit_tree() -> void: + remove_autoload_singleton("PlayFabGSDK") diff --git a/experimental/godot/addons/playfab_gsdk/plugin.cfg b/experimental/godot/addons/playfab_gsdk/plugin.cfg new file mode 100644 index 00000000..e5b26e58 --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="PlayFab GSDK" +description="PlayFab Game Server SDK for integrating Godot game servers with PlayFab Multiplayer Servers." +author="PlayFab" +version="0.1.0" +script="playfab_gsdk_plugin.gd" diff --git a/experimental/godot/addons/playfab_gsdk/types.gd b/experimental/godot/addons/playfab_gsdk/types.gd new file mode 100644 index 00000000..13f6acca --- /dev/null +++ b/experimental/godot/addons/playfab_gsdk/types.gd @@ -0,0 +1,74 @@ +class_name PlayFabGsdkTypes +## Type definitions for the PlayFab Game Server SDK. +## +## Contains game state and operation enums, configuration key constants, +## and utility methods for serialization. + + +## Game server lifecycle states. +enum GameState { + INVALID, + INITIALIZING, + STANDING_BY, + ACTIVE, + TERMINATING, + TERMINATED, + QUARANTINED, +} + + +## Game operations received from the PlayFab agent via heartbeat responses. +enum GameOperation { + INVALID, + CONTINUE, + ACTIVE, + TERMINATE, +} + + +## Configuration key constants matching the PlayFab GSDK specification. +const SESSION_COOKIE_KEY := "sessionCookie" +const SESSION_ID_KEY := "sessionId" +const HEARTBEAT_ENDPOINT_KEY := "heartbeatEndpoint" +const SERVER_ID_KEY := "serverId" +const LOG_FOLDER_KEY := "logFolder" +const SHARED_CONTENT_FOLDER_KEY := "sharedContentFolder" +const CERTIFICATE_FOLDER_KEY := "certificateFolder" +const TITLE_ID_KEY := "titleId" +const BUILD_ID_KEY := "buildId" +const REGION_KEY := "region" +const VM_ID_KEY := "vmId" +const IPV4_ADDRESS_KEY := "publicIpV4Address" +const FQDN_KEY := "fullyQualifiedDomainName" + + +## Converts a [enum GameState] value to its string representation for JSON serialization. +static func game_state_to_string(state: GameState) -> String: + match state: + GameState.INVALID: + return "Invalid" + GameState.INITIALIZING: + return "Initializing" + GameState.STANDING_BY: + return "StandingBy" + GameState.ACTIVE: + return "Active" + GameState.TERMINATING: + return "Terminating" + GameState.TERMINATED: + return "Terminated" + GameState.QUARANTINED: + return "Quarantined" + return "Invalid" + + +## Converts a JSON operation string to a [enum GameOperation] value. +static func string_to_game_operation(s: String) -> GameOperation: + match s: + "Continue": + return GameOperation.CONTINUE + "Active": + return GameOperation.ACTIVE + "Terminate": + return GameOperation.TERMINATE + return GameOperation.INVALID diff --git a/experimental/godot/project.godot b/experimental/godot/project.godot new file mode 100644 index 00000000..ad954d88 --- /dev/null +++ b/experimental/godot/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; but it can also be manually edited with care. + +[application] + +config/name="PlayFab GSDK Tests" +config/features=PackedStringArray("4.4") + +[autoload] + +PlayFabGSDK="*res://addons/playfab_gsdk/gsdk.gd" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/playfab_gsdk/plugin.cfg") diff --git a/experimental/godot/tests/test_gsdk_logger.gd b/experimental/godot/tests/test_gsdk_logger.gd new file mode 100644 index 00000000..f89d6351 --- /dev/null +++ b/experimental/godot/tests/test_gsdk_logger.gd @@ -0,0 +1,105 @@ +extends GutTest +## Unit tests for the GSDK logger. + +const GsdkLogger := preload("res://addons/playfab_gsdk/gsdk_logger.gd") + +var _logger = null +var _test_dir: String = "" + + +func before_each() -> void: + _logger = GsdkLogger.new() + _test_dir = "user://test_logs_%d" % randi() + + +func after_each() -> void: + _logger = null + if _test_dir != "" and DirAccess.dir_exists(_test_dir): + var dir := DirAccess.open(_test_dir) + if dir != null: + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + dir.remove(file_name) + file_name = dir.get_next() + dir.list_dir_end() + DirAccess.remove_absolute(_test_dir) + + +func test_initialize_creates_directory() -> void: + assert_false(DirAccess.dir_exists(_test_dir)) + _logger.initialize(_test_dir) + assert_true(DirAccess.dir_exists(_test_dir)) + + +func test_initialize_creates_log_file() -> void: + _logger.initialize(_test_dir) + var dir := DirAccess.open(_test_dir) + assert_not_null(dir, "Should be able to open test log directory") + dir.list_dir_begin() + var file_name := dir.get_next() + var found_log := false + while file_name != "": + if file_name.begins_with("GSDK_output_") and file_name.ends_with(".txt"): + found_log = true + break + file_name = dir.get_next() + dir.list_dir_end() + assert_true(found_log, "Log file should be created") + + +func test_log_info_writes_to_file() -> void: + _logger.initialize(_test_dir) + _logger.log_info("test info message") + var content := _read_log_content() + assert_string_contains(content, "[INFO] test info message") + + +func test_log_warn_writes_to_file() -> void: + _logger.initialize(_test_dir) + _logger.log_warn("test warning") + var content := _read_log_content() + assert_string_contains(content, "[WARN] test warning") + + +func test_log_error_writes_to_file() -> void: + _logger.initialize(_test_dir) + _logger.log_error("test error") + var content := _read_log_content() + assert_string_contains(content, "[ERROR] test error") + + +func test_log_debug_writes_to_file() -> void: + _logger.initialize(_test_dir) + _logger.log_debug("test debug") + var content := _read_log_content() + assert_string_contains(content, "[DEBUG] test debug") + + +func test_log_without_initialize_does_not_crash() -> void: + # Logger should handle gracefully when not initialized (no file) + _logger.log_info("no crash expected") + _logger.log_warn("no crash expected") + _logger.log_error("no crash expected") + _logger.log_debug("no crash expected") + pass_test("Logger did not crash without initialization") + + +func _read_log_content() -> String: + var dir := DirAccess.open(_test_dir) + if dir == null: + return "" + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + if file_name.begins_with("GSDK_output_") and file_name.ends_with(".txt"): + var path := _test_dir + "/" + file_name + var file := FileAccess.open(path, FileAccess.READ) + if file != null: + var content := file.get_as_text() + file.close() + dir.list_dir_end() + return content + file_name = dir.get_next() + dir.list_dir_end() + return "" diff --git a/experimental/godot/tests/test_internal_gsdk.gd b/experimental/godot/tests/test_internal_gsdk.gd new file mode 100644 index 00000000..4d6c2215 --- /dev/null +++ b/experimental/godot/tests/test_internal_gsdk.gd @@ -0,0 +1,331 @@ +extends GutTest +## Unit tests for internal GSDK logic. + +var _internal: Node = null + +const InternalGsdk := preload("res://addons/playfab_gsdk/internal_gsdk.gd") + + +func before_each() -> void: + _internal = InternalGsdk.new() + _internal.name = "TestInternalGSDK" + add_child(_internal) + + +func after_each() -> void: + if _internal != null: + _internal.queue_free() + _internal = null + + +# --- _create_config_map tests --- + + +func test_create_config_map_extracts_all_fields() -> void: + _internal.configuration = { + "heartbeatEndpoint": "localhost:56001", + "sessionHostId": "test-host-id", + "vmId": "test-vm-id", + "logFolder": "/tmp/logs", + "sharedContentFolder": "/shared", + "certificateFolder": "/certs", + "publicIpV4Address": "10.0.0.1", + "fullyQualifiedDomainName": "test.playfab.com", + "gameCertificates": { "cert1": "/path/to/cert" }, + "buildMetadata": { "version": "1.0" }, + "gamePorts": { "game_port": "7777" }, + } + var config := _internal._create_config_map() + + assert_eq(config[PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY], "localhost:56001") + assert_eq(config[PlayFabGsdkTypes.SERVER_ID_KEY], "test-host-id") + assert_eq(config[PlayFabGsdkTypes.VM_ID_KEY], "test-vm-id") + assert_eq(config[PlayFabGsdkTypes.LOG_FOLDER_KEY], "/tmp/logs") + assert_eq(config[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY], "/shared") + assert_eq(config[PlayFabGsdkTypes.CERTIFICATE_FOLDER_KEY], "/certs") + assert_eq(config[PlayFabGsdkTypes.IPV4_ADDRESS_KEY], "10.0.0.1") + assert_eq(config[PlayFabGsdkTypes.FQDN_KEY], "test.playfab.com") + assert_eq(config["cert1"], "/path/to/cert") + assert_eq(config["version"], "1.0") + assert_eq(config["game_port"], "7777") + + +func test_create_config_map_handles_missing_optional_fields() -> void: + _internal.configuration = {} + var config := _internal._create_config_map() + + assert_eq(config[PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY], "") + assert_eq(config[PlayFabGsdkTypes.SERVER_ID_KEY], "") + assert_eq(config[PlayFabGsdkTypes.VM_ID_KEY], "") + assert_eq(config[PlayFabGsdkTypes.LOG_FOLDER_KEY], "") + assert_eq(config[PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY], "") + assert_eq(config[PlayFabGsdkTypes.CERTIFICATE_FOLDER_KEY], "") + assert_eq(config[PlayFabGsdkTypes.IPV4_ADDRESS_KEY], "") + assert_eq(config[PlayFabGsdkTypes.FQDN_KEY], "") + + +func test_create_config_map_skips_non_dict_game_certs() -> void: + _internal.configuration = { + "gameCertificates": "not_a_dict", + } + var config := _internal._create_config_map() + # Should not crash and should not contain gameCertificates entries + assert_false(config.has("not_a_dict")) + + +func test_create_config_map_skips_non_dict_build_metadata() -> void: + _internal.configuration = { + "buildMetadata": 42, + } + var config := _internal._create_config_map() + assert_eq(config[PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY], "") + + +func test_create_config_map_skips_non_dict_game_ports() -> void: + _internal.configuration = { + "gamePorts": [], + } + var config := _internal._create_config_map() + assert_eq(config[PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY], "") + + +# --- _update_state_from_heartbeat tests --- + + +func test_heartbeat_continue_keeps_state() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + _internal._update_state_from_heartbeat({ + "operation": "Continue", + }) + assert_eq( + _internal.current_game_state, + PlayFabGsdkTypes.GameState.STANDING_BY, + ) + + +func test_heartbeat_active_transitions_state() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + _internal._update_state_from_heartbeat({ + "operation": "Active", + }) + assert_eq( + _internal.current_game_state, + PlayFabGsdkTypes.GameState.ACTIVE, + ) + + +func test_heartbeat_active_emits_signal() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + watch_signals(_internal) + _internal._update_state_from_heartbeat({ + "operation": "Active", + }) + assert_signal_emitted(_internal, "transitioned_to_active") + + +func test_heartbeat_active_no_double_transition() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.ACTIVE + watch_signals(_internal) + _internal._update_state_from_heartbeat({ + "operation": "Active", + }) + assert_signal_not_emitted(_internal, "transitioned_to_active") + + +func test_heartbeat_terminate_transitions_state() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.ACTIVE + _internal._update_state_from_heartbeat({ + "operation": "Terminate", + }) + assert_eq( + _internal.current_game_state, + PlayFabGsdkTypes.GameState.TERMINATING, + ) + + +func test_heartbeat_terminate_calls_shutdown_callback() -> void: + var was_called := false + _internal.shutdown_callback = func() -> void: + was_called = true + _internal.current_game_state = PlayFabGsdkTypes.GameState.ACTIVE + _internal._update_state_from_heartbeat({ + "operation": "Terminate", + }) + assert_true(was_called, "Shutdown callback should have been called") + + +func test_heartbeat_terminate_emits_signal_to_unblock() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + watch_signals(_internal) + _internal._update_state_from_heartbeat({ + "operation": "Terminate", + }) + assert_signal_emitted(_internal, "transitioned_to_active") + + +func test_heartbeat_terminate_no_double_transition() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.TERMINATING + watch_signals(_internal) + _internal._update_state_from_heartbeat({ + "operation": "Terminate", + }) + assert_signal_not_emitted(_internal, "transitioned_to_active") + + +func test_heartbeat_processes_session_cookie() -> void: + _internal.config_map = {} + _internal._update_state_from_heartbeat({ + "sessionConfig": { + "sessionCookie": "my-cookie-value", + }, + }) + assert_eq( + _internal.config_map.get(PlayFabGsdkTypes.SESSION_COOKIE_KEY, ""), + "my-cookie-value", + ) + + +func test_heartbeat_processes_session_id() -> void: + _internal.config_map = {} + _internal._update_state_from_heartbeat({ + "sessionConfig": { + "sessionId": "abc-123", + }, + }) + assert_eq( + _internal.config_map.get(PlayFabGsdkTypes.SESSION_ID_KEY, ""), + "abc-123", + ) + + +func test_heartbeat_processes_initial_players() -> void: + _internal._update_state_from_heartbeat({ + "sessionConfig": { + "initialPlayers": ["player1", "player2", "player3"], + }, + }) + assert_eq(_internal.initial_players.size(), 3) + assert_eq(_internal.initial_players[0], "player1") + assert_eq(_internal.initial_players[1], "player2") + assert_eq(_internal.initial_players[2], "player3") + + +func test_heartbeat_empty_initial_players_not_overwritten() -> void: + _internal.initial_players = PackedStringArray(["existing"]) + _internal._update_state_from_heartbeat({ + "sessionConfig": { + "initialPlayers": [], + }, + }) + assert_eq(_internal.initial_players.size(), 1) + assert_eq(_internal.initial_players[0], "existing") + + +func test_heartbeat_processes_metadata() -> void: + _internal.config_map = {} + _internal._update_state_from_heartbeat({ + "sessionConfig": { + "metadata": { + "mapName": "de_dust2", + "gameMode": "competitive", + }, + }, + }) + assert_eq(_internal.config_map.get("mapName", ""), "de_dust2") + assert_eq(_internal.config_map.get("gameMode", ""), "competitive") + + +func test_heartbeat_processes_maintenance_callback() -> void: + var received_time := "" + _internal.maintenance_callback = func(time: String) -> void: + received_time = time + _internal._update_state_from_heartbeat({ + "nextScheduledMaintenanceUtc": "2026-01-15T10:00:00Z", + }) + assert_eq(received_time, "2026-01-15T10:00:00Z") + assert_eq( + _internal.cached_scheduled_maintenance_utc, + "2026-01-15T10:00:00Z", + ) + + +func test_heartbeat_processes_maintenance_schedule() -> void: + var received_time := "" + _internal.maintenance_callback = func(time: String) -> void: + received_time = time + _internal._update_state_from_heartbeat({ + "maintenanceSchedule": { + "events": [ + { + "eventId": "evt-1", + "eventType": "Reboot", + "notBefore": "2026-02-20T14:00:00Z", + "description": "Scheduled reboot", + }, + ], + }, + }) + assert_eq(received_time, "2026-02-20T14:00:00Z") + assert_eq( + _internal.cached_scheduled_maintenance_utc, + "2026-02-20T14:00:00Z", + ) + + +func test_heartbeat_maintenance_schedule_takes_precedence() -> void: + var received_time := "" + _internal.maintenance_callback = func(time: String) -> void: + received_time = time + _internal._update_state_from_heartbeat({ + "maintenanceSchedule": { + "events": [ + { + "notBefore": "2026-03-01T08:00:00Z", + }, + ], + }, + "nextScheduledMaintenanceUtc": "2026-01-15T10:00:00Z", + }) + assert_eq(received_time, "2026-03-01T08:00:00Z") + + +func test_heartbeat_maintenance_not_called_if_unchanged() -> void: + var call_count := 0 + _internal.maintenance_callback = func(_time: String) -> void: + call_count += 1 + _internal.cached_scheduled_maintenance_utc = "2026-01-15T10:00:00Z" + _internal._update_state_from_heartbeat({ + "nextScheduledMaintenanceUtc": "2026-01-15T10:00:00Z", + }) + assert_eq(call_count, 0, "Callback should not fire for same time") + + +func test_heartbeat_handles_missing_fields_gracefully() -> void: + _internal.current_game_state = PlayFabGsdkTypes.GameState.STANDING_BY + _internal.config_map = {} + _internal._update_state_from_heartbeat({}) + assert_eq( + _internal.current_game_state, + PlayFabGsdkTypes.GameState.STANDING_BY, + ) + + +func test_heartbeat_ignores_non_dict_session_config() -> void: + _internal.config_map = {} + _internal._update_state_from_heartbeat({ + "sessionConfig": "not_a_dict", + }) + assert_false(_internal.config_map.has(PlayFabGsdkTypes.SESSION_COOKIE_KEY)) + + +# --- start_internal guard test --- + + +func test_start_internal_is_idempotent() -> void: + # Without GSDK_CONFIG_FILE set, start_internal should fail but not crash. + # Calling it twice should not cause issues. + _internal.start_internal() + _internal.start_internal() + # The state should still be INITIALIZING since config load failed + # but start_internal should not crash on repeated calls. + pass diff --git a/experimental/godot/tests/test_types.gd b/experimental/godot/tests/test_types.gd new file mode 100644 index 00000000..1d81f9e6 --- /dev/null +++ b/experimental/godot/tests/test_types.gd @@ -0,0 +1,102 @@ +extends GutTest +## Unit tests for [PlayFabGsdkTypes]. + + +func test_game_state_to_string_invalid() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.INVALID), + "Invalid", + ) + + +func test_game_state_to_string_initializing() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.INITIALIZING), + "Initializing", + ) + + +func test_game_state_to_string_standing_by() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.STANDING_BY), + "StandingBy", + ) + + +func test_game_state_to_string_active() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.ACTIVE), + "Active", + ) + + +func test_game_state_to_string_terminating() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.TERMINATING), + "Terminating", + ) + + +func test_game_state_to_string_terminated() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.TERMINATED), + "Terminated", + ) + + +func test_game_state_to_string_quarantined() -> void: + assert_eq( + PlayFabGsdkTypes.game_state_to_string(PlayFabGsdkTypes.GameState.QUARANTINED), + "Quarantined", + ) + + +func test_string_to_game_operation_continue() -> void: + assert_eq( + PlayFabGsdkTypes.string_to_game_operation("Continue"), + PlayFabGsdkTypes.GameOperation.CONTINUE, + ) + + +func test_string_to_game_operation_active() -> void: + assert_eq( + PlayFabGsdkTypes.string_to_game_operation("Active"), + PlayFabGsdkTypes.GameOperation.ACTIVE, + ) + + +func test_string_to_game_operation_terminate() -> void: + assert_eq( + PlayFabGsdkTypes.string_to_game_operation("Terminate"), + PlayFabGsdkTypes.GameOperation.TERMINATE, + ) + + +func test_string_to_game_operation_unknown_returns_invalid() -> void: + assert_eq( + PlayFabGsdkTypes.string_to_game_operation("UnknownOp"), + PlayFabGsdkTypes.GameOperation.INVALID, + ) + + +func test_string_to_game_operation_empty_returns_invalid() -> void: + assert_eq( + PlayFabGsdkTypes.string_to_game_operation(""), + PlayFabGsdkTypes.GameOperation.INVALID, + ) + + +func test_config_key_constants() -> void: + assert_eq(PlayFabGsdkTypes.SESSION_COOKIE_KEY, "sessionCookie") + assert_eq(PlayFabGsdkTypes.SESSION_ID_KEY, "sessionId") + assert_eq(PlayFabGsdkTypes.HEARTBEAT_ENDPOINT_KEY, "heartbeatEndpoint") + assert_eq(PlayFabGsdkTypes.SERVER_ID_KEY, "serverId") + assert_eq(PlayFabGsdkTypes.LOG_FOLDER_KEY, "logFolder") + assert_eq(PlayFabGsdkTypes.SHARED_CONTENT_FOLDER_KEY, "sharedContentFolder") + assert_eq(PlayFabGsdkTypes.CERTIFICATE_FOLDER_KEY, "certificateFolder") + assert_eq(PlayFabGsdkTypes.TITLE_ID_KEY, "titleId") + assert_eq(PlayFabGsdkTypes.BUILD_ID_KEY, "buildId") + assert_eq(PlayFabGsdkTypes.REGION_KEY, "region") + assert_eq(PlayFabGsdkTypes.VM_ID_KEY, "vmId") + assert_eq(PlayFabGsdkTypes.IPV4_ADDRESS_KEY, "publicIpV4Address") + assert_eq(PlayFabGsdkTypes.FQDN_KEY, "fullyQualifiedDomainName")